- Your agent never holds a Bot Framework credential. There is nothing to register, rotate or leak.
- The worker dials out. The socket is opened from your side, exactly like the call lane, so there is no listener, no port to expose and no tunnel.
This is the twin of the Python SDK’s
standin.chat, method for method, with this language’s
casing. The shared conformance vectors assert both parse and build identical wire payloads. One
behaviour genuinely differs, on shutdown, and it is called out below.The whole thing
ChatChannel throws a StandInError at construction when no secret is available. Managed
connections only, and that needs no flag: the socket authenticates with your connection secret, so if
it opens at all you are managed.
Options
Which secret
A managed deployment can be issued a separate key for chat. When it is, setSTANDIN_CHAT_SECRET
and it wins; with one key for both lanes, set only STANDIN_SECRET and this lane falls back to it. A
key scoped to one lane cannot be used on the other, which is the point of issuing two.
Shutting down
InboundMessage
One user message, already authenticated and resolved to your connection.
text.
cardAction carries the submit payload of an Action.Submit on a card your agent sent, and text is
empty on those messages.
bindingId says which StandIn connection this conversation resolved to. One tenant can have several
connections, so the tenant alone no longer identifies who a reply is from. You never set it: read it
if you need it, and let buildReply echo it.
attachments is raw. A pasted screenshot, a dragged-in file or a voice note all arrive here as plain
objects, and reading a JSON blob to a model is the worst thing you can do with one. buildChatTurn
turns the whole message into something to hand a model in one call: see
Attachments in chat.
isPersonal(message) is true when the message came from a 1:1 chat rather than a group or channel.
It is a free function here and a property on the message in Python, message.is_personal, so this is
one of the few lines a port has to rewrite rather than rename. Use it to decide how chatty to be:
parseInbound and buildReply
Use these when you are relaying messages yourself rather than through ChatChannel, for example
behind your own HTTP endpoint.
The exception type differs between the SDKs: this one throws
StandInError, the Python twin raises
ValueError. Code mapping the failure to HTTP 400 catches a different class in each.buildReply also sets replyToId from the inbound activityId, derives an idempotencyKey of
{activityId}:{kind}, and echoes bindingId when the inbound message carried one.
It takes four arguments, not three:
kind defaults to message; typing and error are the other two the channel itself sends, and a
typing reply carries neither text nor image, because it is a state rather than a message. image
is an OutboundImage rendered inline in the reply. Build one with outboundImage, which checks the
declared type against the file’s real magic bytes, caps it at 1 MB and refuses image/svg+xml by
design: see Sending a picture.
That page also covers the MEDIA: marker convention, which is how an agent that has written a file
gets it sent: parseMedia strips a MEDIA:/path line out of the reply text so a caller is never read
a temp path aloud, and loadMedia turns one into bytes only under the directories
STANDIN_MEDIA_ROOTS names. That gate defaults to none, because an agent can be talked into
writing MEDIA:/etc/passwd, and it is the kind of thing an operator turns on too widely.
parseInbound requires tenantId, conversationId and activityId to be non-empty strings. It
rejects a schemaVersion above SCHEMA_VERSION, which is currently 1. That version is a major
version: additive evolution does not bump it, because the schema already requires receivers to ignore
unknown fields, so an integer above yours means incompatible semantics rather than new fields.
An unknown scope is relayed as a group chat rather than rejected, because the scope is an open enum.
Authentication
The chat dial signs the literal channel name"chat" with signHandshake, in the
X-StandIn-Timestamp and X-StandIn-Signature headers. That is the inbound call handshake running in
the opposite direction: same scheme, same 60 second window, the worker signing instead of verifying.
ChatChannel does this for you. You only need it if you are dialing the channel yourself.
What the channel does for you
Per-conversation ordering
Per-conversation ordering
Turns are chained per
{tenantId}:{conversationId}. The schema promises per-conversation ordering,
and independent tasks would let replies overtake each other. A failed turn does not dam the chain.At-least-once dedupe
At-least-once dedupe
StandIn delivers at least once. A redelivery of the same activity must not start a second turn, so
the channel keeps a bounded LRU of seen keys. An aged-out redelivery running again is acceptable
at-least-once behaviour; a fresh double-run is not.A redelivery is still remembered by
PersonalChats before the dedupe runs, because it is still
evidence that this person has a chat with this bot, and remembering it twice changes nothing.Typing, empty answers and failures
Typing, empty answers and failures
A typing indicator is sent before your handler runs, not after, so the indicator still lands first
while the agent thinks. If your handler returns an empty string the channel says so rather than
leaving the user watching a typing indicator forever, and a thrown error becomes a short apology.
After a typing indicator, silence looks exactly like a hang.
A bounded turn
A bounded turn
One turn may run for five minutes before it is abandoned. Serialization means a hung turn would wedge
its conversation forever. The bound is generous because agent turns legitimately run long. A timeout
sends an error reply rather than silence, because after a typing indicator silence looks exactly like
a hang.The bound is a module constant here and cannot be changed from outside. The Python twin holds the
same five minutes in a public class attribute,
ChatChannel.TURN_TIMEOUT_S, which a deployment can
raise or lower.Remembering who messaged you
A 1:1 call carries no thread and no conversation to post into. So when a caller asks for the minutes, or for anything else in writing, there is nowhere obvious to send it. The only honest source of that person’s own chat is a message they sent the bot.PersonalChats is that memory. Feed it from the chat lane, read it from the call lane:
- It was a 1:1 chat, decided by the message’s scope and by nothing else. An @mention in a team
channel would otherwise make that channel the caller’s “chat” and put a private escalation in front
of their whole team. Testing the id prefix instead inverts the rule exactly: a personal chat’s id
begins
a:1...while19:...is precisely the group and channel shape, so a prefix test rejects every real personal chat and admits nothing useful. - It is in this tenant, the one the worker is bound to, never the caller’s own.
- It was seen inside
CHAT_FALLBACK_WINDOW_MS, 12 hours: long enough to cover a working day, short enough that a conversation from last week is not treated as evidence of who is on the phone today. - The call names a caller whose AAD id is the one that sent it. Without this last rule every anonymous caller collapses onto whoever chatted last.
PersonalChat is the record itself: conversationId, tenantId, aadId, displayName and atMs.
A message is remembered before the at-least-once dedupe runs, but the dedupe still governs the
turn: a redelivery is more evidence that this person has a chat with this bot, and remembering it
twice changes nothing.
The options object’s type, ForCallerOptions, is not re-exported from the package barrel today, so
pass the argument inline rather than trying to name its type.
resolveMinutesTarget on Meeting recap is what actually consumes this.
Posting without an inbound message
chat.send() posts into a Microsoft Teams conversation with nothing to answer, which is useful from inside a
call:
image and bindingId. It is best-effort and returns false rather than throwing,
because a failed post must never break a live call. A false here means the socket was not open or
the send threw, and nothing more: the socket carries no status back, so a post StandIn later refuses
still returns true. Pass an idempotencyKey whenever a retry is possible.
Next
Attachments in chat
Screenshots, dragged-in files and voice notes, as one
ChatTurn.Sending a picture
outboundImage, and MEDIA: markers with their filesystem gate.Meeting recap
Where a 1:1 call’s minutes are allowed to go.
Security
The handshake this lane signs, and the body lane it is not.