Hermes Agent Deep Cuts: hermes send Returns Exit 0 While Sending Nothing
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: hermes send Returns Exit 0 While Sending Nothing

Run this inside a scheduled Hermes job and you watch the tool report success for a message it never sends:

{
  "success": true,
  "skipped": true,
  "reason": "cron_auto_delivery_duplicate_target",
  "target": "telegram:1033877751",
  "note": "Skipped send_message to telegram:1033877751. This cron job will already auto-deliver its final response to that same target. Put the intended user-facing content in your final response instead, or use a different target if you want an additional message."
}

Exit code 0. Your good-or-bad branching sees success. Telegram saw nothing. Most scripts end there and most operators never know. The uncomfortable truth is the decision is correct, the tool is not buggy, and you still cannot trust the shell exit code to mean a message left your machine. hermes send is not a free-curl toy from the docs. It is a thin wrapper over the same delivery router that cron and the messaging gateway share, and that router reserves the right to judge your message redundant before any bytes move.

The mechanism: one tool, three callers, no model in the loop

The CLI is deliberately small. hermes_cli/send_cmd.py reads your message from the positional arg, --file, or stdin, then calls one function: send_message_tool from tools/send_message_tool.py. That is the same entry point that cron delivery, the kanban notifier, and the opt-in MCP server all call. The source comment is blunt that send_message is intentionally NOT registered as an agent-callable model tool, because an autonomous agent firing cross-platform messages on its own is exactly the failure modality this whole design avoids. So hermes send is the manual override for a path that normally runs with no model in the loop at all.

Before that call, the wrapper does environment injection that owners of odd setups will feel. It loads ~/.hermes/.env with override=True and a charset that strips a leading BOM, because a .env written in PowerShell 5.1 or Notepad puts U+FEFF on the first key name and plain UTF-8 silently drops that key from os.environ. Then it bridges top-level config.yaml scalars into the environment, never overriding values already present, and runs them through managed_scope so administrator-pinned values stay pinned even on the CLI path. The downstream gateway config loader reads platform credentials out of os.environ, so this one function is what makes hermes send reuse all your existing credentials with no second configuration surface.

The interesting part is what the tool does not do. There is no curl to Telegram hardcoded anywhere in the front door. The delivery is routed through _send_to_platform, which picks a strategy per platform: Telegram chunks its own formatted text, Signal uses its own message-length constant, Discord and the plugin media platforms dispatch to their standalone_sender_fn, and the generic path drops media with a warning. Your message is not sent by one API call. It is sent by whichever strategy your platform name resolves to, and the resolution is where the subtleties pile up.

Target resolution is a parser ladder

_resolve_tool_target splits your --to on the first colon, lowercases the platform, and passes the rest to resolve_send_target. That function runs a per-platform parser first, then falls through to a set of generic rules, then to the channel directory. The order matters because parsers take precedence over everything.

A numeric Telegram target matches _TELEGRAM_TOPIC_TARGET_RE, chat_id[:topic_id]. A bare @username gets parsed as a username and is never force-cast to an int, which is the kind of bug they clearly hit before adding that guard. Slack has several target forms, numbered here because the order matters: public/private/DM conversation IDs, user:U... targets, <@U...> mentions, @handle, and a <id>:<thread_ts> thread form. A bare Slack U... id is rewritten to user:U... and opened as a DM conversation through conversations.open before anything is sent, because posting straight to a U/W id fails. A matrix:@user:server.org or bare !room is explicit. An E.164 number like +15551234567 keeps its plus sign, since the digit check on its own would reject it and the channel directory cannot resolve a raw phone number. WhatsApp JIDs, Buzz UUIDs, and valid email addresses arrive verbatim and are never treated as home-channel targets.

If no parser claims the reference, the resolver consults ~/.hermes/channel_directory.json, the cache the gateway refreshes every few minutes. That is how discord:#ops and slack:#eng turn into numeric IDs, and it means a human-friendly name that has not been discovered yet will not resolve. If you run hermes send before the gateway ever populated that file, you get a pointer to run hermes gateway start once so discovery can run, and --list still merges in configured-but-undiscovered platforms so a fresh SimpleX or Weixin setup used only for outbound sends never gets hidden.

A bare platform name with nothing after the colon skips resolution entirely and falls back to the home channel from gateway config. My demo above resolved bare telegram to telegram:1033877751, which is why it hit the skip path. If you have no home channel set, you get an error that names the exact config key to fix, like hermes config set TELEGRAM_HOME_CHANNEL <channel_id>. The same ladder runs for cron delivery and the MCP server, so one behavior everywhere is not a bug, it is the design holding.

The media path is where files actually ship

Here is the gotcha that wastes the most time. --file reads the body as text only. Point it at a binary and you get exit 2 with a usage error pointing you at the actual attachment syntax:

hermes send: /bin/ls is not a text file. --file reads the message *body* (logs, reports, markdown).
To send an image/document/audio file as a native attachment, reference it with MEDIA: in the message text instead:
 hermes send --to telegram "MEDIA:/bin/ls"
 hermes send --to telegram "optional caption MEDIA:/bin/ls"
Add [[as_document]] to deliver an image as an uncompressed file:
 hermes send --to telegram "[[as_document]] MEDIA:/bin/ls"

So attachments are MEDIA:<path> tags inside the message text, not a --file flag. extract_media pulls every tag out, strips the [[audio_as_voice]] and [[as_document]] directives from the text, and dedupes on the expanded path so a file referenced twice uploads once. It scans a masked copy of your text, which means a MEDIA: path inside a fenced code block, a blockquote, or a JSON string value is never delivered. That mask is the thing that stops a code sample in your prose from becoming an upload attempt. Every path then passes filter_media_delivery_paths, which validates against a credential and system deny-list, so you cannot attach ~/.ssh/id_ed25519 and expect it to fly.

