Attachments reach Claude through a tool the mod registers
accepted Architecture decision record 0024
People paste screenshots into Claude Code’s terminal, and Claude sees them. kndl’s web UI should let people do the same (#275), but every way a plugin puts a prompt into Claude Code is text only. Claude Code 2.1.296, run against a stub model server, showed it: $.prompt.submit doesn’t expand pasted images for a plugin’s prompt, $.session.append takes text blocks alone, and $.session.send and $.fs.write take text. What a plugin can hand the model unchanged is a tool’s result: a tool.call hook for a tool the plugin registered may answer with an array of Messages API blocks, images and documents included, and they reach the API request as the tool_result’s content.
So an attachment is a file the Hub keeps, and Claude opens it with a tool.
How it works
- Scaling down. The browser scales each image down before it is uploaded, as Claude Code scales a pasted one: the long edge to 2000 px at most, Claude Code 2.1.296’s default for a paste. An image is encoded again when it was scaled, or is over 3.75 MiB (Claude Code’s own threshold, whose base64 is 5 MiB). It stays PNG where it has transparency, and becomes JPEG at 0.85 otherwise, stepping down to 0.7 and 0.5, then to half the size, if it doesn’t fit. A GIF is always sent as its first frame, the one Claude reads. A small image at its size goes as it is, and one the browser can’t decode is left to the Hub. Scaling down is what keeps requests small. A Messages API request is capped at 32 MB, and every image a Turn opened stays in the conversation, so four or five full-size 5 MB images, about 6.7 MB of base64 each, would break every request after them. A 2000 px JPEG is typically 1 to 2 MB.
- Upload. The Composer uploads each file as soon as it is added, one
POST /sessions/<id>/attachmentsper file: the raw body, its name URI-encoded inx-kndl-filename. It sits behind the sign-in cookie and the web UI’s origin, for anyone with access to the Session who may prompt it (not Archived). The Hub reads at most 5 MB, the most the Messages API takes of an image, as a backstop to the scaling, and keeps the file as the image type its bytes are, JPEG, PNG, GIF or WebP, whateverContent-Typesays: the Messages API refuses an image whose type doesn’t match its data. The upload’s type is only a hint. It answers with a random 128-bit id. The web channel’s WebSocket stays at Bun’s default message size. - Storage. An SQLite table of blobs, each row the Session’s: purging the Session deletes them. A name is kept as one line without brackets, at most 100 characters, since it goes into the prompt.
- Prompt.
SendPromptcarries the ids, which must be files its sender uploaded to that Session. The queued prompt,DeliverPromptand the Turn’sTurnStartedcarry the attachments (id, name, type, size). The Hub fillsTurnStarted’s in from the queued prompt, so the transcript shows them on replay, whatever the mod says. An image alone is a prompt. - Reference. hub-link-commands submits the prompt with a marker per file after its text, in the terminal’s style with the id:
[Image #1: shot.png (attachment <id>)]. hub-link adds hiddencontextto that prompt, throughprompt.submit, telling Claude to open each file before answering: a plugin’s own hooks don’t see the prompt it submits, so hub-link-commands says the context in itsdeliveredstate. The system prompt says what a marker is, which later paths that carry the marker alone (#278) rely on. - Tool.
mcp__hub-link-commands__attachment({ id }), registered atsession.startwithisDeferred: false, so it is in the tool list from the first request, not behind ToolSearch. It fetchesGET /mod/attachments/<id>?offset=<n>through the Runner’s/mod/*relay, which serves only the mod’s own Session’s files, as JSON with the bytes in base64: the mod runtime’s$.http.fetchreads a body as text, and refuses one over 4 MiB (Claude Code 2.1.296), which a 5 MB image’s base64 is. So the Hub answers in parts of 2.4 MB, 3.2 MB of base64, each saying where the next starts, and the tool joins them. It answers with a text block naming the file and an image block. hub-link reports the call like any other, its result summarised as text and[image/png], never the bytes. - Display.
GET /sessions/<id>/attachments/<attId>serves a file to anyone who can see the Session, as its sniffed type, withnosniff,Content-Security-Policy: sandboxandCross-Origin-Resource-Policy: same-origin. The Turn shows a thumbnail of each, which opens full size; the queue says how many images a prompt carries. - Steering. A prompt with attachments can be steered like any other (#278, below).
Considered options
- Write the file into the Workspace and ask Claude to Read it. Read shows Claude an image, and no tool is needed. But the file lands in the Repo’s checkout, where
git add -Acommits it, every file needs a path no one else’s clashes with, and Claude must be trusted to Read it. - Wait for Claude Code to take images in a plugin’s prompt. The simplest for kndl, but nothing says when, and people paste screenshots now.
- One tool call for several ids. Fewer round trips, but twenty 5 MB images in one result would pass the 32 MB cap on a whole request. One file per call keeps a single result small. It doesn’t keep the whole request small, since earlier results stay in the conversation; scaling down does that.
- Sending images at full size, and letting the API scale them. The API scales an image down anyway, but it counts the bytes sent against the request’s cap first.
Consequences
- One tool call per file. Claude spends a step on each file, and a prompt with many files is slower to start.
- Subagent types that list their own tools can’t open attachments. General-purpose subagents get the tool; one whose definition lists its tools doesn’t, unless it lists this one.
- The terminal shows the markers. Anyone at the Session’s terminal sees
[Image #1: …]lines, not the image. - The Hub holds people’s files for as long as the Session lives. There is no quota on uploads, beyond 5 MB an image and 10 MB a PDF (#277), but unsent uploads last at most 24 h (#280).
- A marker typed by hand opens a file too. Claude opens any of the Session’s files whose id is in a message, sent with a prompt or not. That gives nothing away: every Participant can already view all of the Session’s attachments.
- Re-encoding drops a photo’s metadata, EXIF and its location included, but only for the images it encodes again. A small JPEG sent as it is keeps it.
- base64 costs a third. A 5 MB image is about 6.7 MB of JSON from the Hub to the mod, in three requests.
Addendum: dropped and picked attachments, and PDFs (#277)
-
Every way in is one. An attachment dropped on the Composer, or picked with its attach button, goes the way a pasted one does: the same refusals with a reason, scaling for an image, a chip with its progress. The picker offers the image types and PDFs, so an iPhone offers the camera and the photo library.
-
PDFs up to 10 MB. #275 agreed on 20 MB. It is 10 MiB, because a bigger PDF would not reliably reach Claude (below). The Hub reads an upload up to the largest limit, sniffs it (
%PDF-beside the image signatures), then holds it to the limit for its kind: 5 MB for an image, 10 MB for a PDF. One table inprotocol/src/Attachment.tsholds what differs by kind: the limit, the marker’s label, the name for an unnamed one, how the Hub serves it, and the block the tool answers with. A PDF is not scaled. Its marker is[PDF #1: spec.pdf (attachment <id>)]: images and PDFs are each numbered on their own, in the style of Claude Code’s[Image #1]. The tool answers with adocumentblock,{type:"document", title, source:{type:"base64", media_type:"application/pdf", data}}, which reaches the API unchanged. A 10 MB PDF is five parts of 2.4 MB. SQLite still loads the whole blob to cut each part, but only the part is held in the Hub’s memory, so that stays bounded; at five parts of a 10 MB file, that is acceptable. The Turn shows a PDF as a chip with its name and size, served as a download (Content-Disposition: attachment), still sandboxed. -
Why 10 MiB: Claude Code caps a request’s media before the API does. The API caps a request at 32 MB, and every attachment a Turn opened stays in the conversation. Claude Code 2.1.296 adds up the base64 of every image and document in a request, old tool results included. Past 24 MiB (the API’s 32 MiB less 8, its
tengu_media_byte_cap), it replaces them, oldest first, with[media removed: request limit]until they are under 14 MiB. Measured against a stub API, with a plugin tool’sdocumentblock:- an 18 MB PDF (24 MB of base64) reached the request whole;
- a 19 MB or 20 MB PDF never did: Claude got the text
[media removed: request limit]in its place; - a second 10 MB PDF pushed the first out of later requests.
A 10 MiB PDF’s base64 is 13.3 MiB, under the 14 MiB that eviction leaves, so the newest PDF always reaches Claude. Older attachments may still be evicted by a later one. An evicted attachment keeps its marker, so Claude can open it again with the tool.
-
Pages count too. The API takes at most 600 PDF pages in a request on 1M-context models, and 100 on 200K ones, and each page costs about 1,500 to 3,000 tokens. Past that the API refuses the whole request, so the Turn fails, not the tool.
Addendum: steered prompts, messages to subagents and replies (#278)
Attachments now go everywhere a Participant writes to Claude, each way through the marker alone: these paths have no prompt.submit, so no hidden context comes with them, and the system prompt’s line on markers, with the tool’s description, is what tells Claude to open them.
- Steering. A queued prompt with attachments can be steered.
DeliverIntoTurncarries its files, marked sent as the prompt was queued, and hub-link-commands appends the steered row with a marker per file. It tells hub-link the row’s exact text, so hub-link knows it.PromptSteeredcarries the files, which the Hub fills in from the queued prompt as it logs the event, as it doesTurnStarted’s. For a prompt no longer queued, it logs the Session’s own files for the ids the mod names, never the mod’s own, and logs nothing for a prompt of files alone with none of them the Session’s. A prompt of files alone can be steered too. - Messages to subagents.
MessageAgentcarries attachment ids, and a message of files alone is a message. The Hub wakes an Asleep Session first, then marks the files sent as it logs them onAgentMessagedand sends them inDeliverAgentMessage: a wake that is refused keeps none, though a file refused after a wake leaves the Session woken. The mod puts a marker per file in the text it sends withsession.send. A general-purpose subagent has the tool. One whose definition lists its own tools can’t open the files, as Consequences says. - Replies to an Open question.
AnswerQuestioncarries attachment ids beside its answer, for a reply only, since a reply is the only answer Claude reads as text. A reply still needs words: files alone are not one. The Hub marks the files sent and puts them on the reply (QuestionReplied.attachments) inDeliverAnswer, and the mod adds a marker per file to the refusal Claude reads. The mod reports the reply it settled, files and all, inQuestionClosed. The Hub keeps only the ids of what it says, and logs the Session’s own files for them. A reply that comes too late keeps its files for the Session’s life anyway. The web keeps the draft’s files when the Composer turns into a reply, and sends them with it. - Every way marks its files sent, all or nothing, through
Attachments.markSent, so none is swept a day later (#280). A file that isn’t its sender’s, or was swept, refuses the whole message. - A mod from before. A mod that marks these files says
attaches: truein itsModHello, assteers: truesays it steers (ADR 0018). The Hub won’t send files these ways to a Session whose mod doesn’t say so. It refuses the command, saying the files can go once the Session starts again on an updated Runner, since a mod from before would drop them, and a command of files alone wouldn’t even decode. - The transcript shows a steered prompt’s, a message’s and a reply’s files as it shows a Turn’s, on replay too, and names their files when it redacts.
Rendered from docs/adr/0024-attachments-reach-claude-through-a-mod-tool.md in the repository.
kndl is an independent project. It is not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC. About kndl