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.

July 28, 2026· Prepared by Hermes· Protocol v4 · plugin 0.4.0
Bottom line

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.

Attachment calls
4
2 images · 2 documents
CLI failures
0
exit code 0 on every send
Expected frames
8
4 text + 4 media
False warnings
4
one per successful call
Queue after test
0 B
all deliveries acknowledged
Plugin tests
150
all passing

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.

!
What was actually broken

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-gateway v0.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-gateway is 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

#CaptionFileTypeBytesDimensionsSHA-256
1Image 1/2 — Hermes Agent bannerbanner.pngimage/png12,3331145×19675e85ef6fecf5a6227985f2082e5b35331fceff4d8db03720c82a7d9ee8b7eea
2Image 2/2 — bundled Nous character artworknous-girl.jpgimage/jpeg20,026256×256a870cb8df69c158c9439227ec57983d921ac80284b7e6d6e6654b69ed8186e6c
3File 1/2 — Hermes Kanban v1 specification (PDF)hermes-kanban-v1-spec.pdfapplication/pdf213,669708469e633223b2e52478b008d6a5fbe75b8da305cabfdd8248c0d02913455f7
4File 2/2 — Hermes CLI guide (Markdown)cli.mdtext/markdown19,889446 linesd2dc8434ccdbc7a7ca408981fdd81db21627128f0645c3de8bee879e99e2ee1a

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 IDExitJSON statusActual outcome
18929f7f9-2366-481b-92df-2e936500dd070success: trueDelivered
21727b626-f6e6-435a-b51d-b1cfdbec9eea0success: trueDelivered
3bd76a6d6-2477-4773-a897-580e8433599b0success: trueDelivered
4624045f4-8c1f-4b92-8bca-5b3dcad072fc0success: trueDelivered

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

SignalCountSeverityInterpretation
MEDIA attachments were omitted for t3…4MisleadingFalse positive from Hermes core. The media was forwarded and acknowledged.
TERMINAL_CWD=/data/home/agent found in .env — this is deprecated4UnrelatedConfiguration 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 Unix file utility. The command was not installed, so size inspection was rerun with stat, MIME was derived with Python mimetypes, 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 send was a separate process and used the plugin’s short-lived role: delivery WebSocket 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.

No actual send error occurred

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

  1. Extract the directive. BasePlatformAdapter.extract_media() recognized each MEDIA:/absolute/path, returned one (path, is_voice) tuple, and removed the directive from the user-visible text. The remaining cleaned message was the caption.
  2. Validate the local path. filter_media_delivery_paths() resolved and accepted each local file.
  3. 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.
  4. Precompute a warning. Because media_files was non-empty, the generic “non-media platform” section set an omission warning before it knew what the plugin would do.
  5. 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.
  6. Use the standalone sender. hermes send ran outside the gateway process, so no in-process runner weak reference existed. _send_via_adapter() therefore invoked T3’s registered standalone_sender_fn and passed the media tuples through.
  7. Build two frames. T3’s standalone_send() built a home.deliver frame for the non-empty caption and a media.deliver frame containing the file’s MIME, size, base64 bytes, and its own delivery ID.
  8. Persist before network send. Both frames were appended to ~/.hermes/gateway/t3_home_delivery_queue.jsonl under a cross-process lock. The queue and lock use mode 0600.
  9. Dial a short-lived delivery socket. The plugin authenticated with role: "delivery", preventing the one-shot sender from displacing the live primary gateway connection.
  10. Wait for every acknowledgement. The sender waited for both home.deliver.ack and media.deliver.ack. It returned plain success: true only when the number of acknowledged delivery IDs equaled the number of generated frames.
  11. Purge acknowledged queue entries. Every acknowledged caption/media frame was removed from the local queue.
  12. 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

BehaviorFileLines observed
MEDIA extraction and cleanupgateway/platforms/base.py3780–3874
Standalone plugin fallback forwards media_filestools/send_message_tool.py685–768
Generic T3 warning is createdtools/send_message_tool.py1035–1048
Generic path still passes media_filestools/send_message_tool.py1084–1095
Warning appended after successtools/send_message_tool.py1101–1105
T3 registers standalone senderintegrations/hermes-t3-gateway/__init__.py44–79
T3 standalone builds media framesintegrations/hermes-t3-gateway/home.py580–718
Short-lived socket awaits all acksintegrations/hermes-t3-gateway/home.py721–782
Live adapter media deliveryintegrations/hermes-t3-gateway/adapter.py665–836
Protocol declares attachmentsintegrations/hermes-t3-gateway/protocol.py24–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

  1. User confirmation: Davis reported that the images and files worked on his end.
  2. 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.pdf card labeled 209 KB. It also shows “File 2/2 — Hermes CLI guide (Markdown)” followed by a downloadable cli.md card labeled 19 KB.
  3. UI/wire alignment: captions and cards appear as separate Hermes rows, matching the plugin’s separate home.deliver and media.deliver frames.
  4. Every CLI call returned success: true and exit code 0.
  5. No degraded success: there was no queued: true, no detail saying a file was skipped, and no error field.
  6. 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.
  7. Durable queue: after the test, t3_home_delivery_queue.jsonl was 0 bytes with mode 0600. The queue was last rewritten during the test window, consistent with acknowledged frames being purged.
  8. 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

  1. A generic plugin with standalone_sender_fn and media capability receives the exact media_files tuples.
  2. A successful media-capable plugin result contains no omission warning.
  3. A media-incapable plugin still receives the existing warning—or rejects media explicitly.
  4. A plugin returning partial/skipped media detail preserves that detail without core replacing it with a false blanket statement.
  5. 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.deliver frame per file, separate caption/media rows, MIME-based rendering, short-lived delivery sockets, and durable queue semantics.
  • Document that hermes send is out-of-process and therefore does not produce ordinary live-gateway message logs.

Minimal reproduction for a developer

  1. Run an enrolled protocol-v4 T3 gateway with a designated Home thread.
  2. Create a small local file below the 25 MiB ceiling.
  3. Execute the command below from a process separate from hermes gateway.
  4. Observe that T3 renders the caption and file, while the JSON result contains an omission warning.
  5. 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_frames
  • test_unacked_media_stays_queued_for_the_next_connect
  • test_media_is_queued_before_it_is_sent_and_purged_only_on_ack
  • test_an_image_only_reply_completes_its_turn
  • test_reply_media_arriving_just_after_completion_keeps_its_turn
  • test_unscopeable_media_falls_back_to_home_instead_of_dropping
  • test_turn_attachments_land_as_local_files_on_the_message_event
  • test_media_deliver_encodes_the_payload_and_applies_every_wire_bound
  • test_media_deliver_rejects_an_empty_or_oversized_payload
  • test_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.

Columbia Pages · generated by HermesEphemeral debugging report · 7-day TTL