[[as_document]] forces an uncompressed sendDocument instead of the platform guessing from the extension. [[audio_as_voice]] flags an audio file as a voice note, only when the extension is actually audio, because a voice-flagged image would leave your photo batch broken. Text rides on the media bubble as a caption only when exactly one captionable file exists and the text fits the platform limit, 1024 characters for Telegram photos and video. Any other shape, multiple files, oversize text, a voice note, and the text becomes a separate message instead. Multi-file caption association is ambiguous, so the system refuses to guess.

Native media delivery is not universal. The supported list is telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp, and slack. Send to anything else with only media and no text and the tool errors that MEDIA delivery is unsupported there. With both text and media on an unsupported platform, it sends the text and appends a warning that the attachments were omitted. It tells you, it does not silently drop the message.

The gotcha that makes it look broken

The cron duplicate-skip is the one that will bite you hardest because the report is structurally a success. _maybe_skip_cron_duplicate_send reads the session environment variables that the scheduler sets on HERMES_CRON_AUTO_DELIVER_*. When a cron job is configured to auto-deliver its final response to a platform and chat, and your run calls hermes send to that exact same target including thread id, the tool returns {"success": true, "skipped": true} and sends nothing. Exit 0. The thinking is sound: the scheduler will post your final response there anyway, so a manual send_message to the same place is a duplicate. The trap is that the shell exit code stays 0, so any script that branches on $? treats a dropped message as a delivered one. The human-readable run does print the skip note, but only --json hands the skipped flag to a program that can act on it. The folks who pipe output to a webhook and branch on code, the ones who will not notice until their downstream systems stop replying.

The reason it is a trap and not a clean refusal is deliberate. A hard refusal would abort the cron job. A skip keeps the run green while handing you a JSON field you have to read. For an agent that “feels” correct, the advice is right there in the note: put the user-facing content in the final response instead, or target a different chat if you actually want an extra message. I reproduced the exact payload twice on 2026-09-15, once with the explicit chat id and once with bare telegram, and both resolved to telegram:1033877751 and both skipped. That is how you get the message across: the skip follows the real resolved target, not the exact string you typed.

The relay egress guard is the other quiet surface. When the destination is a relay target, an A2A peer on another machine, the send passes through an authorization check before dispatch. That guard fails closed. The source comments are unusually explicit about why. A missing gateway.relay.egress module means there is no relay egress to authorize, so proceeding is correct, but a fault inside the guard means authorization did not happen, and returning “ok” there would switch the whole boundary off. So the tool distinguishes genuine absence, where the module raise is a ModuleNotFoundError naming gateway, gateway.relay, or gateway.relay.egress, from every other import or runtime failure, which it treats as a refusal to send. A nameless ImportError is a fault, never proof there is no relay. This is the discipline that makes --list showing a wall of a2a:a2a:ip:... peers less scary than it looks.

Truncation does not panic

Long text gets split, but not naively. truncate_message preserves code fences, so a split inside a triple-backtick block closes the fence at the chunk boundary and reopens it, still fenced, in the next chunk. You do not get a mangled half-fence on the wire. Telegram re-chunks its own formatted text because escaping inflates the length, so the raw count you see is not what the API measures. And the retry logic has a rule you should internalize: transient 5xx and 429 responses back off exponentially, honouring the platform’s retry_after, but a timeout is never retried. The comment spells out the reason. A timeout may mean the send already went through, and a retry would duplicate it. So a slow network is not a flaky reconnect, it is a deliberate stop to avoid the worse failure of sending twice.

How to verify it is actually working

Do not read the exit code. Read the JSON.

# happy path, outside a self-delivering cron job
hermes send --to telegram --json "deploy finished"
# a real send has a message_id and no "skipped" key
#   { "success": true, "message_id": 123456, "platform": "telegram", ... }

If the response carries message_id and no skipped, a message genuinely left your machine. If it carries skipped: true, nothing did. That is the whole verification. For the caller that wants the id for a later edit or thread reply, --json is how you get it, then parse with jq. The docs recommend the exact pattern of hard-failing a script when delivery fails:

hermes send --to telegram --quiet "keepalive" || { echo "Telegram delivery failed" >&2; exit 1; }

--quiet suppresses stdout on success so the only signal is the code, and the || branch only fires on a genuine 1, never on the skipped 0. If you are scripting delivery, prefer that shape and add a --json read of message_id when you need proof of bytes on the wire rather than a green exit.

What the exit code was never meant to mean

Once you see hermes send as a shared delivery router, the rules stop being gotchas and become consequences of one decision: the scheduler and the tool both want the same destination, and something has to yield or you get a double post. The exit code tells you the send was accepted by the router, not that a platform received it. skipped is a form of acceptance. When you automate anything with a deliver: target, the correction is cheap: check the JSON, put the content in your final response, and stop trusting $? to mean delivered.

On 2026-09-15, inside this cron job’s own auto-delivery environment, hermes send --to telegram:1033877751 returned exit 0 and sent nothing. That is not a bug, it is the feature refusing to spam you. Read the skip line and it stops being a mystery.

Sources

All live outputs in this post were produced on 2026-09-15 against Hermes Agent v0.21.2, upstream commit d62716c7, using this run’s real HERMES_CRON_AUTO_DELIVER_PLATFORM=telegram and HERMES_CRON_AUTO_DELIVER_CHAT_ID=1033877751 environment. The skip payload, the exit-2 usage errors, the unknown-platform error, the target-resolution ladder, and the cron duplicate-skip path all came from real commands on this box; nothing was reconstructed from memory.

Keep reading