Dispatch an Agent Session From a Teams Mention
A user mentions the configured bot in a Teams thread. The bridge must take that message and build a prompt that carries the conversation history. It must dispatch the agent team. It must acknowledge the user while the team runs. It must post the reply back into the same thread when the workflow finishes. It must not lose the correlation between the dispatch and the callback. This page traces the bounded flow for one dispatch. Use the page to read logs, debug mismatches, and predict the bridge's behaviour.
For the full setup with credentials and the tunnel, see Bridge Microsoft Teams to the Agent Team. The Kata agent team ships the reference dispatch workflow.
Prerequisites
-
Complete the
Bridge Microsoft Teams to the Agent Team
guide.
msbridgeruns. The tunnel is published. The Teams app is sideloaded. The bridge acknowledges a mention of the configured bot in your test thread.
The dispatch sequence
A Teams activity arrives at POST /api/messages. The Bot
Framework adapter routes it into
MsBridgeService.#handleNewMessage. That method runs a
fixed sequence:
-
Activity filter — the handler returns immediately
unless the activity has
activity.type === "message", a non-emptytext, aconversation.id, and afrom.id. It also drops messages the bot sent itself. Thefrom.idbecomes the dispatch'srequester. Therequesterdrives the per-user auth step and the inbox-injection step below. A multi-tenant deployment also resolves the activity's Entra tenant here. It drops activities from unknown tenants and from non-active tenants. A single-tenant deployment binds thedefaulttenant. -
Conversation reference capture —
TurnContext.getConversationReferenceproduces an opaque reference. The bridge needs that reference to post the reply later. The bridge stores it onparticipants[0].metadataof the discussion context. -
Discussion context load or create —
DiscussionAdapter.loadByChannel("msteams", threadId)calls the sharedservices/bridgegRPC service. The service returns any prior record for this conversation fromdata/bridges/discussions.jsonl, keyed bymsteams:<thread-id>. A new conversation starts with an empty history fromnewDiscussionContext. -
History append —
appendHistoryadds the user turn toctx.historyimmediately as{ role: "user", text, author: requester }, with a cap of 10 entries. The bridge then persists the context. Both steps happen before the bridge decides whether to dispatch. Messages that never dispatch still widen the next prompt's window. -
Resume gate —
ResumeScheduler.processInbound(ctx)evaluates any open RFCs. It uses the same library mechanics as the ghbridge resume guide. When an RFC is open and no trigger fires,freshDispatchAllowedis false. The message already accrued to history. The handler then returns and dispatches nothing. -
Inbox injection — a workflow run can already be
in flight for this thread.
ctx.pending_callbacksis then non-empty andctx.active_requesteris set. The bridge starts no parallel run:-
the bridge queues a message from the same requester
to the active session's inbox with
EnqueueInboxon the sharedbridgeservice, so the active run can pick it up mid-flight; -
a message from a different requester gets
"A session is in progress on this thread. The bridge did not forward your message to the active run."and the bridge does not queue it.
-
the bridge queues a message from the same requester
to the active session's inbox with
-
Rate-limit check —
RateLimiter.check(threadId, ctx.dispatches)enforces a sliding-window cap of 5 dispatches per 60 seconds. Above the cap, the bridge replies"Your messages arrive too quickly. Please wait a moment before you try again.". It then persists the context and returns. It dispatches nothing. -
Dispatch dance —
Dispatcher.dispatch({ ctx, prompt, requester, ackTarget, callbackMeta, workflowInputs })from libbridge performs, in order:-
resolves the tenant, then the dispatch credential for
requesterwith per-user GitHub auth throughservices/ghuser. A user who did not link GitHub gets{ kind: "link_required" }back. The bridge then stashes a pending dispatch and posts a sign-in link. It does not run the workflow. It also rendersreauth_requiredandtransientresults into the thread. It throws neither; -
mints a fresh
correlation_idwithrandomUUID(); -
calls
CallbackRegistry.register(...)to issue a callback token. The token is also a UUID and has a 2h TTL. It carries the requester and the tenant on its metadata. The call recordsctx.pending_callbacks[token] = correlationId, and marksctx.active_requester = requester; -
starts the acknowledgement on the user's message. It adds
a
likereaction immediately through the Bot Framework reaction adapter. It then posts a randomized typing verb every ~25 seconds (Moonwalking,Unravelling,Tempering,Crafting,Simmering,Percolating,Decoding); -
calls
dispatchWorkflowwith the bridge's configured dispatch workflow file, the prompt frombuildPrompt(text, ctx.history), the callback URL${SERVICE_MSBRIDGE_CALLBACK_BASE_URL}/api/callback/<tenant_id>/<token>(defaulttenant when self-hosted), an inbox URL the workflow can poll for mid-run messages, and the correlation ID; -
on success: pushes the dispatch timestamp into
ctx.dispatchesand flushes the store; -
on failure: stops the acknowledgement, consumes the token from
the registry, removes the pending callback, clears
ctx.active_requester, and rethrows.
-
resolves the tenant, then the dispatch credential for
If the dispatch throws, the catch in
#handleNewMessage posts
"Failed to reach the agent team. Please try again
later."
into the thread. The webhook then returns 200 and the bridge waits
for the callback.
The callback sequence
The dispatch workflow finishes, or it streams an interim reply
mid-run. The workflow then POSTs to
/api/callback/<tenant_id>/<token> on the
bridge. The shared createCallbackHandler skeleton from
libbridge runs, in order:
-
Payload validation —
validateCallbackPayload(body)is lenient by design. It requires onlycorrelation_id. It coerces a missingverdictto"unknown", a missingsummaryto"", and missingrepliesto[], capped at 50 entries. It truncates strings beyondMAX_FIELD_LENGTH(2000). It passes through optionaldiscussion_id,trigger, andrun_urlwhen they are present. It treats a payload without akindfield askind: "terminal". Invalid JSON or a missingcorrelation_idreturns 400. -
Token lookup — a
terminalpayload consumes the token.CallbackRegistry.consume(token)atomically looks up and deletes the registry entry. A streamed payload only peeks. The token stays valid for the run's later callbacks. Unknown tokens and expired tokens return 404. The bridge posts nothing. -
Acknowledgement finish — on terminal callbacks
only,
Acknowledgement.finish(token)stops the typing ticker and removes thelikereaction from the user's message. -
Correlation match — if the payload's
correlation_iddoes not equal the one stored against the token, the request returns 400. A leaked token then cannot deliver a reply that does not belong to this dispatch. -
Context load — the bridge calls
loadByChannel("msteams", threadId, tenant_id)with the metadata stored against the token. A missing context returns 410. -
Streamed-reply dedupe — a streamed payload whose
seqis at or belowctx.last_posted_seqreturns 200 with{ dedupe: true }and posts nothing. For any other streamed payload, the bridge wraps thebodyas a single reply for delivery.ctx.last_posted_seqthen advances after the post. -
Pending callback cleanup — on terminal callbacks,
the bridge deletes
ctx.pending_callbacks[token]and clearsctx.active_requester. The bridge then never honours the same token twice. The inbox accepts no more injections for this run. -
Reply delivery — msbridge's
#handleReplyposts each unstreamed reply as a separatesendActivitythrough the stored conversation reference. An unstreamed reply is apayload.repliesentry with nokindfield. The handler filters out replies it already streamed mid-run. It then appends each posted reply toctx.historyas an{role: "assistant"}entry. If the conversation reference is missing, the handler throwsCallbackHandlerError(410, "Conversation reference missing")and the request returns 410. -
Verdict application —
#handleReplyswitches onpayload.verdict:-
adjourned— the replies are the whole story.cancelRecessclears the recess state for this correlation id. The bridge does not post thesummaryinto the thread. -
failed— the bridge clears the recess state. It posts thesummaryinto the thread after the replies as a final message. -
recessed— the bridge callsResumeScheduler.enterRecess(ctx, correlationId, trigger, requester)to persist the trigger onctx.open_rfcs[correlationId]. It also persists the requester whose message triggered the run. Later inbound messages in the same Teams thread accrue toward amissing_inputtrigger. Anelapsedtrigger arms a timer, and that timer survives a service restart throughrearm(). The bridge still posts the replies (step 8) so the user sees what the team has so far. -
any other verdict — the bridge clears the recess state. It
posts the
summaryonly when the payload carried no replies.
-
-
Inbox reconciliation — after every non-
recessedverdict the bridge drains the run's inbox withDrainInboxpast the workflow'slast_acted_seq. It coalesces the messages the run never acted on into one prompt. It re-dispatches that prompt as a fresh run. Nothing the user typed mid-run is lost. -
Store flush — the bridge writes the updated
context (
last_active_at, history, pending callbacks) to disk.
Common failure shapes
| Symptom | Cause |
|---|---|
| Typing verb cycles forever; no reply |
Workflow ran but callback_url was unreachable
(check tunnel hostname drift)
|
| Callback 404, summary never posted | Callback token TTL (2h) expired before the workflow finished |
| Callback 400 "Correlation ID mismatch" | Two dispatches against the same registry entry. Only the first wins |
| Callback 410 "Conversation context missing" |
Someone deleted the JSONL record in
data/bridges/discussions.jsonl between dispatch
and callback, or the bridge service swept it past
its conversation TTL
|
Sorry, something went wrong. posted to thread
|
onTurnError caught an exception inside the Bot
Framework turn
|
Failed to reach the agent team. Please try again
later.
|
Dispatcher.dispatch rethrew (typically the
workflow_dispatch POST failed)
|
A session is in progress on this thread. … posted
to thread
|
A different user messaged while a run was active. The bridge forwards only the messages of the requester that dispatched the run |
| Sign-in link posted instead of a workflow run |
The requester did not link GitHub
(link_required). The bridge stashes the dispatch
and resumes it once the link completes
|
When SERVICE_MSBRIDGE_CALLBACK_BASE_URL and the Azure
Bot messaging endpoint diverge (different tunnel hostnames), the
inbound webhook works but the callback fails. Both endpoints must be
the current tunnel hostname.
Verify
You have reached the outcome of this guide when:
-
A new mention of the configured bot shows a
likereaction on the user's message. A typing verb also cycles in the thread within ~25 seconds of the mention. - The Actions tab on the configured repository shows a fresh dispatch-workflow run triggered by the bridge dispatch.
-
When the run finishes, the typing ticker stops. The bridge removes
the reaction. It posts each entry in
payload.repliesas its own message in the same thread. - A follow-up mention in the same thread reaches the agent team with the prior exchange in context. You can see it in the prompt input of the dispatched workflow.