T3 attachment delivery: successful send, false omission warning
A forensic breakdown of four Hermes → T3 Home attachment sends, why the CLI claimed the files were omitted, and why the T3 UI correctly received them anyway.
The T3 integration worked. All four attachment commands exited successfully, the plugin sent one home.deliver plus one media.deliver frame per call, T3 acknowledged every frame, and the local durable queue drained to zero. The alarming “MEDIA attachments were omitted for t3” text was appended afterward by a stale hard-coded warning in Hermes core. It was a false positive, not a transport failure.
Summary and corrected interpretation
I initially interpreted the generic warning literally and told Davis that T3 had omitted the attachments. Davis’s screenshot and direct confirmation showed that interpretation was wrong. The visible PDF and Markdown cards, the plugin’s success contract, and the empty post-send queue all agree: the files were delivered.
The status reporting was broken, not attachment delivery. Hermes core treats every platform outside a fixed media allowlist as “non-media” for warning purposes, even when a plugin’s standalone sender accepts and acknowledges media. T3 is absent from that fixed list, so core adds an omission warning after the T3 plugin has already returned full success.
The screenshot also reveals an important rendering detail: for each send, the caption arrives as a Home notification and the attachment arrives as a separate attachment row/card. That is consistent with the wire implementation: a non-empty command produces a home.deliver frame for the cleaned caption and a separate media.deliver frame for the bytes.
Environment under test
- Hermes Agent
- v0.19.0 (2026.7.20), git install at
/usr/local/lib/hermes-agent - T3 plugin
hermes-t3-gatewayv0.4.0- Wire protocol
- v4, with
attachments: true - T3 repo branch
experiment/hermes-provider-ui- T3 repo HEAD during investigation
a5400f8b399d- Latest integration-specific commit
3bc3f45bffdead82327d98231bdaea7163b0a0d9— “fix: harden media delivery durability per review findings”- Plugin source
/data/home/agent/workspace/t3code/integrations/hermes-t3-gateway- Install shape
~/.hermes/plugins/hermes-t3-gatewayis a symlink to the source directory- Destination
- T3 Home thread
2c9a416e-93af-4588-abeb-b1ce386af197 - Test window
- Approximately 16:58–17:02 UTC on July 28, 2026
The integration tree was clean when inspected. The plugin’s media durability commit is especially relevant: it persists frames before send, removes them only after acknowledgements, protects the queue with owner-only permissions, and makes success contingent on either a successful live send or durable queue persistence.
Exact test sequence
1. Confirm the standalone send interface
I first ran hermes send --help. The CLI documents MEDIA:<path> as the attachment syntax and accepts a bare platform target such as --to t3 to resolve the designated Home channel.
hermes send --to t3 "MEDIA:/tmp/chart.png"
2. Verify text-only delivery
hermes send --to t3 --json \
'Gateway test: message delivery to your T3 home thread is working ✅'
Result: success, message ID 1f3c5847-dc41-42ee-bf00-9f36cd90506c, delivered to the designated T3 Home thread.
3. Discover safe bundled test artifacts
I selected four public, bundled Hermes assets rather than personal files: a PNG banner, a JPEG character asset, a PDF specification, and a Markdown documentation file. I inspected sizes, MIME guesses, hashes, and image dimensions before sending.
4. Send four independent caption + attachment commands
Each command used a non-empty caption followed by one MEDIA: directive. The commands were run sequentially so their order would remain deterministic.
hermes send --to t3 --json \
$'Image 1/2 — Hermes Agent banner\nMEDIA:/usr/local/lib/hermes-agent/assets/banner.png'
hermes send --to t3 --json \
$'Image 2/2 — bundled Nous character artwork\nMEDIA:/usr/local/lib/hermes-agent/apps/desktop/public/nous-girl.jpg'
hermes send --to t3 --json \
$'File 1/2 — Hermes Kanban v1 specification (PDF)\nMEDIA:/usr/local/lib/hermes-agent/docs/hermes-kanban-v1-spec.pdf'
hermes send --to t3 --json \
$'File 2/2 — Hermes CLI guide (Markdown)\nMEDIA:/usr/local/lib/hermes-agent/website/docs/user-guide/cli.md'
The actual automation invoked the same payloads through four sequential terminal() calls and shell-quoted each complete message.
5. Send an incorrect follow-up diagnosis
Because I trusted the warning, I sent this text-only correction to Home:
Attachment test result: the four captions arrived, but Hermes omitted the actual media. The current T3 gateway adapter does not yet support native image/file delivery via MEDIA attachments.
That follow-up itself delivered successfully as message 781c1838-98e1-4266-b975-0dd5cc494939, but its content was wrong. The screenshot shows it beneath successfully rendered attachment cards.
Full payload inventory
| # | Caption | File | Type | Bytes | Dimensions | SHA-256 |
|---|---|---|---|---|---|---|
| 1 | Image 1/2 — Hermes Agent banner | banner.png | image/png | 12,333 | 1145×196 | 75e85ef6fecf5a6227985f2082e5b35331fceff4d8db03720c82a7d9ee8b7eea |
| 2 | Image 2/2 — bundled Nous character artwork | nous-girl.jpg | image/jpeg | 20,026 | 256×256 | a870cb8df69c158c9439227ec57983d921ac80284b7e6d6e6654b69ed8186e6c |
| 3 | File 1/2 — Hermes Kanban v1 specification (PDF) | hermes-kanban-v1-spec.pdf | application/pdf | 213,669 | — | 708469e633223b2e52478b008d6a5fbe75b8da305cabfdd8248c0d02913455f7 |
| 4 | File 2/2 — Hermes CLI guide (Markdown) | cli.md | text/markdown | 19,889 | 446 lines | d2dc8434ccdbc7a7ca408981fdd81db21627128f0645c3de8bee879e99e2ee1a |
All four files are bundled with the Hermes installation; no personal files or credentials were sent. The T3 protocol ceiling is 25 MiB per raw attachment, so every test payload was comfortably in range.
Per-command results
| # | Returned message ID | Exit | JSON status | Actual outcome |
|---|---|---|---|---|
| 1 | 8929f7f9-2366-481b-92df-2e936500dd07 | 0 | success: true | Delivered |
| 2 | 1727b626-f6e6-435a-b51d-b1cfdbec9eea | 0 | success: true | Delivered |
| 3 | bd76a6d6-2477-4773-a897-580e8433599b | 0 | success: true | Delivered |
| 4 | 624045f4-8c1f-4b92-8bca-5b3dcad072fc | 0 | success: true | Delivered |
The surfaced message ID is the first frame’s delivery ID—the caption’s home.deliver ID—not the media frame’s separate delivery ID. The plugin does not currently return every media delivery ID to hermes send.
Representative raw output
{
"success": true,
"message_id": "bd76a6d6-2477-4773-a897-580e8433599b",
"warnings": [
"MEDIA attachments were omitted for t3; native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and whatsapp"
],
"note": "Sent to t3 home channel (chat_id: 2c9a416e-93af-4588-abeb-b1ce386af197)"
}
The contradiction is visible inside the same result: the platform-specific sender returned success after receiving all acknowledgements, while generic core logic then attached a warning claiming the attachment was omitted.
Every warning and error observed
Attachment-send warnings
| Signal | Count | Severity | Interpretation |
|---|---|---|---|
MEDIA attachments were omitted for t3… | 4 | Misleading | False positive from Hermes core. The media was forwarded and acknowledged. |
TERMINAL_CWD=/data/home/agent found in .env — this is deprecated | 4 | Unrelated | Configuration migration warning emitted before each CLI result. It does not affect T3 or media delivery. |
Investigation-only errors
file: command not found, exit 127. An initial artifact-inspection command attempted to use the optional Unixfileutility. The command was not installed, so size inspection was rerun withstat, MIME was derived with Pythonmimetypes, and image dimensions were read with Pillow. This happened before sending and had no delivery impact.ModuleNotFoundError: No module named 'hermes_plugins'. During source discovery I guessed the runtime log namespace was importable as a normal Python package. User plugins are dynamically loaded under a generated module name instead. The source was then located through the plugin symlink. This also had no delivery impact.- No gateway-log entry for the four standalone message IDs. This is expected:
hermes sendwas a separate process and used the plugin’s short-livedrole: deliveryWebSocket rather than routing through the long-running gateway process.
Earlier gateway startup noise, resolved before the sends
During gateway restarts at 16:50–16:51 UTC, the logs briefly showed invalid T3 credentials and one state database routing warning. The final gateway instance connected to T3 successfully at 16:53:48 UTC, and the attachment test occurred afterward. These startup messages are not implicated in the successful standalone delivery path.
None of the four attachment commands returned a nonzero exit code, an error field, queued: true, or a plugin detail indicating skipped/unacknowledged media.
What the code actually did
- Extract the directive.
BasePlatformAdapter.extract_media()recognized eachMEDIA:/absolute/path, returned one(path, is_voice)tuple, and removed the directive from the user-visible text. The remaining cleaned message was the caption. - Validate the local path.
filter_media_delivery_paths()resolved and accepted each local file. - Select generic plugin routing. T3 is not one of the explicitly branched built-in media platforms in
_send_to_platform(), so execution entered the generic plugin path. - Precompute a warning. Because
media_fileswas non-empty, the generic “non-media platform” section set an omission warning before it knew what the plugin would do. - Forward the media anyway. The same section called
_send_via_adapter(..., media_files=media_files). This is the key contradiction: the path labels media unsupported but still forwards it to capable plugins. - Use the standalone sender.
hermes sendran outside the gateway process, so no in-process runner weak reference existed._send_via_adapter()therefore invoked T3’s registeredstandalone_sender_fnand passed the media tuples through. - Build two frames. T3’s
standalone_send()built ahome.deliverframe for the non-empty caption and amedia.deliverframe containing the file’s MIME, size, base64 bytes, and its own delivery ID. - Persist before network send. Both frames were appended to
~/.hermes/gateway/t3_home_delivery_queue.jsonlunder a cross-process lock. The queue and lock use mode 0600. - Dial a short-lived delivery socket. The plugin authenticated with
role: "delivery", preventing the one-shot sender from displacing the live primary gateway connection. - Wait for every acknowledgement. The sender waited for both
home.deliver.ackandmedia.deliver.ack. It returned plainsuccess: trueonly when the number of acknowledged delivery IDs equaled the number of generated frames. - Purge acknowledged queue entries. Every acknowledged caption/media frame was removed from the local queue.
- Append the false warning. Back in Hermes core, the previously created generic warning was appended to the already-successful result solely because the platform was not in the hard-coded support sentence.
Relevant code locations
| Behavior | File | Lines observed |
|---|---|---|
| MEDIA extraction and cleanup | gateway/platforms/base.py | 3780–3874 |
Standalone plugin fallback forwards media_files | tools/send_message_tool.py | 685–768 |
| Generic T3 warning is created | tools/send_message_tool.py | 1035–1048 |
Generic path still passes media_files | tools/send_message_tool.py | 1084–1095 |
| Warning appended after success | tools/send_message_tool.py | 1101–1105 |
| T3 registers standalone sender | integrations/hermes-t3-gateway/__init__.py | 44–79 |
| T3 standalone builds media frames | integrations/hermes-t3-gateway/home.py | 580–718 |
| Short-lived socket awaits all acks | integrations/hermes-t3-gateway/home.py | 721–782 |
| Live adapter media delivery | integrations/hermes-t3-gateway/adapter.py | 665–836 |
| Protocol declares attachments | integrations/hermes-t3-gateway/protocol.py | 24–32 |
Root-cause analysis
Primary defect: capability detection is hard-coded and stale
Hermes core maintains media behavior as a sequence of explicit platform branches followed by a generic “non-media platforms” section. That generic section assumes any platform not present in its fixed sentence omits media. This assumption stopped being valid when plugin platforms gained standalone_sender_fn implementations that can accept media_files.
T3 exposes real media support through protocol v4 and the plugin implementation, but core has no capability bit to query. The transport works because media tuples are forwarded optimistically; the reporting is wrong because the warning is generated pessimistically from platform identity.
Why the warning can coexist with acknowledged media
# Simplified current behavior
warning = "MEDIA attachments were omitted for t3" if media_files else None
result = await _send_via_adapter(..., media_files=media_files)
if result.success and warning:
result.warnings.append(warning)
return result
The warning is not derived from the plugin result. It does not inspect acked, skipped, detail, or any capability declaration. Therefore it can—and did—claim omission after the plugin proved end-to-end acceptance.
Secondary documentation defect
The plugin README is stale. It still says “Attachments intentionally advertise false,” while the current protocol code pins attachments: True, the compatibility document describes protocol-v4 media frames, and 150 tests exercise attachment behavior. That stale sentence can reinforce the same incorrect diagnosis.
Observability gap
The standalone sender returns only the first frame’s delivery ID. When a call contains text plus media, the media frame’s delivery ID and per-frame acknowledgement details are not surfaced to the CLI. The success contract is strong enough to prove completion, but debugging would be easier if the result optionally returned delivery_ids, media_count, and acked_count.
Evidence that delivery succeeded
- User confirmation: Davis reported that the images and files worked on his end.
- Screenshot: the T3 Home UI visibly shows the caption “File 1/2 — Hermes Kanban v1 specification (PDF)” followed by a downloadable
hermes-kanban-v1-spec.pdfcard labeled 209 KB. It also shows “File 2/2 — Hermes CLI guide (Markdown)” followed by a downloadablecli.mdcard labeled 19 KB. - UI/wire alignment: captions and cards appear as separate Hermes rows, matching the plugin’s separate
home.deliverandmedia.deliverframes. - Every CLI call returned
success: trueand exit code 0. - No degraded success: there was no
queued: true, nodetailsaying a file was skipped, and noerrorfield. - Plugin contract: plain success is returned only when
len(acked) == len(frames). Each call generated two frames, so each result implies both the caption and media IDs were acknowledged. - Durable queue: after the test,
t3_home_delivery_queue.jsonlwas 0 bytes with mode 0600. The queue was last rewritten during the test window, consistent with acknowledged frames being purged. - Focused verification: all 150 integration tests passed, including standalone media delivery, unacknowledged retry, live media delivery, image-only completion, inbound attachments, MIME construction, queue durability, and protocol bounds.
The screenshot viewport shows the two document sends directly. The two image sends are outside the visible scrolled region, so their UI rendering is supported by Davis’s confirmation plus the same acknowledgement and queue evidence—not by pixels in this particular screenshot.
Recommended fix
Preferred: capability-driven media reporting
Add an explicit media capability to PlatformEntry / register_platform(), for example supports_media_delivery=True. Set it in T3’s platform registration. Then make _send_to_platform() decide whether to warn from the registered capability rather than a hard-coded platform list.
# Platform registration
ctx.register_platform(
name="t3",
...,
standalone_sender_fn=standalone_send,
supports_media_delivery=True,
)
# Core warning logic
supports_media = built_in_support or bool(entry and entry.supports_media_delivery)
if media_files and not supports_media:
warning = ...
Safer result-driven refinement
For generic plugin platforms, prefer the plugin’s own result. If the sender returns success with no skipped/detail indication, core should not invent an omission warning. If a plugin actually ignores media, it should return a structured warning or declare supports_media_delivery=False.
Minimal hotfix
Add t3 to the current supported-platform condition and warning text. This removes the immediate false positive but preserves the architectural problem for the next media-capable plugin.
Regression tests to add
- A generic plugin with
standalone_sender_fnand media capability receives the exactmedia_filestuples. - A successful media-capable plugin result contains no omission warning.
- A media-incapable plugin still receives the existing warning—or rejects media explicitly.
- A plugin returning partial/skipped media detail preserves that detail without core replacing it with a false blanket statement.
- A text + media send surfaces or logs both delivery IDs and acknowledgement counts.
Documentation cleanup
- Update README lines 105–106 to describe protocol-v4 attachment support.
- Document the 25 MiB raw-file ceiling, one
media.deliverframe per file, separate caption/media rows, MIME-based rendering, short-lived delivery sockets, and durable queue semantics. - Document that
hermes sendis out-of-process and therefore does not produce ordinary live-gateway message logs.
Minimal reproduction for a developer
- Run an enrolled protocol-v4 T3 gateway with a designated Home thread.
- Create a small local file below the 25 MiB ceiling.
- Execute the command below from a process separate from
hermes gateway. - Observe that T3 renders the caption and file, while the JSON result contains an omission warning.
- Inspect the queue after acknowledgement; it should be empty.
printf 'hello from T3 media repro\n' > /tmp/t3-media-repro.txt
hermes send --to t3 --json \
$'T3 media repro\nMEDIA:/tmp/t3-media-repro.txt'
stat -c '%s bytes, mode %a' \
~/.hermes/gateway/t3_home_delivery_queue.jsonl
Expected current result: attachment visible in T3, exit 0, success: true, queue 0 bytes after acknowledgements, and an incorrect “omitted for t3” warning.
Expected fixed result: same successful delivery and empty queue, but no omission warning. Optionally return media delivery IDs and an acknowledgement count.
Appendix: verification detail
Focused test command
/usr/local/lib/hermes-agent/venv/bin/python3 -m unittest discover \
integrations/hermes-t3-gateway/tests \
-p 'test_*.py' -v
Result: Ran 150 tests in 0.342s — OK. One test intentionally logs a simulated mid-handshake socket drop and still passes; that traceback is expected test coverage, not a suite failure.
Media-relevant passing coverage
test_standalone_send_delivers_media_files_as_media_framestest_unacked_media_stays_queued_for_the_next_connecttest_media_is_queued_before_it_is_sent_and_purged_only_on_acktest_an_image_only_reply_completes_its_turntest_reply_media_arriving_just_after_completion_keeps_its_turntest_unscopeable_media_falls_back_to_home_instead_of_droppingtest_turn_attachments_land_as_local_files_on_the_message_eventtest_media_deliver_encodes_the_payload_and_applies_every_wire_boundtest_media_deliver_rejects_an_empty_or_oversized_payloadtest_the_queue_is_readable_only_by_its_owner
Queue state after live test
/data/home/agent/.hermes/gateway/t3_home_delivery_queue.jsonl
size: 0 bytes
mode: 600
modified: 2026-07-28 17:02:10.880729233 UTC
/data/home/agent/.hermes/gateway/t3_home_delivery_queue.jsonl.lock
size: 0 bytes
mode: 600
Important distinction
The warning’s phrase “native send_message media delivery” describes a Hermes-core allowlist, not the actual negotiated T3 protocol capability. In this test, the T3 plugin’s out-of-process sender was the transport of record, and it demonstrably supported media.