This page is about pictures your agent sends. Putting a picture on the bot’s video tile during a
call is a different lane with different limits: see Vision and the avatar.
outboundImage
Buffer or an already-base64 string, a declared content type, and an optional filename.
It returns an OutboundImage, which is the only shape the chat lane will carry:
- The declared type is on the allowlist.
image/jpgis folded toimage/jpegfirst, because that spelling is common and is not a media type. Anything else throws naming the four that work. - The bytes are within the cap. Checked on the decoded length, so a base64 string that expands past the limit is refused.
- The bytes carry the signature of the type they claim. A declared type is a claim made by
whatever produced the bytes. The signature is what they are. Without this check an HTML document
or an SVG labelled
image/pngis posted into somebody’s chat under your bot’s name. - The filename is sanitized, which never throws: a name that cannot be made safe is simply dropped.
image/png is refused, not quietly relabelled. If you do not know
what you are holding, sniff first and pass the answer back in, which is exactly what loadMedia does
below.
That check is also the only thing standing behind a base64 string, because
Buffer.from(data, "base64") is lenient: Node discards characters that are not base64 rather than
objecting, so a truncated or scrambled string decodes quietly to fewer bytes and arrives at step 3 as
the wrong bytes. The Python SDK decodes strictly and raises on that same input, so do not port a test
that expects the decode itself to do the rejecting.
Every refusal on this page, from
outboundImage and loadMedia alike, is a plain Error rather than
a StandInError, so an instanceof StandInError filter will not catch one. Catch Error and read
err.message: the messages are written to be handed straight back to whatever chose the bytes. The
Python twin raises ValueError on the same paths.What may be sent
Every constant in that table is importable from the barrel, so a check of your own can use the same
number the SDK does rather than a copy that drifts:
sniffImageType
undefined when they are none of
the four. WebP gets a second look: a RIFF header is only a WebP when bytes 8 to 11 say WEBP, and
a RIFF container that holds audio would otherwise sail through on its first four bytes.
Use it whenever the type you have came from somewhere you do not control: a Content-Type header, a
file extension, or a field a model filled in. All three are claims.
sanitizeImageName
<>:"|?*, and what is left is trimmed. An empty result, . or .. returns undefined, which
means the picture is sent with no name rather than with a dangerous one.
Names longer than 200 characters are truncated, keeping the extension so the file still opens with
the right application. outboundImage calls this for you, and it returns undefined rather than
throwing, because a bad filename is never a reason to drop an answer.
Getting the picture into a reply
Two paths, and they differ in how many messages the person sees. One message carrying both.buildReply takes the image as its fourth argument, after the kind:
ChatChannel. A typing reply
carries neither text nor image: it is a state, not a message.
A second message from inside a handler. ChatChannel’s own reply path posts the string your
respond returns, and that string carries no picture. chat.send() is how you post one:
send is best-effort and returns false rather than throwing, since a failed post must never break a
live call. Pass an idempotencyKey whenever a retry is possible. bindingId says which StandIn
connection the message is from, and one organisation can have several, so echo the inbound value
rather than leaving it out.
MEDIA: markers
Some agent frameworks let a reply name a file by writing a line like MEDIA:/tmp/chart.png and expect
the channel to attach it. That marker is an instruction to the channel, not something anyone
should see. A channel that does not understand the convention posts the line as prose, and the person
reads a temporary file path in their chat. On a call it is worse: text to speech reads the path out,
character by character.
parseMedia takes the markers out and hands back what they referred to. loadMedia turns one
reference into bytes, through the same guards everything else in this SDK uses.
parseMedia
text is what to post and what to say. Both, always: the whole point is that nobody
sees or hears the marker. refs holds what the markers pointed at, in the order they were written.
The shape is exported as AgentMedia.
Four things the parser does on purpose:
- It is case-insensitive and one marker per line.
MEDIA:,media:andMedia:all match. - It is anchored to the end of the line, so a path with spaces survives intact. The documented
form is backticked for exactly that reason, and the backticks are stripped:
MEDIA:`/tmp/a b.png`yields/tmp/a b.png. Splitting on whitespace would truncate that path at the first space. - A line that is not plausibly a reference is left alone. A reference has to start with
http://,https://,/,./,../,~/or a Windows drive letter, or end in.png,.jpg,.jpeg,.gifor.webp. Without that narrowing, a sentence like “MEDIA: we should talk to them about it” vanishes out of the answer and whoever wrote it never learns why. - The blank line a removed marker leaves is collapsed. Three or more newlines become two, which is a paragraph break. Two are left alone, because they already are one.
loadMedia
text is still worth posting, and the reason is a sentence the agent can be told.
The SDK’s own
logger is not exported, and there is no @komaa/standin-sdk/log subpath to reach it
through, so a sample like this uses console or your own logger. What the barrel does export is
setLogger, which replaces the one the SDK writes its own lines with: pass an object with debug,
info, warn and error and the SDK’s lines land wherever the rest of your worker’s do. The Python
twin takes the other route and exposes standin.log.logger directly.OutboundImage. It throws with something worth reading,
because the caller is on a path where the alternative is a dropped answer with no explanation.
Options are LoadMediaOptions:
What it does, and why each branch exists:
http://orhttps://goes through the SDK’s own fetch guard with a ten second budget, so a reference aimed at a private, loopback, link-local or reserved address is refused rather than fetched. The address the socket actually connects to is re-checked, which closes the window where a hostname resolves publicly for the validation and privately for the fetch. See Fetching an image a model chose.- Any other scheme is refused by name, including
file://anddata:. Handing afile://URL to a URL fetcher is the usual way around a path guard, so it is rejected before anything opens it. - Everything else is a local path, read only from a directory an operator named, and only after a
stat: the size is checked before the read, because the point of a cap is not to load the file.
application/octet-stream accepted as “no opinion”. A type from a response header is a claim; the
bytes are the fact.
mediaRoots and STANDIN_MEDIA_ROOTS
mediaRoots returns the directories a local reference may be read from, resolved through symlinks.
With no argument it reads STANDIN_MEDIA_ROOTS, split on the platform path delimiter: : on Linux
and macOS, ; on Windows. ~/ is expanded. A root that cannot be resolved, because nothing is there,
is dropped, since a directory that does not exist cannot contain anything.
It is empty by default, and that is the whole design. Sending a local file is off until somebody
opts in. An agent can be talked into writing MEDIA:/etc/passwd, and the answer to that has to be a
refusal rather than a file read followed by an upload into somebody’s chat.
Two details in the containment check earn their keep:
- Both sides are resolved through symlinks first. A symlink inside a root that lands outside it is
judged by where it lands, so dropping a link named
innocent.pnginto an allowed directory buys nothing. - The comparison is separator-terminated. Without that,
/tmp/rootevilpasses for/tmp/root, and one adjacent directory becomes readable to a path a model chose.
roots explicitly instead when a single worker serves more than one purpose. An explicit list
wins over the environment entirely, so the variable is not consulted at all on that call. It is still
resolved the same way: a root you pass that does not exist is dropped exactly as one from the
environment would be, so an explicit list is not a way around the check.
Next
Chat
The lane a picture travels on, and what it echoes back.
Configuration
Every variable the SDK reads, including the roots above.