ChatChannel is the messages lane. It is the same shape as the call lane, and it is deliberately the easier half: the worker dials out and StandIn pushes messages down that socket.
That means no listener, no open port and no tunnel for chat. It also means your agent never holds a Bot Framework credential: StandIn owns the Microsoft Teams bot, authenticates the activity, resolves it to your connection, strips the bot mention, and performs the send on your behalf. Your handler returns text.
Managed connections only, and that needs no flag: the socket authenticates with your connection secret, so if it opens at all you are managed.
Minimal use
respond is an async callable taking an InboundMessage and returning the reply text. Returning an empty string makes the channel say so rather than leaving the user watching a typing indicator forever.
Constructor
await chat.start() dials StandIn and begins taking messages. await chat.aclose() cancels the in-flight turns and closes the socket.
Which secret
A managed deployment may issue a separate key for chat. When it does, setSTANDIN_CHAT_SECRET and it wins; with one key for both lanes, set only STANDIN_SECRET and the chat lane falls back to it. A key scoped to one lane cannot be used on the other, which is the point of issuing two.
In Python a blank
STANDIN_CHAT_SECRET falls through to STANDIN_SECRET; the TypeScript twin stops at the first variable that is merely present. Unset the variable rather than blanking it and the two behave alike. See Configuration.InboundMessage
One user message, already authenticated and resolved to your connection. Reserved bot commands are handled by StandIn and never arrive here. In group and channel scope, only messages that mention the bot are relayed, and the mention is already stripped fromtext.
parse_inbound
ValueError with the specific problem, which a caller maps to HTTP 400: malformed json, body must be an object, tenantId is required and the same for conversationId and activityId, each of which has to be a non-empty string, or an unsupported schemaVersion. Everything else has a safe default, including scope, which reads as personal when the message does not say.
ChatChannel calls this for you and logs and drops anything malformed. Use it directly only when you are receiving the relay yourself, over the POST lane rather than the socket.
The exception type differs between the SDKs: this one raises
ValueError, the TypeScript twin throws StandInError. A caller mapping the failure to HTTP 400 catches a different class in each.build_reply
schemaVersion, tenantId, conversationId, replyToId, kind, and an idempotencyKey of "{activityId}:{kind}". It adds bindingId when the inbound carried one. text is omitted for kind="typing", and so is image: a typing indicator is a state, not a message.
image is an OutboundImage from outbound_image. See Sending a picture.
bindingId echoes for the same reason one level down, between connections inside one tenant.
That is also the reason build_reply takes the message rather than two strings. Passing the message through is what makes the echo automatic.
SCHEMA_VERSION
parse_inbound refuses the message rather than guessing.
What the channel does for you
Beyond parsing,ChatChannel handles four things that are easy to get wrong.
Deduplication. StandIn delivers at least once. A redelivery of the same tenantId:conversationId:activityId must not start a second turn, so the channel keeps a bounded LRU of what it has seen. An aged-out redelivery running again is acceptable at-least-once behaviour; a fresh double-run is not.
Per-conversation ordering. The schema promises ordering within a conversation, so turns for one conversation are chained rather than run as independent tasks, which would let replies overtake each other. Different conversations still run concurrently. A failed turn does not dam the chain behind it.
Typing indicators. A typing reply is sent before your handler runs, and it does not sit in front of the turn. The indicator still lands first.
Bounded turns. Because turns within a conversation are serialized, a hung turn would wedge that conversation forever. Each one is bounded at ChatChannel.TURN_TIMEOUT_S, which is 300 seconds: generous, because agent turns legitimately run long. A timeout or an exception sends an error reply rather than silence, since after a typing indicator, silence looks exactly like a hang. It is a public class attribute, so you can raise or lower it; the TypeScript twin holds the same bound in a module constant a caller cannot change.
Remembering who messaged you
A one-to-one call carries no thread id that can be posted into. So the only honest source for “where do I send this person something” is a message that person actually sent, andPersonalChats is where those are remembered.
remember() is fed by the channel on every inbound message, and for_caller() is asked from the call lane. At most 512 senders are kept, keyed by tenant and directory id, oldest evicted, because a tenant with many users must not grow without bound inside a worker that is also carrying live audio. A record stays usable for CHAT_FALLBACK_WINDOW_MS, which is 12 hours: somebody who messaged this morning and calls this afternoon is plainly the same person in the same working context, and a record older than that is a guess about who is on the phone.
PersonalChat is the record itself: conversation_id, tenant_id, aad_id, display_name and at_ms.
Three decisions inside it are worth knowing, because each one closes a way of posting call content into the wrong conversation:
Scope decides what is personal, and only scope. A conversation-id prefix looks like it would do the same job and does the opposite: a personal chat with a bot is addressed a:1... while 19:... is exactly the group and channel shape this has to exclude. Without the scope test, an @mention in a team channel would make that channel somebody’s “personal” chat and put their private escalation in front of their team.
A message is remembered behind the dedupe. A redelivery of this morning’s message must not make the sender look like they messaged just now, because the recency window is evidence about who is on the phone and a repeat is not.
That last one is the one real behavioural difference between the SDKs on this lane: the TypeScript twin remembers a message before the dedupe runs, so a redelivery there refreshes the 12 hour window. The dedupe still governs the turn in both, so only the freshness of the record differs.
for_caller enforces four rules, all of which must hold. The conversation was recorded from a personal-scope message; it belongs to the tenant this worker is bound to, never the caller’s own, which is absent or foreign for a guest; it was seen inside the window; and the call names its caller, whose directory id is the remembered sender’s. A call that identifies nobody gets nothing. allow_unidentified=True relaxes the last rule for a single-operator install, and warns by name every time, because with it on every anonymous caller collapses onto whoever chatted last.
This is where Meeting recap gets the caller_chat it resolves a delivery target from.
Posting without an inbound message
False rather than raising, because a failed post must never break a live call. Pass an idempotency_key when a retry would otherwise post twice.
image attaches one picture, the same OutboundImage build_reply takes. binding_id names which of a tenant’s connections the post is from, and there is no inbound message here to echo it off, so pass it yourself when the tenant has more than one.
Authentication
The outbound dial signs the channel name withsign_handshake, using your connection secret, in the X-StandIn-Timestamp and X-StandIn-Signature headers. That is the same v1 scheme the inbound call handshake uses, in the opposite direction: here your worker signs and StandIn verifies.
The separate POST relay lane signs the body instead, with a longer window. The two are not interchangeable. See Security.
Next
Attachments in chat
One call turns a pasted screenshot, a file or a voice note into a turn your agent can answer.
Sending a picture
outbound_image, the checks it runs, and why a reply carries bytes rather than a link.Security
Both signing lanes, and why they differ.
Call handler
The voice half of the same connection.