ADR-038: Channel media — reference in the DB, materialise per agent at send
Status: Accepted (the v1 "defer a persisted attachment block" decision is superseded by ADR-041 — FR-033) Date: 2026-07-04 Deciders: Yuxing Wu Related: spec 009-channels (FR-020); builds on ADR-014 (channel adapter framework); superseded in part by ADR-041
Context
A channel-driven agent must be able to receive the photos and files the owner sends from a phone (the motivating case: "here's a screenshot of the new time, change the invitation"). Before this, TelegramAdapter._dispatch read only message.text, so any non-text message reached the core as an empty envelope and was rejected — the agent never saw the image.
The two supported agents consume images differently:
- Claude Code (driven through the Claude Agent SDK's stream-json input) accepts an inline
image/documentcontent block (base64) in the user turn — the documented, native mechanism for a programmatic driver. The model sees the image directly, no tool call required. - Codex (driven through its app-server RPC) has no inline-image path; it is path-native (
-i <path>+ aview_imagetool). An Anthropic image block is meaningless to it.
A survey of how established agents feed images (aider, Cline, OpenHands, Continue, Open Interpreter, Goose all inline base64; Codex and CLI-process bridges hand off paths) confirmed there is no single mechanism that fits both — the robust design must adapt per agent.
The tension: inline base64 is the native mechanism, but a 3–5 MB photo becomes ~4–7 MB of base64 that, if persisted in the message content, bloats the chat DB and is re-sent on every turn.
Decision
Store bytes out-of-band; keep only a reference in the chat DB; materialise per agent at send time.
- The transport downloads each attachment (
getFile→ download) to a Coffer-managed media dir (~/.coffer/channel-media). The inbound envelope carries anInboundAttachment(path, mime, filename). - The bytes never enter the chat DB. The persisted user message keeps the caption (or a short "(sent an image: …)" note when there is none). The attachment references travel out-of-band, for this turn only, through
start_turn → run_turn— not as message content — so the frozen message model, persistence, and the web/OpenAPI contract are untouched. - Each agent adapter materialises the reference in its own native shape:
- Claude Code reads the file at send time, base64-encodes it in memory, and inlines an
imageblock (ordocumentfor PDF); a non-vision file becomes a text pointer to its path. The base64 exists only in the outbound request. - Codex receives the on-disk path appended to the prompt (its native model).
- Claude Code reads the file at send time, base64-encodes it in memory, and inlines an
Alternatives considered
- Inline base64 as a new
ContentBlockpersisted in the message — the most "native" (attachment lives in history, shows in the web UI), but ripples across the message model, persistence, the frontend block renderer, and the OpenAPI contract, and either bloats the DB (base64) or needs a path-ref block anyway. Rejected for v1: the ripple and DB cost outweigh the history-replay benefit, which Claude Code's session resume already covers within a conversation. - Anthropic Files API (
file_id) — a scale optimisation for large assets re-referenced across many turns, but stateful, provider-locked, unavailable on Bedrock/Vertex, and not documented through the SDK stream-json path (Claude Code's subscription auth makesfile_idscoping unreliable). Overkill for one-shot channel sends. Deferred until a concrete need. - Save-to-disk + path note only, for every agent — simplest, but fobs a vision agent off with a
Readtool call when it could see the image natively. Rejected: not robust, and the research showed inline is the standard for vision-capable agents.
Consequences
- History stays small; no base64 in the DB; the frozen message/persistence/web seams are untouched.
- The design is modality-agnostic: audio, zip, etc. are the same reference; a new type is a new
mime, not a new schema. A future audio-native agent is one morerun_turnbranch (inline audio block vs. transcribe-then-text vs. hand off the path), keyed on the agent's capability — this is the seam spec009FR-022 (voice) builds on. - An attachment is materialised per turn; it is not persisted as message content, so a follow-up that re-reads full history (no session resume) sees the note, not the image. Acceptable for v1 — Claude Code's resume retains it in-session.
- Bytes accumulate under
~/.coffer/channel-media; retention/cleanup is a follow-up (not gated here).