InboundMessage.attachments as raw objects, so the best it can do is read a JSON blob to a model and the worst is answer a message about a picture as though nothing had been sent.
One call does the whole message
buildChatTurn takes the InboundMessage your chat handler was given and returns a ChatTurn: the text to ask, the pictures to hand over, and a plain sentence naming anything that came with the message.
Everything but the message arrives in one
ChatTurnOptions object, and every field of it is optional:
images: false and no transcribe make it a pure text assembler that opens no sockets at all. A handler that fetches nothing pays nothing. There is a fourth option, fetchFn, which substitutes the fetch every request goes through: it exists so a test can reach every edge of this page without a socket.
This lives in
attachments.ts, deliberately not in chat.ts. That module owns the socket, the duplicate check and the per-conversation ordering, and none of that changes here. Fetching is optional work that must never be able to wedge the transport, so it sits beside it rather than inside it. Everything on this page is re-exported at the package root, with exactly one exception, and that exception is under Images below.The order is the order a person would say it in
query is assembled in a fixed order, and the order is not arbitrary: it is the order somebody would have said the same thing out loud.
- What they typed.
message.text, trimmed. - What they pressed. The submit payload of an
Action.Submiton a card this agent sent, ascardActionNote. A card message arrives with empty text, so without this the agent is asked nothing at all and answers as though the person said nothing. The payload is serialized and truncated atCARD_PAYLOAD_MAX_CHARS, which is 4096 characters, and that bounds this agent’s own card template rather than a stranger’s message. - What they said out loud. The transcribed voice notes, under
[They sent a voice message]. - What they attached. The note from
attachmentsNote, under[Attached to this message].
query that is exactly the text the person typed.
It never throws
buildChatTurn does not throw. Anything that will not load is named in the note instead of failing the turn, because a failed picture is not a reason to leave a question unanswered.
The note distinguishes the two outcomes per attachment:
ATTACHMENT_NOTE_MAX_LINES entries, which is ten, followed by a count of the rest, so a message with forty attachments does not become the whole prompt. attachmentsNote(attachments, status) is callable on its own, where status is a Map<number, string> keyed by the attachment’s index in the message. Both that bound and CARD_PAYLOAD_MAX_CHARS above are exported from the package root here. The Python twin keeps its two out of its public surface entirely, so that page spells the numbers rather than naming a constant to import.
Images
ChatImage is an interface, and an interface carries no accessors, so the two conveniences the Python twin exposes as properties are plain functions here:
fetchChatImages(attachments, { origin }) is the piece underneath, for a handler that wants the pictures without the rest of the turn. It is best-effort per attachment: one that will not load costs that attachment, never the answer.
The caps are at the package root, with one missing on purpose:
MAX_IMAGES is the accept cap: four pictures are kept from one message, because each becomes a base64 blob in front of a model. IMAGE_FETCH_TIMEOUT_MS is how long one image has to arrive, in milliseconds. The Python twin spells the same two budgets in seconds, as IMAGE_FETCH_TIMEOUT_S and CLIP_FETCH_TIMEOUT_S, so a number carried across unchanged is out by a factor of a thousand. Override either cap on a fetchChatImages call with its maxImages and timeoutMs options.
The one number you cannot import is how much one image may weigh. It is 4 MiB, it is the default for the maxBytes option, and it matches the per-attachment ceiling StandIn applies anyway, so a larger local number could never be reached.
Two more things the fetch does, both about what arrives rather than what was claimed. The media type is judged before a byte is read, and the response’s own header wins, because an error page would otherwise be base64’d in front of a model as though it were a picture. What the message declared is the fallback, and only when it is itself a media type: a dragged-in file declares a bare extension, and taking that literally would fail every image/ and audio/ check. And the size cap holds while the body is read rather than after it, because a content-length that lies, or is simply absent, otherwise gets to allocate whatever it likes before a later check objects.
A dragged-in picture is a file, not an image
A pasted screenshot arrives withkind of image. The same file dragged in from disk arrives with kind of file and a contentType that is a bare extension like png rather than a media type.
So gating on the declared kind alone is the trap here, and it is a quiet one: the picture is never fetched, but the note still says one was attached, and the model answers about a picture nobody gave it.
An attachment is therefore worth a request when its kind matches or when its kind is exactly file and its filename’s extension, or a declared type that is itself a bare extension, is one of png, jpg, jpeg, gif, webp, bmp, heic, heif. The widening is file only: it exists to rescue the dragged-in case, not to override a kind the message stated outright. What the file then turns out to be is decided by the response, not by the extension that got it tried.
An attachment StandIn marked as not relayable is skipped without a request.
Voice notes
ChatAudio is one clip before anything has transcribed it: data as a Buffer, mime, and name. fetchChatAudio returns them. video/ types are accepted as well as audio/, because some clients label a voice note with a container type that speech-to-text reads perfectly well, and the same extension widening applies: a voice note relays as a file in practice, so a plugin gating on kind === "audio" transcribes nothing, ever.
Transcriber is the function you supply:
An engine that only takes a path
Plenty of transcription engines will not take bytes.spoolClip writes the clip to a temporary file, named with the extension its media type implies, and hands your callback the path:
This is a callback rather than a context manager, which is the shape TypeScript has for “hold this open for exactly this long”. The other difference from the Python twin is that this one always spools into the system temporary directory: there is no directory argument here.
The origin pin
Every fetch on this page is pinned to the one origin the messages themselves arrived from:ws maps to http and wss to https, and a default port is not spelled out, so wss://host:443 and wss://host compare equal.
The name is
chatAttachmentOrigin here and attachment_origin in Python. That is the one name on this page that is not a straight casing translation of its twin, so a search for attachmentOrigin finds nothing.302 points.
Pass origin explicitly when your handler already knows it. Otherwise buildChatTurn derives it for you.
Next
Chat
The channel these messages arrive on, and what goes back out.
Security
The signing lanes, and the rest of the fetch posture.