InboundMessage.attachments as raw dictionaries, 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
build_chat_turn 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.
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 keyword, get, which substitutes the opener every request goes through: it exists so a test can reach every edge of this page without a socket.
This lives in
standin.attachments, deliberately not in standin.chat. 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. Every function and class on this page is re-exported at the package root, so from standin import ... reaches it. The caps are not, and each one below spells the module import it needs.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, stripped. - What they pressed. The submit payload of an
Action.Submiton a card this agent sent, ascard_action_note. 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 at 4096 characters, which 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
attachments_note, under[Attached to this message].
query that is exactly the text the person typed.
It never raises
build_chat_turn does not raise. 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:
attachments_note(attachments, status=...) is callable on its own, where status maps an attachment’s index in the message to the word in its brackets. Neither that ten nor the 4096 above is part of the module’s exported surface, where the TypeScript twin names both at its root as ATTACHMENT_NOTE_MAX_LINES and CARD_PAYLOAD_MAX_CHARS. Read them here rather than importing them.
Images
data and data_url are properties here. ChatImage is an interface in the TypeScript SDK, and an interface carries no accessors, so the same two conveniences are the free functions chatImageData and chatImageDataUrl there. A search for .dataUrl across that SDK finds nothing.fetch_chat_images(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. Every cap below is also a keyword argument on it, as max_bytes, max_images and timeout_s, so a handler that wants a tighter number passes one rather than reaching for the constant.
The caps are not at the package root. Spell the module:
MAX_IMAGES is the accept cap: four pictures are kept from one message, because each becomes a base64 blob in front of a model. MAX_IMAGE_BYTES is what one of them may weigh, and it matches the per-attachment ceiling StandIn applies anyway, so a larger local number could never be reached. IMAGE_FETCH_TIMEOUT_S is how long one image has to arrive, in seconds. The TypeScript twin counts the same budget in milliseconds under a different name, IMAGE_FETCH_TIMEOUT_MS, so a number carried across unchanged is off by a factor of a thousand.
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 audio/ check and spool the bytes under a nonsense name. 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.
This
MAX_IMAGE_BYTES is not the only one in the SDK, and the two are different numbers for different things. The one here, in standin.attachments, is how big an inbound attachment may be before this worker refuses to read it. There is a second MAX_IMAGE_BYTES in standin.vision, and that one is the outbound cap on an image you put on the bot’s video tile: a smaller number, on a different direction of travel. Neither is re-exported at the package root, so the module you import from is what says which you meant.The two SDKs differ here, and in the direction that matters for porting. The TypeScript SDK does export MAX_IMAGE_BYTES from its root, and the one it exports is the outbound number: passing it where an inbound cap belongs compiles cleanly and quietly caps attachments at the smaller value. Python has no such collision to fall into, because neither name is at the root to be picked up by accident. See Vision.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 bytes, mime, and name. fetch_chat_audio 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 callable you supply:
An engine that only takes a path
Plenty of transcription engines will not take bytes.spool_clip is a context manager that writes the clip to a temporary file, named with the extension its media type implies, and hands you the path:
directory to spool somewhere other than the system temporary directory, when an engine needs the file on a particular volume.
Both differences from the TypeScript twin are here. This one is a context manager, which is the shape Python has for “hold this open for exactly this long”, while that one takes a callback. And this one takes
directory: there is no such argument there, so that SDK always spools into the system temporary directory.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
attachment_origin here and chatAttachmentOrigin in the TypeScript SDK. That is the one name on this page that is not a straight casing translation of its twin, so a search for attachment_origin across that SDK finds nothing.302 points.
Pass origin= explicitly when your handler already knows it. Otherwise build_chat_turn derives it for you.
Next
Chat
The channel these messages arrive on, and what goes back out.
Security
The two signing lanes, and what the SDK refuses.