Skip to main content
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, set STANDIN_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 from text.

parse_inbound

Parses and validates one inbound message body. It raises 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

Builds the reply StandIn expects: 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.
tenantId and conversationId echo the inbound message exactly. StandIn rejects a mismatch, and that check is the cross-tenant leak guard the whole relay rests on. Never construct a reply with a tenant or conversation you assembled from somewhere else, and never reuse an InboundMessage from one conversation to answer another.
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

A major version. Additive evolution does not bump it, because the schema already requires receivers to ignore unknown fields. An integer above yours therefore means incompatible semantics, and 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, and PersonalChats 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.
Both lanes have to run in one process. PersonalChats is in-memory: across processes a caller who has not messaged the bot inside the window simply has no personal target.
This is where Meeting recap gets the caller_chat it resolves a delivery target from.

Posting without an inbound message

Useful from inside a call. Best-effort: it returns 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 with sign_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.