How incoming user files and outgoing agent files appear in chat, and whether they can be opened. Four sections: current behavior, eight implementations, remaining work, and the target design. How attachments enter model context is a separate subject covered in Complete attachment handling design. This page references its conclusions where needed. Both documents share the marker grammar in openprogram/attachments.py .
Attachment bytes, markers in message text, model input, and user-visible content are separate concerns. All four inbound paths follow the same rules: persist bytes once, leave a marker using the shared grammar, provide images or paths as appropriate to the model, and render chips with one parser.
Diagram details: Same persistence path, deduplication, and limits as documents. Plus a 4KB opening preview. Slack (WeChat has no attachments). Can be opened on the web. [display name (id)]. is prepended to the text; attachment markers are appended. They never overlap. _one_line removes square brackets, preventing forged additional prefixes.
attachments.format_marker ; chips are parsed exclusively by parseAttachments . A fifth inbound path must call this formatter instead of defining another grammar. Web images previously existed only as base64 in memory: the model could see them, but users could not reopen them. They now share document persistence (sha256 deduplication, 32MB/64MB limits, path insertion) while remaining ImageContent in model input.
openprogram/attachments.py · openprogram/channels/_attachments.py:181 · apps/server/openprogram_server/_webui/ws_actions/chat.py · apps/web/components/chat/messages/user-attachments.tsx
Diagram details: Only the speaker prefix and message remain; the marker is hidden.
parseAttachments extracts markers from text; AttachmentChips renders chips; AttachmentPreview opens them using FileViewer the absolute-path mode. User messages, assistant messages, and the message rail share this implementation.
apps/web/components/chat/messages/user-attachments.tsx · attachment-preview.tsx · apps/web/components/files/file-viewer.tsx · apps/web/lib/net/chat-stream.ts
Diagram details: Extract marker → post_file → omit marker from delivered text. Failure / unsupported → rewrite as a readable explanation.
MEDIA: instruction line in message text? A textual convention can leak to the user when extraction fails; channel inbound handling already exposed that failure mode. A tool call validates file existence and can report “the file no longer exists.” Merely mentioning a path is not interpreted as a delivery request.
openprogram/programs/tools/interaction/send_file.py · openprogram/agent/dispatcher/__init__.py · openprogram/channels/_conversation.py:_deliver_outbound_files
/files/raw. Its “always reject absolute paths” invariant should remain. Each route retains its own contract. The viewer implementation remains shared: FileViewer adds an abs argument to switch the two endpoints to absolute-path routes. Image, PDF, Markdown, line-numbered code, and download-card rendering still have one implementation.
openprogram/attachments.py:readable_roots · apps/server/openprogram_server/_webui/routes/files/file_search.py · apps/server/openprogram_server/server.py · apps/web/components/files/file-viewer.tsx
| Requirement | Available now? | Implementation | Remaining gap |
|---|---|---|---|
| User-sent images Open for viewing |
Yes Thumbnail and overlay |
Images use document persistence (sha256 deduplication, 32MB/64MB limits, path insertion) while retaining ImageContent blocks. Pasted images without filenames receive an extension based on media type; the chip regex captures the path. | Overlay only, no central tab; no multiresolution thumbnails for large images |
| Agent sends images or files to users |
Yes Web and channel paths |
send_file registers a marker; the dispatcher appends it to final_text; the web renders a chip and channels call the existing post_file; unsafe_in Capabilities declared per channel |
No caption parameter; no separate outbound allowlists for plugins and MCP tools |
| Readable files Open directly for viewing |
Yes Images, PDF, Markdown, code, download cards |
New /api/file-raw and absolute-path /api/file-read, FileViewer plus abs mode, reused in the overlay |
Code has no syntax highlighting (pre with line numbers is sufficient); no central-tab entry |
[attachment: /abs (image/png, 20481 bytes)] line as ordinary text when the same session was opened on the web. Channels had their own grammar (bytes instead of KB, and no @ path), so neither chip regex matched. Both sides now share format_marker, producing a clickable thumbnail chip on the web.
All eight repositories under references were examined. Missing capabilities are explicitly marked absent. Five let agents actively deliver files to users; three share this technique: write an instruction line in the reply and remove it in the delivery layer.
| Repository | Inbound context handling | Display and opening | Agent-initiated file delivery | Readable-file previews · cleanup |
|---|---|---|---|---|
| claude-code TUI |
Images are inline base64 blocks. Bytes are stored per ~/.claude/image-cache/<session>/ with 0600 permissions. Limits: 5MB and 2000px; sharp resizing; JPEG quality steps 80/60/40/20. Bridged web and mobile attachments inject paths for the read tool.
utils/imageStore.ts:9-67 · utils/imageResizer.ts:256-334 · bridge/inboundAttachments.ts:2-11 |
[Image #N] Text chip; clickable OSC-8 hyperlink opens the system image viewer. Non-images show a summary line Read <Path> (N lines). No inline terminal image protocol
components/ClickableImageRef.tsx:16-34 · messages/AttachmentMessage.tsx:131-158 |
Yes, dedicated tool parameter.SendUserMessage(formerly Brief), with attachments: string[], described as “photos, screenshots, diffs, logs, any file the user should see.” Uploads to the server return a file_uuid for mobile and web rendering; 30MB limit. Related tool SendUserFile appears only in references; its directory is absent from this dump
tools/BriefTool/BriefTool.ts:25-30 · tools/BriefTool/upload.ts:32-166 · tools.ts:42-43 |
TUI Markdown rendering and syntax highlighting. PDFs become document blocks or per-page JPEGs through poppler. No built-in image viewer; OSC-8 opens the system viewer. Cleanup: each session start removes other sessions’ image caches; the in-memory path table is a 200-entry LRU. utils/imageStore.ts:129-150 |
| codex-cli TUI |
Base64 data URL as input_image, wrapped in a <image name path> tag.Bytes remain in place without copying. 2048px limit and patch budget.sha1 content-addressed memory cache, 64MB LRU. Remote URLs are rejected
protocol/src/local_media.rs:21-35 · utils/image/src/lib.rs:26-203 |
[Image #N] Label only, not clickable. Kitty and sixel exist in the repository but only render the mascot, never attachments
tui/src/pets/image_protocol.rs:27-31 · tui/src/history_cell/messages.rs:95-96 |
No general-purpose tool. Image generation writes to disk and prints file://, with a prompt claiming “the image has already been shown to the user”
ext/image-generation/src/artifact.rs:31-43 |
TUI Markdown and syntect highlighting. No image or PDF viewer.file_opener configuration rewrites file references into vscode:// URIs for the editor
core/src/config/mod.rs:939-941 |
| openclaw Web + TUI |
Two modes selected by capability. Models with vision receive base64 blocks; otherwise content goes to the media store and a receipt media://inbound/<id> is inserted into the text. Store permissions 0700; inbound and outbound subdirectories. Images 6MB, audio/video 16MB, documents 100MB. EXIF orientation normalization followed by JPEG re-encoding
gateway/chat-attachments.ts:391-393 · media/store.ts:146-152 · media/constants.ts:1-4 |
Web actual thumbnails open in a new tab. Non-images use cards; audio has a native player, with “checking / unavailable” status badges. TUI has labels only ui/src/ui/chat/grouped-render.ts:822-830,1203-1290 | Yes, two paths. A separate text line MEDIA:<path or URL>, taught through the system prompt; only allowlisted core tools may send local paths. Plugins and MCP tools are excluded to prevent induced reads of unauthorized files. A separate message tool accepts attachments/media/caption/asDocument and other parameters. Outbound files first enter the store’s outbound directory, then go to the channel
agents/system-prompt.ts:424-426 · agents/pi-embedded-subscribe.tools.ts:280-300 · media/outbound-attachment.ts:5-30 |
Web Markdown-it with DOMPurify sanitization; native audio/video players. PDFs and documents are extracted as text for the model, not previewed for users. Cleanup: two-minute media TTL media/document-extractors.runtime.ts:14-45 · media/store.ts:24,188 |
| opencode Web + TUI |
Base64 data URLs stored in the session SQLite part.data column, without disk files.@ Mentioned files actually run the read tool on submission; results are injected as two synthetic plain-text parts. 5MB and 2000px limits, Lanczos3 resizing, stepped JPEG quality. Deduplicated by URL
session/prompt.ts:888-922,982-1002 · image/image.ts:10-136 |
Web image cards; click opens a lightbox dialog. Non-images are non-clickable file-icon cards.@ Mentions are character-offset highlights in the text, not cards. TUI shows a MIME badge and filename
ui/src/components/message-part.tsx:1103-1164 · tui/src/routes/session/index.tsx:1350-1358 |
No. Tools may return attachments, but that goes into model context, not to the user. No channel adapters
tool/tool.ts:52 |
Built into the web UI.GET /file/content returns text or base64 binary; the frontend renders images, audio, and SVG, plus file tabs and diff views. An “open in external application” menu opens projects, not attachments. No cleanup; data URLs are cascade-deleted with session rows
server/routes/…/file.ts:62-100 · ui/src/components/file-media.tsx:31-70 |
| hermes-agent Primarily chat channels |
Two routing modes selected per turn. Native mode inlines base64 and also adds a path hint, providing both to the model. Text mode first runs vision_analyze and prepends a description; the model never receives pixels. Documents receive sandbox paths only, with text files additionally inlined. Bytes use type-specific cache directories.No initial size limit; after server rejection, compress to 4MB agent/image_routing.py:298-379 · gateway/run.py:7794-7900 | Telegram and Discord provide native rendering. Its CLI and TUI show one label line; the web uses an xterm.js terminal stream. None of these three are clickable cli.py:5649 · web/src/pages/ChatPage.tsx:19-23 | Yes, the most complete of the eight. Text MEDIA:<path> instructions and a send_message tool are both supported. The security check validate_media_delivery_path: resolves symlinks before checking containment; files must be inside safe roots or an operational allowlist. System prompts declare capabilities per platform; CLI explicitly says “no attachment channel here; do not emit MEDIA tags.” is also automatically extracted and sent as a photo
gateway/platforms/base.py:867-905,2262-2301 · agent/prompt_builder.py:420-597 |
No general viewer; CLI renders Markdown through rich. Inbound text documents are inlined into prompts instead of previewed. Cleanup: 24-hour TTL for image and document caches gateway/platforms/base.py:672-690,989-1005 |
| pi-mono TUI |
@file parameter is inlined, and the model also receives an <file name> text marker.Clipboard paste supplies only a path: write a temporary file and insert its path as text into the editor for the model to read. 2000px and 4.5MB limits; PNG first, then stepped JPEG quality, running in a worker; users can disable it in settings
cli/file-processor.ts:48-89 · modes/interactive/interactive-mode.ts:2442-2462 · utils/image-resize-core.ts:22-127 |
The only implementation of actual inline terminal images among the eight. Kitty and iTerm2 protocols; disabled under tmux and screen, with fallback to [Image: name [mime] 800x600]. However, only images in tool results are rendered; user-sent images are not echoed
tui/src/terminal-image.ts:1-109,447-453 · modes/interactive/components/user-message.ts:11-34 |
No. Tools are only bash edit find grep ls read write; no channel adapters | TUI Markdown, syntax highlighting, diffs, and inline terminal images. No PDF support. No cleanup for temporary clipboard images |
| pi-ai Protocol layer |
A read-only copy of the pi-mono package: twelve files, no UI or tools. Base64 images; image blocks are removed entirely if the model declares no image-input capability. Images in tool results are split into text and a synthetic user message providers/openai-completions.ts:561-570,657-714 | No | No | No |
| weclaw |
The agent cannot see images at all. Inbound images are intercepted before reaching the agent: download, detect magic bytes, write as <timestamp>.<ext> with a sidecar, then reply “Saved: filename.” Voice is the exception, using WeChat’s transcription as the prompt. No size limit
messaging/handler.go:288-294,726-783 |
No separate UI; WeChat is the client | Yes, implicitly. No convention is needed: bare absolute-path lines or  in replies are extracted and uploaded, then the line is replaced in place with “Attachment sent: filename”. Failure appends “Attachment delivery failed.” Files must be under allowed roots, defaulting to ~/.weclaw/workspace, with an additional extension allowlist
messaging/attachment.go:17-107 · messaging/handler.go:491-524 |
None. Markdown is stripped rather than rendered and sent to WeChat as plain text messaging/markdown.go:7-40 |
Diagram details: Speaking and delivering files become separate actions. Same action as speaking; no additional round trip. Enable capabilities per channel in the system prompt. The model may write an incorrect path; a security check is required. Incomplete extraction leaks the instruction into user-visible text. file:// and claims it has been shown to the user,. This cell represents our position at the time of the comparison.
Openclaw materializes documents as /__openclaw__/canvas/documents/<id>/index.html, rendered inline in chat through [embed ref="cv_123"] ; its system prompt prohibits local paths in embeds. Codex asks the assistant to emit an ::codex-inline-vis{ instruction line; the TUI materializes the HTML fragment in a sandboxed viewer and replaces the line with a browser link, with a 2MB limit and strict CSP. Pi-mono’s /share exports the complete session as self-contained HTML, publishes a private gist, and returns a hosted viewer URL.
Openclaw’s /__openclaw__/assistant-media?source=…&mediaTicket=… uses token format v1.<payload>.<signature>, with a five-minute TTL. This addresses browser access to bytes without granting arbitrary-path access, imposing stricter controls than allowed-root checks alone.
Claude-code treats [Image #N] as an indivisible unit for cursor and vim operations. An image is sent only while its placeholder remains in the text. Deleting the chip cancels the attachment without a separate remove button.
view_image tool for loading local images into context. Pi-ai removes image blocks entirely when the model lacks declared image-input support—the fallback gap recorded in attachment-handling. Cleanup intervals differ: Openclaw two minutes, Hermes 24 hours, Claude-code removes other sessions’ caches at every session start.
role: "user" text, not tool_use and tool_result blocks. The three reasons for rejecting that approach remain; only the description was wrong. Both documents are corrected.
All three requirements are implemented; section one describes their resulting behavior. Only deliberately deferred work and issues discovered during implementation remain here. None blocks the existing user paths.
| Remaining item | Benefit | Why deferred | When to implement |
|---|---|---|---|
| Central file tab as a second opening option |
Large files requiring extended reading can remain beside the chat without occupying an overlay | Central tabs are keyed by (projectId, relative path) . Absolute-path tabs require changes to center-tabs-store and its consistency checker; the overlay already covers quick viewing without changes |
When someone needs to read an attachment for an extended period.turn-files-chips already uses fileTabId plus useCenterTabs; reuse that approach |
send_file caption for |
Telegram sendPhoto can attach explanatory text to the image instead of sending two messages | The marker grammar must match inbound markers and has no caption field. Reply text already accompanies the file, so repeating it provides no benefit | When a platform specifically requires text attached to the file |
| Outbound tool allowlist | Openclaw’s extra restriction: only core tools may send local paths, excluding plugins and MCP tools to prevent induced unauthorized reads | Currently, send_file is the only outbound entry point and already checks allowed roots. Another layer would block nothing new |
When plugins or MCP tools can trigger outbound delivery themselves |
| Short-lived signed tokens | Openclaw media endpoints use signed tokens with a five-minute TTL, stricter than allowed-root checks alone | The web UI listens locally, already has a same-origin guard, and validates allowed roots at the route. Tokens add no protection under these assumptions | As soon as the web UI is exposed beyond localhost; this is the first addition required |
| Code highlighting | Opened .py attachments display more than a single color | FileViewer with line-numbered pre is sufficient. docs_site pygments runs at build time and cannot directly serve runtime single-file rendering |
When users report a need |
sendable_roots read and deliver any file under home from any temporary session. Allowed roots therefore accept only non-default project bindings. This emerged from inspecting the real state directory; static code reading did not expose it, because project_for_session the name suggests explicit bindings only.
The requirement: any file, regardless of source channel, producer, or format, opens through the same viewer in every interface. Agent-produced and user-uploaded files differ only in their source field. The diagram is not constrained by the current implementation. Dashed elements are unavailable or too costly; reasons follow.
Diagram details: Anything openable on the web can be received on Telegram. It would violate the invariant that each session workdir is committed to git every turn and remains replayable. The cost is retaining large binaries permanently in git history, an acknowledged and accepted cost.
| Desired behavior | Problem solved | Current gap | Why deferred |
|---|---|---|---|
| One attachment record References only in text |
Rename, deduplication, cross-session references, and “which turns mentioned this file?” become queries instead of string manipulation | All information is encoded in a text marker parsed by two regexes | Requires session_db schema changes and historical-message migration. Markers currently carry all necessary information; wait until cross-session file references are a concrete requirement |
| One parser Four interfaces |
Web, central tabs, overlays, and terminals share the same rendering decision for a resource | Already true on the web (parseAttachments plus FileViewer has one implementation); TUI has no attachment concept and cannot even paste images into the composer |
TUI work is independent and does not block the web. Only Pi-mono among the eight supports real inline terminal images, and it does not echo user-sent images. Claude-code and Codex only render an [Image #N] label. The practical benefit is lower than it initially appears |
| Full bidirectional symmetry | User and agent files share a data model and UI, differing only in source | Grammar, parser, chips, and viewer are already shared; only fallback paths remain asymmetric | Partially implemented: when WeChat cannot receive a file, the marker becomes a Chinese explanation with a path, rather than leaking a raw marker or silently dropping it. Clickable links require an externally reachable web UI URL |
| Output can be a page rather than only a file |
The agent renders while writing, allowing users to watch a document develop without waiting for a saved file | canvas already writes Markdown blocks with IDs, and canvas-panel.tsx exists, but the two are not connected |
The wire format is settled. canvas.py comments say to preserve it and add rendering when WebUI gains a richer canvas interface. Three of the eight projects independently implement this, demonstrating demand. It is the most valuable addition after section three |
The canvas tool persists named Markdown blocks independently of the deferred rendering panel. For set and append, an explicitly supplied empty string is content; an omitted or null content argument requires an error without changing the file. set(content="") retains the named block with an empty body, so a subsequent get returns the empty string. Explicit content takes precedence over the body and text aliases, including when it is empty. The plain Python callable and the registered tool share these semantics and retain the existing block format and sandbox path checks. Acceptance uses registered set/get on an owned file and checks alias compatibility, missing-content non-mutation, and preservation of neighboring blocks.
Section one describes current repository behavior; section three lists deferred additions, and section four describes the unimplemented target design. Following project documentation conventions, unimplemented designs are stated positively in the body and identified here.
| Item | Status | Location |
|---|---|---|
| Empty-string canvas content semantics | Independent reviews and combined verification pending | openprogram/programs/tools/interaction/canvas.py |
| One marker grammar: format_marker and find_markers shared by four producers | Implemented | openprogram/attachments.py |
| Allowed-root policy: readable_roots / sendable_roots / resolve_within (resolve symlinks first) | Implemented | openprogram/attachments.py |
| Persist web images and write markers while retaining ImageContent | Implemented | apps/server/openprogram_server/_webui/ws_actions/chat.py:_persist_attachments |
| Infer extensions for unnamed pasted images from media type | Implemented | apps/server/openprogram_server/_webui/ws_actions/chat.py:_attachment_name |
| Channel attachments use web grammar (previously chip regexes failed and markers appeared as text) | Implemented | channels/_attachments.py:attachment_notes |
| Byte endpoints /api/file-raw and absolute-path /api/file-read | Implemented | apps/server/openprogram_server/_webui/routes/files/file_search.py |
| Capture chip paths, image thumbnails, and overlay opening | Implemented | apps/web/components/chat/messages/user-attachments.tsx · attachment-preview.tsx |
| chat_ack echoes stored text; newly sent attachments are immediately clickable | Implemented | apps/server/openprogram_server/_webui/ws_actions/chat.py · apps/web/lib/net/chat-stream.ts |
| send_file tool and dispatcher insertion into final_text | Implemented | programs/tools/send_file/ · agent/dispatcher/__init__.py |
| Channel return delivery: extract markers, call post_file, rewrite failures as readable messages | Implemented | channels/_conversation.py:_deliver_outbound_files |
| Capabilities declared per channel (tool absent in cli / tui / wechat / plan) | Implemented | programs/tools/send_file/send_file.py unsafe_in |
| post_file transport implementations for four platforms | Implemented; now has callers | channels/_transport.py:186-211,777-780 |
| FileViewer accepts absolute paths | Implemented (abs mode) | apps/web/components/files/file-viewer.tsx |
| Central-tab alternative, caption, outbound tool allowlist, signed tokens, code highlighting | Not implemented (section three) | — |
| Attachment record table, TUI inline images, live canvas rendering panel | Not implemented (section four) | — |
| document modality and choose_delivery capability switch | Not implemented; that column in attachment-handling’s decision matrix remains a design | providers/types.py:329 |
openprogram/attachments.py ; changing it changes behavior described by both documents.