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 publishes a hostname. You sideloaded the Teams app. The bridge acknowledges a mention 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 unless the
activity has
activity.type === "message", a non-emptytext, aconversation.id, and afrom.id. It drops messages the bot sent itself. Thefrom.idbecomes the dispatch'srequester, which drives the auth and inbox-injection steps below. A multi-tenant deployment resolves the Entra tenant and drops unknown or non-active tenants. A single-tenant deployment bindsdefault. -
Conversation reference capture.
TurnContext.getConversationReferenceproduces an opaque reference. The bridge stores it on the context'sparticipants[0].metadatato post the reply later. -
Discussion context load or create.
DiscussionAdapter.loadByChannel("msteams", threadId)calls the sharedservices/bridgegRPC service. The service returns any prior record fromdata/bridges/discussions.jsonl, keyed bymsteams:<thread-id>. A new conversation starts with an empty history fromnewDiscussionContext. -
History append.
appendHistoryadds the user turn toctx.historyas{ role: "user", text, author: requester }, capped at 10 entries. The bridge then persists the context before the dispatch decision. 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 handler then returns and dispatches nothing. -
Inbox injection. When a run is in flight,
ctx.pending_callbacksis non-empty andctx.active_requesteris set. It starts no parallel run:-
It queues a message from the same requester to the
active session's inbox with
EnqueueInboxon the sharedbridgeservice. 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.". The bridge does not queue that message.
-
It 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 without a dispatch. -
Dispatch dance.
Dispatcher.dispatch({ ctx, prompt, requester, ackTarget, callbackMeta, workflowInputs })from libbridge performs these steps in order:-
It 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, posts a sign-in link, and runs no workflow. It rendersreauth_requiredandtransientresults into the thread and throws neither. -
It mints a fresh
correlation_idwithrandomUUID(). -
It calls
CallbackRegistry.register(...)to issue a callback token. The token is a UUID with a 2h TTL. Its metadata carries the requester and tenant. The call recordsctx.pending_callbacks[token] = correlationIdand marksctx.active_requester = requester. -
It starts the acknowledgement on the user's message: a
likereaction through the Bot Framework reaction adapter, then a randomized typing verb every ~25 seconds (Moonwalking,Unravelling,Tempering,Crafting,Simmering,Percolating,Decoding). -
It calls
dispatchWorkflowwith the bridge's configured dispatch workflow file and the prompt frombuildPrompt(text, ctx.history). It passes the callback URL${SERVICE_MSBRIDGE_CALLBACK_BASE_URL}/api/callback/<tenant_id>/<token>(defaulttenant when self-hosted). It also passes an inbox URL the workflow can poll for mid-run messages, and the correlation ID. -
On success, it pushes the dispatch timestamp into
ctx.dispatchesand flushes the store. -
On failure, it stops the acknowledgement, consumes the
registry token, removes the pending callback, clears
ctx.active_requester, and rethrows.
-
It 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. The bridge waits for
the callback.
The callback sequence
The dispatch workflow finishes, or it streams an interim reply
mid-run. It then POSTs to
/api/callback/<tenant_id>/<token> on the
bridge. The shared createCallbackHandler skeleton from
libbridge runs these steps in order:
-
Payload validation.
validateCallbackPayload(body)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_url. 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, and the token stays valid for the run's later callbacks. Unknown or expired tokens return 404, and 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 for a different 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.ctx.last_posted_seqadvances 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, and the inbox accepts no more injections for this run. -
Reply delivery. The msbridge
#handleReplymethod posts each unstreamed reply as its ownsendActivitythrough the stored conversation reference. An unstreamed reply is apayload.repliesentry with nokindfield. The handler filters out replies it already streamed mid-run. It appends each posted reply toctx.historyas an{role: "assistant"}entry. A missing conversation reference returns 410 throughCallbackHandlerError(410, "Conversation reference missing"). -
Verdict application.
#handleReplyswitches onpayload.verdict:-
adjourned: The replies are the complete response.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]with the requester whose message triggered the run. Later inbound messages accrue toward amissing_inputtrigger. Anelapsedtrigger arms a timer that survives a restart throughrearm(). The bridge still posts the replies (step 8). -
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 unacted messages into one prompt and re-dispatches it as a fresh run. It keeps every mid-run user message. -
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 use different tunnel hostnames, the inbound
webhook works but the callback fails. Set both to 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. - 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 and posts each
payload.repliesentry as its own thread message. - A follow-up mention in the same thread reaches the agent team with the prior exchange in context. The dispatched workflow's prompt input shows it.