* Update hosting channel endpoint paths Treat channel paths as concrete endpoint paths so built-in channels can be mounted at their defaults or at the app root without sample-specific subclasses. Update docs, tests, and the Foundry Telegram Invocations sample accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add push support to ActivityProtocolChannel Implement the ChannelPush protocol so the Activity Protocol channel can receive cross-channel fan-out (ResponseTarget.all_linked) and echo_input replay as a non-originating destination: - Add push() that reconstructs a proactive Bot Framework activity (bot/user swap) from the stored conversation reference and POSTs it to /v3/conversations/{id}/activities. - Record a ChannelIdentity (service_url, conversation, bot, user, channel_id, locale) on ChannelRequest.identity so the host registers the channel under its isolation key for fan-out resolution. - Route the streaming path through deliver_response so Activity-originated turns broadcast like Telegram/Discord. - Add tests for push delivery, service_url validation, ChannelPush instance check, and inbound identity recording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Don't delete Telegram webhook on shutdown by default The TelegramChannel deleted its webhook on shutdown in webhook mode. During a rolling redeploy the new revision registers the webhook on startup, then the old revision's shutdown deletes it, silently breaking inbound delivery until the next boot. setWebhook is overwriting/idempotent, so startup re-asserts the webhook every boot and no teardown is needed. Add a delete_webhook_on_shutdown flag (default False) so teardown is opt-in for ephemeral deployments, and leave the webhook in place otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Activity channel streaming on non-Teams channels (405 on updateActivity) The Activity Protocol channel streamed replies the Teams way: POST a placeholder, then PUT-edit it as tokens arrive. Only Teams supports the updateActivity REST op; Web Chat, Direct Line and the Emulator return 405 Method Not Allowed on the PUT, so the user saw only the placeholder. Gate the placeholder+edit flow on edit-capable channels (msteams). Other channels now buffer the stream and POST a single final message, mirroring the non-streaming path's fan-out and response-hook semantics. Also add a defensive 405 fallback inside the Teams edit loop so an unexpected 405 can never strand the user on the placeholder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hosting-activity-protocol): don't parse Teams inline attachment content as a URI Teams message activities include a text/html attachment whose inline `content` is raw HTML (not a URL). _parse_activity fell back to `attachment["content"]` and passed it to Content.from_uri, raising ContentError ("URI must contain a scheme") and failing the whole turn, so Teams users got no response. Only treat `contentUrl` as a URI, require an absolute scheme, and skip unparseable attachments defensively instead of failing the message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(hosting-activity-protocol): native slash-command dispatch for Teams/Activity Add a commands= parameter to ActivityProtocolChannel that intercepts a leading /command (after stripping the bot's own @mention) and dispatches to ChannelCommand handlers, mirroring the Telegram channel. Unknown commands fall through to the agent. The channel run_hook is applied to command requests so handlers observe the same resolved isolation key as ordinary messages, and handler errors are swallowed (200, no Bot Service retry of non-idempotent commands). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(hosting): silent attributed Telegram echoes + Teams markdown rendering - hosting-telegram: send cross-channel input echoes with disable_notification (silent) and detect echo payloads so they aren't re-broadcast. - hosting-activity-protocol: render outbound + push activities as textFormat 'markdown' so Teams shows formatted replies (enables per-channel variants). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hosting-activity-protocol): address PR #6307 review feedback Consult the host delivery pipeline even for empty streamed replies so ResponseTarget.none is honoured and non-originating fan-out is consulted instead of always emitting an originating "(no response)" message. Applies to both the progressive-edit (Teams) and buffered (Web Chat/Direct Line) streaming paths. Re-validate service_url against the allow-list in push(): the identity is read from a persisted store and push runs out-of-band, so the captured service_url must be re-checked before a bearer token is sent. Adds tests for empty-stream host consultation/suppression on both streaming paths and for push rejecting a disallowed service_url. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
220 KiB
status, contact, date, deciders
| status | contact | date | deciders |
|---|---|---|---|
| proposed | eavanvalkenburg | 2026-04-24 | eavanvalkenburg |
Python hosting core and pluggable channels
What are the business goals for this feature?
Give Python app authors one low-level, Starlette-based hosting surface that can expose a single hostable target — either a SupportsAgentRun-compatible agent or a Workflow — on one or more channels (Responses API, Invocations API, Telegram, future A2A, MCP-tool, Activity Protocol via Azure Bot Service — which fronts Teams, Web Chat, Slack, …— WhatsApp, optional future direct-to-Teams, etc.) without requiring them to hand-build protocol routing or server glue per protocol, and let an end user start a conversation on one channel (e.g. Telegram on their phone) and seamlessly continue it on another (e.g. Teams at their desk via the Activity channel) against the same target and the same conversation history.
This consolidates the protocol-specific hosting layers that exist today (agent-framework-foundry-hosting, agent-framework-ag-ui, agent-framework-a2a, agent-framework-devui) into a shared composable model where:
- a host owns the ASGI app and channels own protocol shape,
- session identity is channel-neutral — the host resolves a session from a channel-supplied
isolation_key(e.g. a stable user identity) so two channels mounted on the same host can resolve to the sameAgentSessionfor the same end user, and a future pluggable session store extends that continuity across hosts and processes, and - channel-native identity is mapped, not assumed — the host owns a first-class
IdentityResolverseam (channel-native id →isolation_key) and anIdentityLinkerseam (well-known connect ceremony — OAuth, MFA, signed one-time code — to associate a new channel-native id with an existingisolation_key), so cross-channel continuity does not depend on each channel's user namespace happening to align, and - response delivery is decoupled from request origin — every
ChannelRequestcarries aResponseTarget(originating(default),activefor the user's most recently used channel, a specific channel id, all linked channels, ornonefor background-only). Background/asynchronous runs are first-class via aContinuationTokenreturned byhost.run_in_background(...)so a user can submit a long-running request on one channel and receive the result on another (or poll by continuation token), and - channels can be assigned different confidentiality tiers so two channels on one host can share an agent without sharing a session — e.g. Teams (corporate, allowed to access internal resources) and Telegram (public) can run against the same target while remaining session-isolated, with a host-level
LinkPolicythat decides which confidentiality tiers may be linked (and includes an explicit "deny all" variant for hosts that want no cross-channel continuity at all). Running two separate hosts is always a valid alternative; the per-tier policy exists for cases where one shared host with two policy-isolated tiers is preferred, and - multi-user surfaces (Telegram groups, supergroups, forum topics; Teams group chats and team channels) are first-class — the channel layer separates user identity from conversation locator, defaults to safe behavior (
mention_onlyaddressing,per_user_per_conversationsession scoping, link ceremonies redirected to DMs), and exposes per-channel options to opt into shared-context modes when desired (see Multi-user conversations).
We know we're successful when:
- after the agent is created, a basic multi-channel sample requires only one
AgentFrameworkHost, channel objects, and onehost.serve(...)call — no handwritten protocol routes and no per-protocol server bootstrap. The hosting core itself takes no dependency onagentserver; individual channel packages MAY depend on it where it provides directly reusable building blocks (e.g.agent-framework-foundry-hostingbuilds on the Foundry response-store SDK that ships inazure.ai.agentserver), - a single
AgentFrameworkHostconfigured with two channels (e.g. Telegram + a future Activity Protocol channel — Teams via Azure Bot Service) can be exercised by one end user across both channels and observe one continuous conversation, - an end user known on one channel can run a host-provided
link/connectcommand on a second channel, complete an OAuth (or MFA, or one-time-code) ceremony, and see subsequent messages on the second channel resolved against the sameAgentSessionas the first, and - a user can submit a long-running request on Telegram with
response_target="active", switch to Teams (via the Activity channel), and receive the result there as a proactive message — with a poll route as a fallback for callers that prefer polling.
Problem Statement
How do developers solve this problem today?
Today, every protocol surface is its own package with its own server. A developer who wants to expose one agent over both the Responses API and a webhook channel has to stand up two separate hosts and stitch them into one ASGI app by hand:
# Today: developer composes two protocol-specific hosts manually
import os
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework.foundry_hosting import (
ResponsesHostServer,
InvocationsHostServer,
)
agent = Agent(
name="WeatherAgent",
instructions="You are a helpful weather agent.",
client=OpenAIChatClient(model="gpt-4.1-mini"),
)
# Two separate, protocol-specific host wrappers, each with their own
# request/session/event mapping inside.
responses_host = ResponsesHostServer(agent=agent)
invocations_host = InvocationsHostServer(agent=agent)
# Manually mount each into a Starlette app so they share a process.
app = Starlette(routes=[
Mount("/responses", app=responses_host.app),
Mount("/invocations", app=invocations_host.app),
])
# Bring up the server by hand.
if __name__ == "__main__":
uvicorn.run(app, host="localhost", port=8000)
Adding a Telegram bot to the same agent today means leaving this stack entirely: spinning up a separate process, installing a Telegram SDK, writing the polling/webhook loop, manually translating updates into agent calls, and wiring command handlers (/start, /new, /cancel, ...) and set_my_commands(...) registration by hand — none of which is reusable across other message channels.
Why does this problem require a new hosting abstraction?
The gap is between owning a hostable target (a SupportsAgentRun agent or a Workflow) and operationalizing it on multiple channels. Agent Framework already provides agents, workflows, sessions, run inputs, response/update streaming, the SupportsAgentRun execution seam, and the Workflow execution seam. What's missing is a generic host that:
- Owns one Starlette app and one set of lifecycle hooks.
- Lets channels contribute routes, middleware, commands, and startup/shutdown without protocol leakage into the host.
- Standardizes how protocol requests become agent invocations (input, options, session, streaming) and how agent results flow back out.
- Resolves a session from a channel-neutral
isolation_keyso two channels mounted on the same host can converge on the sameAgentSessionfor the same end user — enabling cross-channel chat continuity (start on Telegram, continue on Teams) without per-channel session bookkeeping. - Provides a first-class extension seam for webhook/message channels with native command catalogs (per PR #5393 Telegram sample).
The current agentserver-based hosts are valuable prior art but sit too high in the stack — they encode protocol ownership at the host level. The new generic core learns from their behavior without depending on them; individual channel packages may still depend on the parts of agentserver that ship reusable building blocks (notably the Foundry response-store SDK).
Non-Goals / Relationship to existing hosting packages
The hosting core is deliberately not a replacement for the existing protocol packages in their first form, and it is not a multi-agent router. Hosting core, ag-ui, a2a, devui, and foundry-hosting solve adjacent but distinct problems:
| Dimension | Existing protocol packages | agent-framework-hosting |
|---|---|---|
| Mental model | One package = one protocol surface, owns its own server | One host owns ASGI app; channels plug protocols in |
| Scope | Protocol-specific request/session/event mapping | Generic host + channel contract; protocol logic lives in channel packages |
| Composition | One protocol per process or per Mount | Many channels per host, shared middleware, lifecycle, session resolution |
| Multi-agent | Out of scope per package | No. One host = one agent. Future work. |
Explicit non-goals:
- Migrating
ag-ui,a2a, ordevuionto the new core in the first implementation. - Standardizing a persistent session storage contract across all channels.
- Hosting multiple agents behind one router in this first design.
- Designing every detail of WhatsApp, the full Activity Protocol surface, or a future direct-to-Teams channel now (only Telegram is concretely targeted, informed by PR #5393; Activity Protocol via Azure Bot Service, A2A, MCP-tool, and Teams-native via
microsoft/teams.pyare designed-in fast follow — see reqs #25–#28). - Replacing protocol-specific serializers with one generic event model.
- Taking a runtime or package dependency on the legacy protocol-specific hosts (e.g.
ResponsesAgentServerHost,InvocationAgentServerHost) from the new hosting core. Channel packages MAY depend on lower-level parts ofazure.ai.agentserverwhere it ships reusable building blocks (e.g. the Foundry response-store SDK consumed byFoundryHostedAgentHistoryProvider).
Boundary rule: If you need protocol-specific event semantics, codecs, or signature validation, that lives in the channel package. The host owns ASGI, lifecycle, session resolution, and the call into the target's execution seam (SupportsAgentRun.run(...) for agents, the workflow execution seam for workflows).
Requirements
After we deliver agent-framework-hosting and its first channel packages, users will be able to:
- Compose one host with one or more channels — instantiate
AgentFrameworkHost(target=..., channels=[...])wheretargetis either aSupportsAgentRun-compatible agent or aWorkflow, and get one Starlette application with all channels mounted. - Expose the Responses API — add
ResponsesChannel()and serve/responseswithout writing protocol handlers. - Expose the Invocations API — add
InvocationsChannel()and serve/invocationswithout writing protocol handlers. - Expose a Telegram bot — add
TelegramChannel(bot_token=...)with eitherpollingorwebhooktransport, and register native commands declaratively withChannelCommand. - Override endpoint paths — pass
path="/public/responses"to move a channel endpoint, orpath=""when an external platform must call the app root. - Customize per-request invocation behavior — pass a
run_hookto any built-in channel. The hook receives the channel-producedChannelRequest(the host-neutral envelope each channel builds from its own protocol parsing — see Key Types) and returns a possibly-modifiedChannelRequest. Use it to validate, rewrite, or strip channel-derived options (e.g. enforce or droptemperature, overridesession_mode) before the host calls the target's execution seam. It is also the adapter that reshapes the channel's defaultChannelRequest.inputinto the typed inputs a workflow target requires. - Control session use per request — built-in channels set
ChannelRequest.session_modetoauto,required, ordisabled; the host honors that when resolvingAgentSession. - Partition sessions by isolation key — channels populate
ChannelSession.isolation_key(user, tenant, chat, …) using hosted-agent terminology. - Resolve to the same session across channels on one host — two channels mounted on the same
AgentFrameworkHostthat produce the sameisolation_key(e.g. a stable user identity mapped from each channel's native identifier) resolve to the sameAgentSession, so an end user starting a chat on Telegram can continue it on Teams against the same conversation history without per-channel session bookkeeping. - Map channel-native identity into
isolation_key— every channel has its own user namespace (Telegramchat_id, Teams AAD object id, WhatsApp phone, Slack user id). The host accepts a host-levelidentity_resolvercallable that maps aChannelIdentity(channel_id, native_id, attributes)into anisolation_key(orNoneif unknown). Channels publish the native identity they observed; the resolver decides whether it maps to an existing user. - Link a new channel to an existing identity through a well-known ceremony — the host accepts a host-level
identity_linker(e.g.OAuthIdentityLinker(...),OneTimeCodeIdentityLinker(...)) which contributes its own routes/lifecycle and exposes abegin(channel_identity) -> LinkChallenge/complete(challenge_id, proof) -> isolation_keyflow. Channels surface alink/connectChannelCommandthat delegates to the linker; on success the resolver subsequently maps the new channel-native identity to the existingisolation_key. Mechanism (OAuth provider, signed one-time code, future linker types) is pluggable; the contract is fixed. - Route the response to a chosen channel —
ChannelRequest.response_targetacceptsResponseTarget.originating(default — synchronous response on the originating channel),ResponseTarget.active(the channel most recently observed for the resolvedisolation_key),ResponseTarget.channel("activity")(specific channel id, recipient resolved from the link store),ResponseTarget.channels([...])(a list),ResponseTarget.identities([ChannelIdentity(...)])(one or more explicit channel-native identities — bypasses the link store, used when the caller already knows the recipient's channel-native id),ResponseTarget.all_linked(every channel where thisisolation_keyis known), orResponseTarget.none(background-only — caller must poll theContinuationToken). When the target is not the originating channel, the host delivers via the destination channel'sChannelPushcapability. - Push proactively from a channel — channels that can deliver outbound messages without a prior request (Telegram bot proactive message, Activity Protocol proactive message via Azure Bot Service, webhook callbacks, SSE broadcasts) implement an optional
ChannelPushcapability on top of the baseChannelprotocol. Channels without push can only be theoriginatingtarget. - Submit background runs as a first-class operation —
host.run_in_background(request) -> ContinuationTokenreturns immediately with an opaque, URL-safetokenand a status (queued|running|completed|failed). The host invokes the target asynchronously and, when complete, both delivers the result via the configuredResponseTargetpush and records it against the token so callers can pollhost.get_continuation(token). Built-in channels expose poll routes (/responses/{continuation_token},/invocations/{continuation_token}) that surface this without app code. Continuation tokens are persisted via aHostStateStore(file-based by default — see Host state storage) so background runs survive host restarts. - Track the active channel per
isolation_key— the host records(isolation_key, last_seen_channel, last_seen_at)on every successfully resolved request soResponseTarget.activeresolves correctly. Apps can override in therun_hook(e.g. forceactiveto a specific channel for a particular request). - Add Starlette middleware at the host level — pass
middleware=[Middleware(CORSMiddleware, ...)]toAgentFrameworkHost. - Serve with one call — call
host.serve(host="localhost", port=8000)without manually importinguvicorn, whilehost.appremains the canonical ASGI surface for any other server (Hypercorn, Daphne, Granian, Gunicorn+uvicorn workers). - Author new channels — implement the
Channelprotocol, return aChannelContributionwith routes/middleware/commands/lifecycle hooks, and callcontext.run(...)orcontext.stream(...)to invoke the agent. - Target any
SupportsAgentRunorWorkflow— host anAgent,A2AAgent, or aWorkflow; therun_hookis the seam for adapting the channel's defaultChannelRequestinto the target-specific input shape (free-form messages for agents, typed inputs for workflows). - Contribute WebSocket endpoints from a channel —
ChannelContribution.routesaccepts bothRoute(HTTP) andWebSocketRoute(WS); the channel codec is responsible for framing and the samerun_hook/ default mapping pipeline applies. Built-inResponsesChannelexposes a WebSocket transport (default/responses/ws, controlled bytransports=("http", "websocket")) alongside its HTTP+SSE transport, anticipating the OpenAI Responses WebSocket transport. The host requires an ASGI server with WebSocket scope support (Uvicorn, Hypercorn, Daphne, Granian). - Mix channels of different confidentiality tiers on one host — every
Channelmay declare an opaqueconfidentiality_tier: str | None(e.g."corp","public"). The host'sLinkPolicydecides which(source_tier, target_tier)pairs may share anisolation_key(link) and which may beResponseTargetsource/destination for one another (deliver). Built-in policies (AllowAllLinks(default),SameConfidentialityTierOnly,ExplicitAllowList,DenyAllLinks) and the policy contract are defined in LinkPolicy. Cross-tier link attempts are refused with a typed error; cross-tier deliveries are dropped — so two tiers can share an agent target on one host while remaining strictly session-isolated. - Choose an authorization profile per channel — every channel that emits a
ChannelIdentitycomposes from two orthogonal parameters,require_link: boolandallowlist: IdentityAllowlist | None, producing the three named profiles open (default), forced-link (must authenticate, any authenticated identity accepted), and allowlist (only listed identities — keyed on either the channel-native id pre-link or on a verified IdP claim post-link). Built-in allowlists (NativeIdAllowlist,LinkedClaimAllowlist, plusAnyOfAllowlists/AllOfAllowlistscombinators) and the unified host seam (host.authorize(...)→AuthorizationOutcomeofAllowed/LinkRequired/Denied) are defined in Authorization profiles and the IdentityAllowlist seam. The host applies adefault_allowlistto every channel whoseallowlistis left at the sentinel"inherit", so app authors can lock down a whole bot in one place. Configuration combinations that would silently deny every user (e.g.LinkedClaimAllowliston a channel withrequire_link=Falseand no native verified claims) are rejected at host startup with a typedChannelConfigurationError.
v1 Fast Follow
-
Generic auth helpers — shared middleware for common channel auth patterns (HMAC signature, bearer token).
-
Pluggable host state store — interface for cross-host persistence of
ContinuationTokens, identity-link grants, and last-seen(isolation_key, channel)records. Default implementation in v1 is file-based (FileHostStateStore);InMemoryHostStateStoreis available for tests. A futureCosmosHostStateStore/SQLHostStateStorewould extend cross-channel chat continuity (req #9), background runs (req #14), and identity-link continuity (req #11) beyond a single host/process — but the v1 file-based default already survives host restarts on a single node. Same protocol covers session aliasing where applicable. -
First-party identity linker helpers — concrete
OAuthIdentityLinker(with provider presets) andOneTimeCodeIdentityLinker(cross-channel code exchange) shipped as opt-in helpers on top of theIdentityLinkercontract. Investigation of additional first-party linker types tracked as a follow-up. -
A2AChannelpackage (agent-framework-hosting-a2a) — exposes the hostable target over the Agent-to-Agent protocol so other agents can consume it as a peer. Caller-supplied-session family (alongside Responses and Invocations): A2A's per-conversation id maps toChannelSession.key; the calling agent's identity (e.g. its A2A agent card / signed JWT) flows throughIdentityResolver; structured replies fit the existingChannelRequest+ResponseTargetenvelope. No new host primitives required — only the protocol binding and package. -
MCPToolChannelpackage (agent-framework-hosting-mcp) — exposes the hostable target as a Model Context Protocol tool so MCP clients (other agents, IDE tooling) can invoke it. Same caller-supplied-session family: the MCPtool/callcarries the conversation key intoChannelSession.key; the MCP client identity flows throughIdentityResolver; the tool result is the target's response. Streaming MCP tools map onto the host's existing streaming response delivery; long-running MCP tools map onto background runs withContinuationTokenwhen the work outlasts a single tool-call round-trip. -
ActivityChannelpackage (agent-framework-hosting-activity) — exposes the hostable target behind Azure Bot Service, which fronts Teams, Web Chat, Slack-style connectors, and the rest of the Bot Framework / M365 connector ecosystem. Provides native translations between Activity Protocol objects (Activity,ConversationReference, adaptive cards,Invokeactivities, …) and the host'sChannelRequest/ChannelResponsetypes — so the contract is explicit rather than implicit through a generic Invocations endpoint. Host-tracked-session family: Bot Service authenticates with a JWT carrying the AAD object id, the channel populatesChannelIdentityfromfrom.aadObjectId, the host's per-isolation_keyalias decides whichAgentSessionto resolve, andhost.reset_session(...)is reachable via a Teams slash command or adaptive-card action.ChannelPushis implemented over Bot Service'sConversationReference+continueConversationAsyncpattern. Naming this channel Activity rather than Teams keeps aTeamsChannelname available for the Teams-native channel below (req #29) and for any future direct-to-Teams transport. -
TeamsChannelpackage (agent-framework-hosting-teams) — Teams-native channel built on the MIT-licensedmicrosoft/teams.pySDK (microsoft-teams-apps,microsoft-teams-api,microsoft-teams-cards). WhereActivityChannel(req #28) targets the generic Activity Protocol surface across all Bot Service-fronted channels,TeamsChannelexploits Teams-specific affordances that the generic Activity Protocol does not surface natively:- Adaptive Cards via the typed
microsoft-teams-cardsbuilder, attached as tool side-effects through aContextVar-scoped pending-cards collector consumed by the channel's result projector. - Streamed assistant replies via
ctx.stream.emit(chunk)— the channel projectsagent.run(..., stream=True)chunks directly. - Teams "AI generated" badge, built-in feedback controls + custom feedback form, suggested-prompt chips (
SuggestedActions/CardAction(IM_BACK)), inline citations (CitationAppearancepopulated from aFunctionMiddlewarethat assigns stable positions to tool-result sources). - Modal Dialogs (multi-step forms) with submission events routed through the host's normal request pipeline.
- Message Extensions — action commands (modal forms invoked from the compose box / message context menu), search commands (typed-ahead inline cards), and link unfurling (preview cards on URL paste). Each is exposed via the same
ChannelCommandmodel as Telegram-style slash commands. - Proactive, targeted (ephemeral), and threaded messages via
app.send(conversation_id, MessageActivityInput(...)),with_recipient(account, is_targeted=True), andto_threaded_conversation_id(conversation_id, message_id)— used byChannelPushand byResponseTarget.identities([ChannelIdentity(channel="teams", chat_id=…)]). - SSO / OAuth via the SDK's MSAL-backed connections, surfaced through
IdentityResolverand the channel's run hook. - Teams API client + Microsoft Graph client preconfigured on the SDK's
App, available to the run hook for Teams-specific lookups (team roster, channel metadata, …) without re-implementing auth.
Mounts the SDK's
Appinto the host's Starlette app via a customHttpServerAdapterthat defersregister_route(...)toChannelContribution.routes— the SDK does not start its own server; the host owns the lifecycle. Host-tracked-session family (same asActivityChannel):from.aadObjectIdpopulatesChannelIdentity. The result projector readsAgentRunResult.messages[*].contentsand routes the rich content variants to their Teams-native renderings (TextContent→ markdown body,DataContent/structured output → Adaptive Card, citation entries fromadditional_properties→add_citation,ErrorContent→ typed error card).Note on transport.
TeamsChannelstill rides on Azure Bot Service in v1 — themicrosoft/teams.pySDK is a higher-level Pythonic wrapper over the same Activity Protocol pipeline thatActivityChannelexposes raw. The difference is what the developer writes against, not the underlying network path. A truly Bot-Service-free Teams transport is not currently possible and is tracked as a separate, speculative stretch item (req #31); when/if Microsoft ships one, the new transport would slot in under the sameTeamsChannelpackage without changing this requirement.ActivityChannelvsTeamsChannel— pick by audience:Channel Built on Audience ActivityChannel(req #28)Activity Protocol over HTTP, no Teams-specific helpers Bot Service-fronted channels generically (Teams, Web Chat, Slack-style connectors, DirectLine, …); maximum portability across the Bot Framework / M365 connector ecosystem TeamsChannel(req #29)microsoft/teams.pyAppmounted via customHttpServerAdapterinto the host's Starlette appTeams-first deployments that want Adaptive Cards, modal Dialogs, Message Extensions, citations, feedback, suggested-prompt chips, and SSO out-of-the-box Deployments that only need plain Activity Protocol over Bot Service stick with
ActivityChannel;TeamsChannelis the upgrade path when Teams-native richness is wanted. - Adaptive Cards via the typed
Stretch
- WhatsApp channel package — using the same
Channel+ChannelCommandmodel, designed so it participates in cross-channel continuity (req #9) and can serve as aChannelPushdestination (req #13) when paired with a stable per-userisolation_key. - Direct-to-Teams channel package — speculative. Reserved for a future transport that connects to Teams without going through Azure Bot Service (and therefore without the Activity Protocol pipeline that backs both
ActivityChannel(req #28) andTeamsChannel(req #29)). At the time of writing no such transport is publicly available — the Microsoft Graph chat APIs (/teams/{id}/channels/{id}/messages,/chats/{id}/messages) and themicrosoft/teams.pySDK both ultimately route through Bot Service for the bot-as-conversation-participant pattern. This requirement is kept on the roadmap purely to preserve theTeamsChannelnaming line for if/when Microsoft ships a Bot-Service-free transport (a native Teams REST/RPC, a Graph subscription strong enough to drive both inbound and outbound message flow, or similar). Until then, the canonical Teams channel isTeamsChannel(req #29) andActivityChannel(req #28) covers the generic Bot Service surface.
API Surface
Architecture overview
The host wires one Agent (or Workflow) to one or more channels, each contributing routes, commands, and a push back-channel. 1a is the runtime topology — how an inbound request flows through the host. 1b is the contribution shape — what each channel hands the host at construction.
Runtime topology
graph LR
Caller[External caller /<br/>messaging app]
subgraph Host[AgentFrameworkHost]
direction TB
ASGI[Starlette app]
Router[Channel router]
Parse{parse →<br/>command or<br/>message?}
Auth[host.authorize]
Resolver[IdentityResolver]
Delivery[_deliver_response]
Push[_handle_push_task]
Annot[_annotate_intended_targets]
end
Channels[Channels<br/>Responses · Invocations ·<br/>Telegram · Activity ·<br/>IdentityLinker]
CmdHandler[CommandHandler<br/>via ChannelCommandContext]
Target[(Agent or Workflow)]
Runner[DurableTaskRunner]
StateStore[(HostStateStore)]
Caller --> ASGI
ASGI --> Router
Router --> Parse
Parse -- /command --> CmdHandler
Parse -- message --> Auth
CmdHandler -- ctx.run --> Auth
CmdHandler -- local reply --> Channels
Auth --> Resolver
Resolver --> StateStore
Auth --> Target
Target --> Delivery
Delivery -- originating sync --> Channels
Delivery -- non-originating --> Runner
Delivery --> Annot
Runner --> Push
Push --> Channels
Channels --> ASGI
Channel contribution shape
Every channel exposes the same three contribution slots, all optional except routes. The host duck-types each slot and stitches them in at construction.
graph LR
subgraph C[ConcreteChannel<br/>e.g. TelegramChannel]
direction TB
Routes[routes:<br/>webhook / poller / API endpoints<br/>→ Starlette router]
Commands[commands: Sequence ChannelCommand<br/>name · description · handle ·<br/>scopes · locales · expose_in_ui]
Push[ChannelPush.push<br/>+ optional ChannelPushCodec<br/>+ optional response_hook]
end
Host[Host]
Native[Platform native catalog<br/>Telegram set_my_commands ·<br/>Teams app manifest · …]
Dispatch[CommandHandler dispatch]
Delivery[Originating sync delivery<br/>+ runner-scheduled fan-out]
Routes -- contribute at startup --> Host
Commands -- startup projection --> Native
Commands -- runtime dispatch --> Dispatch
Push -- driven by --> Delivery
The IdentityLinker is itself a Channel specialisation: when one is configured, the host auto-inserts a link / connect ChannelCommand into every other channel's catalog (opt-out per channel via expose_in_ui=False or rename via metadata).
Packages
| Distribution package | Public import surface | Purpose |
|---|---|---|
agent-framework-hosting |
agent_framework.hosting |
Core Starlette host, channel contract, session/request bridge |
agent-framework-hosting-responses |
agent_framework.hosting (lazy) |
ResponsesChannel |
agent-framework-hosting-invocations |
agent_framework.hosting (lazy) |
InvocationsChannel |
agent-framework-hosting-telegram |
agent_framework.hosting (lazy) |
TelegramChannel and Telegram-specific helpers |
The split is between distribution packages. The public import path stays stable at agent_framework.hosting via lazy imports, consistent with the repository's packaging conventions.
Built-in routes
For built-in channels, path is the configurable endpoint root. Use path="" when an external platform requires that channel at the app root.
| Channel | Default path |
Default exposed route(s) |
|---|---|---|
ResponsesChannel |
/responses |
/responses |
InvocationsChannel |
/invocations |
/invocations |
TelegramChannel |
/telegram/webhook |
webhook mode: /telegram/webhook; polling mode: no required HTTP route |
Overrides replace the endpoint path:
ResponsesChannel(path="/public/responses") # -> /public/responses
InvocationsChannel(path="/internal/invocations") # -> /internal/invocations
TelegramChannel(path="/bots/telegram/webhook", bot_token=token) # -> /bots/telegram/webhook
Key Types
AgentFrameworkHost — owner of the Starlette app and channel lifecycle. Fronts one hostable target (an agent or a workflow).
| Field / Method | Type | Description |
|---|---|---|
__init__(target, *, channels, middleware=(), identity_resolver=None, identity_linker=None, debug=False) |
constructor | Composes one host from one hostable target (SupportsAgentRun or Workflow) and a sequence of channels. Optional identity_resolver and identity_linker provide channel-native-id → isolation_key mapping and a connect ceremony for linking new channels to existing identities. The host detects the target kind and dispatches to the appropriate runner. |
app |
Starlette |
Canonical ASGI surface; can be handed to any ASGI server. |
serve(*, host="127.0.0.1", port=8000, **kwargs) |
method | Convenience wrapper around uvicorn.run(self.app, ...). Lazy-imports uvicorn. |
run_in_background(request) |
-> ContinuationToken |
Submits a ChannelRequest for asynchronous execution. Returns a ContinuationToken immediately; the result is delivered via the configured ResponseTarget push when ready and recorded against the token (in the configured HostStateStore) for later polling. Channels typically call this when their protocol response should be a 202 / acknowledgement rather than the agent reply. |
get_continuation(token) |
-> ContinuationToken | None |
Look up a previously submitted background run by its opaque token. Returns None when the token is unknown or has expired. Reads through the HostStateStore so tokens issued before the most recent restart still resolve. |
HostableTarget — the union of executable targets the host can front.
| Variant | Type | Execution seam |
|---|---|---|
| Agent | SupportsAgentRun |
target.run(input, *, session=..., stream=...) |
| Workflow | Workflow |
target.run(input, ...) (workflow execution seam) |
Channel (Protocol) — anything that contributes routes/commands/lifecycle to a host.
| Field | Type | Description |
|---|---|---|
name |
str |
Channel name used for routing, telemetry, and ChannelRequest.channel. |
confidentiality_tier |
str? |
Optional opaque confidentiality tier (e.g. "corp", "public"). Consumed by the host's LinkPolicy to decide which channels may be linked into the same isolation_key and which may be ResponseTarget destinations for a given originating request. None = single-tier (no policy filtering). See LinkPolicy. |
contribute(context: ChannelContext) -> ChannelContribution |
method | Called once at host construction; returns routes/middleware/commands/lifecycle. |
ChannelContext — host-owned bridge channels use to invoke the agent.
| Method | Type | Description |
|---|---|---|
run(request: ChannelRequest) |
-> HostedRunResult[Any] |
One-shot invocation. For agent targets TResult narrows to AgentResponse; for workflow targets to WorkflowRunResult. |
stream(request: ChannelRequest) |
-> HostedStreamResult |
Streaming invocation. |
ChannelContribution — what a channel returns from contribute(...).
| Field | Type | Description |
|---|---|---|
routes |
Sequence[BaseRoute] |
Starlette routes mounted under the channel's path. Accepts both Route (HTTP) and WebSocketRoute (WS) — both are BaseRoute. |
middleware |
Sequence[Middleware] |
Channel-scoped middleware. |
commands |
Sequence[ChannelCommand] |
Native command catalog (e.g. Telegram bot commands). |
on_startup |
Sequence[Callable] |
Lifecycle hooks for polling workers, command registration, etc. |
on_shutdown |
Sequence[Callable] |
Lifecycle hooks for cleanup. |
ChannelRequest — normalized ingress passed to the host.
| Field | Type | Description |
|---|---|---|
channel |
str |
Originating channel name. |
operation |
str |
e.g. message.create, command.invoke, approval.respond. |
input |
AgentRunInputs |
Reuses framework input types. |
session |
ChannelSession? |
Session hint from the channel. |
options |
ChatOptions? |
Caller-derived options (e.g. Responses temperature). |
session_mode |
Literal["auto", "required", "disabled"] |
Whether host-managed session use is automatic, mandatory, or bypassed. |
metadata |
Mapping[str, Any] |
Protocol-level metadata for telemetry. |
attributes |
Mapping[str, Any] |
Channel-specific structured values (signature state, capability hints). Host code never reads this map; reserved for channel-private bookkeeping. |
client_state |
Mapping[str, Any] | None |
Bidirectional, mutable per-request state object supplied by event-rich front-ends (e.g. AG-UI). Channel-defined shape; the host treats it as opaque. Channels typically thread this into a channel-owned ContextProvider (see Channel-owned per-thread state) and read it back after the run to emit state-snapshot/delta events. |
client_tools |
Sequence[ToolDescriptor] | None |
Frontend tool catalog supplied per request. The channel forwards definitions onto the agent's ChatOptions so the LLM can call them, but tool execution returns to the originating client (the host does not invoke them). Run hooks may filter or rewrite the catalog. |
forwarded_props |
Mapping[str, Any] | None |
Pass-through bag for channel-protocol extras the run hook needs to route into the target — e.g. AG-UI resume / command / HITL response payloads that drive workflow RequestInfo / RequestResponse round-trips. Opaque to the host; the run hook decides where it lands on the rebuilt ChannelRequest.input. |
identity |
ChannelIdentity? |
Channel-native user identity observed on this request — (channel, native_id, attributes). Channels populate it from the inbound payload's user field (Telegram from.id, Teams from.aadObjectId, Responses safety_identifier, …) — not the chat / conversation id, which is carried separately on conversation_id and matters in multi-user surfaces (Telegram groups, Teams group chats and channels — see Multi-user conversations). The host records (isolation_key, channel) → identity on every successful resolve so ResponseTarget.active, .channel(name), .channels([...]), and .all_linked can find a destination native id without per-request payload bookkeeping. |
stream |
bool |
Whether to invoke stream(...) rather than run(...). |
response_target |
ResponseTarget |
Where the response is delivered (default: ResponseTarget.originating). See ResponseTarget below. |
background |
bool |
If True, host returns a ContinuationToken immediately rather than awaiting the response. Forced True when response_target == ResponseTarget.none. |
ChannelSession — small, host-neutral session hint.
| Field | Type | Description |
|---|---|---|
key |
str? |
Stable host lookup key for an AgentSession. Caller-supplied channels populate it from the wire payload (e.g. previous_response_id, request-body session_id). Host-tracked channels leave it None and let the host's per-isolation_key alias decide which AgentSession to resolve (see Channel session-carriage models). |
conversation_id |
str? |
Protocol-visible conversation/thread identifier when one exists. |
isolation_key |
str? |
Opaque isolation boundary (user, tenant, chat, …) using hosted-agent terminology. |
attributes |
Mapping[str, Any] |
Channel-specific session hints. |
ChannelRunHook — per-request escape hatch for built-in channels.
ChannelRunHook = Callable[..., Awaitable[ChannelRequest] | ChannelRequest]
Channels invoke the hook positionally with the channel-built ChannelRequest and pass named extras as keyword arguments. The minimum signature an app author needs is:
def my_hook(request: ChannelRequest, **kwargs) -> ChannelRequest: ...
Hooks that want the named extras pull them out by name:
| Keyword | Type | Description |
|---|---|---|
target |
SupportsAgentRun | Workflow |
The hosted target (so hooks can adapt to e.g. A2AAgent or to a Workflow's typed inputs). |
protocol_request |
Any? |
Original channel-native protocol payload — Responses JSON body, Telegram Update dict, Activity Protocol Activity dict, Invocations body, … (loosely typed in v1). |
Runs after the channel has produced its default ChannelRequest, before the host resolves session behavior and calls the target's execution seam. This is the canonical adapter point for workflow targets, where the channel's free-form input must be reshaped into the workflow's typed inputs.
Earlier drafts wrapped these arguments into a
ChannelRunHookContextobject. The signature was simplified so the typical hook only needs(request, **kwargs)— making it safe against future named extras and easier to write inline.
ChannelIdentity — the channel-native identity the host sees on each request, used as the resolver/linker input.
| Field | Type | Description |
|---|---|---|
channel |
str |
Originating channel name (matches Channel.name). |
native_id |
str |
Channel-native user identifier (Telegram from.id, Teams from.aadObjectId, WhatsApp phone number, Slack user id, …). In 1:1 chats this often coincides with the chat / conversation id; in multi-user surfaces (Telegram groups, Teams group chats and channels) it is strictly the user — the conversation locator lives separately on ChannelRequest.conversation_id / ChannelSession.conversation_id. Always per-channel; never assumed to align across channels. |
attributes |
Mapping[str, Any] |
Optional per-channel context (display name, locale, group/private chat flag, Teams tenantId, Telegram chat.type, Teams conversationType, …) the resolver/linker may key on. |
IdentityResolver — host-level seam that maps a ChannelIdentity to an isolation_key.
IdentityResolver = Callable[[ChannelIdentity], Awaitable[str | None] | (str | None)]
The default resolver auto-issues an isolation_key the first time a (channel, native_id) is seen and persists the mapping in the host's identity store, so every end user automatically gets a stable per-user isolation_key on first contact through any channel — no per-channel boilerplate is required for the single-channel case. Returning None is reserved for advanced cases where the resolver wants to refuse unknown identities; the dedicated host seam for accept/reject decisions is IdentityAllowlist — see Authorization profiles and the IdentityAllowlist seam below.
Cross-channel continuity is then a one-shot merge operation: after a successful link ceremony (Scenario 6), the host atomically rewrites the second channel's auto-issued key to point at the first channel's existing isolation_key. Apps never have to write per-channel mapping hooks just to get continuity to work.
Apps that already own an identity namespace (corporate user id, tenant-scoped account id) can supply a custom resolver that returns those values directly — bypassing auto-issuance.
IdentityLinker (Protocol) — host-level seam that runs a connect ceremony to associate a new ChannelIdentity with an existing isolation_key. The linker is a peer of Channel for routing purposes and contributes its own routes/lifecycle.
| Field / Method | Type | Description |
|---|---|---|
name |
str |
Linker name; used for telemetry and to namespace its routes. |
contribute(context: ChannelContext) -> ChannelContribution |
method | Same shape as Channel.contribute(...); lets the linker publish callback/verification routes (e.g. /identity/oauth/callback, /identity/verify) and lifecycle hooks. |
begin(identity: ChannelIdentity, *, requested_isolation_key=None) -> LinkChallenge |
method | Starts the ceremony for a channel-native identity. Returns a LinkChallenge describing what the user must do (URL to visit, code to enter, MFA prompt). |
complete(challenge_id: str, proof: Mapping[str, Any]) -> str |
method | Verifies the proof and returns the resolved isolation_key. On success the host atomically records both (channel, native_id) → isolation_key and any verified IdP claim recovered from the proof (e.g. (microsoft.oid, <oid>)) so subsequent channels that supply the same claim auto-link without a second ceremony. |
is_linked(identity: ChannelIdentity, *, verified_claims: Mapping[str, str] = {}) -> str | None |
method | Returns the isolation_key for an already-linked identity, or None if no link exists. Channels with require_link=True call this on every inbound request before invoking the agent. When verified_claims are supplied (e.g. Teams' AAD oid from the inbound activity bearer) and a match exists in the link store, the linker silently auto-merges the new (channel, native_id) onto the existing isolation_key and returns it — this is the "sign in once, every other channel just works" mechanism. |
| Built-in helper | Mechanism | Notes |
|---|---|---|
OAuthIdentityLinker(provider, ...) |
OAuth authorization-code redirect | Contributes /identity/oauth/{provider}/start + /callback; ships with provider presets (Microsoft, Google, GitHub) as opt-in helpers. Stores the verified IdP sub / oid as a verified claim alongside the channel-native identity so channels that authenticate with the same IdP (e.g. Teams via Entra ID) auto-link on first contact. |
OneTimeCodeIdentityLinker(...) |
Signed short-lived code | User runs /link on channel A, receives a code; runs /link <code> on channel B; host verifies and merges. |
A built-in link (or connect) ChannelCommand is exposed automatically when an IdentityLinker is configured. Its handle invokes linker.begin(...) and replies with the LinkChallenge payload (URL, code, instructions) projected through the channel's native rendering. Channels may opt out (expose_in_ui=False) or override the command's name per channel.
require_link (per-channel) — every channel that emits a ChannelIdentity accepts a require_link: bool = False constructor argument. When True, the channel calls linker.is_linked(identity, verified_claims=…) before producing a ChannelRequest; un-linked identities are short-circuited to a rendered LinkChallenge reply (the same payload the link command would emit) and the agent is not invoked for that turn. Combined with the linker's verified-claim auto-link, this gives an "authenticate before chatting" enforcement model where the first channel forces the OAuth ceremony and subsequent channels join the same isolation_key silently. See Scenario 6 for the end-to-end flow. Default is False, which preserves the opportunistic flow (auto-issued isolation_key, link manually later). Channels whose protocol does not authenticate the user (e.g. anonymous Responses calls) ignore the flag. require_link is the "identity must be linked" axis; the orthogonal "identity is on the accept list" axis is allowlist — see Authorization profiles and the IdentityAllowlist seam below.
Authorization profiles and the IdentityAllowlist seam
require_link (above) and allowlist (below) compose into the three named authorization profiles the spec supports for any channel that emits a ChannelIdentity. The two parameters stay orthogonal on the channel constructor — there is no single auth_mode enum — but the host exposes named factories on AuthPolicy (AuthPolicy.open() / .require_link() / .native_allowlist(...) / .linked_claim_allowlist(...) / .mixed(...)) for ergonomic configuration:
| Profile | Channel config | What gets gated | Typical use |
|---|---|---|---|
| Open (default) | require_link=False, allowlist=None |
Nothing — every identity gets an auto-issued isolation_key on first contact. |
Public chatbot, internal dev/demo, single-tenant deployments. |
| Forced link | require_link=True, allowlist=None |
Identity must complete the link ceremony at least once. Any successfully authenticated identity is then allowed. | "Sign in once with your corporate account, then chat freely" style bots that gate on tenancy via the IdP rather than per-user. |
| Native allowlist | require_link=False, allowlist=NativeIdAllowlist(...) |
Only listed channel-native ids (Telegram chat_ids, WhatsApp numbers, Slack user ids) get through. Pre-link, no IdP claim involved. |
Personal bots, single-user prototypes, small fixed-membership channels. |
| Linked-claim allowlist | require_link=True, allowlist=LinkedClaimAllowlist(...) |
Identity must (a) complete the link ceremony and (b) carry an IdP claim whose value is on the list (e.g. AAD oid in {…} or tid == "<tenant>"). |
Multi-channel corporate bot where any channel works but only specific people in a specific tenant are admitted. |
| Mixed | require_link=False, allowlist=AnyOfAllowlists(NativeIdAllowlist(...), LinkedClaimAllowlist(...)) |
Either the native id is preapproved or the user successfully links and matches the claim allowlist. Native-id hits bypass the link ceremony; everyone else is funneled into it. | A bot that wants ops-team Telegram ids in immediately while still letting other corp users self-onboard via OAuth. |
The decision pipeline that produces each of those profiles:
flowchart TB
Start([authorize identity,<br/>require_link, allowlist])
Linked{identity already<br/>linked?<br/>StateStore lookup}
Required{require_link?}
OpenPath{allowlist is None?}
Resolve[/isolation_key:<br/>linked → existing,<br/>else auto-issue channel:native_id/]
Evaluate[/allowlist.evaluate context/]
Decision{decision}
Abstain{requires_linked_claims?}
Allowed([Allowed isolation_key])
DeniedPre([Denied<br/>allowlist_denied_pre_link])
LinkReq([LinkRequired<br/>via configured linker])
Start --> Linked
Linked -- yes --> OpenPath
Linked -- no --> Required
Required -- yes --> LinkReq
Required -- no --> OpenPath
OpenPath -- yes --> Resolve --> Allowed
OpenPath -- no --> Evaluate --> Decision
Decision -- ALLOW --> Resolve
Decision -- DENY --> DeniedPre
Decision -- ABSTAIN --> Abstain
Abstain -- yes --> LinkReq
Abstain -- no --> Resolve
The flow shows three terminal states: Allowed, LinkRequired, Denied. LinkRequired is reachable whenever require_link=True and the identity has not completed the link ceremony (or an allowlist ABSTAINed and requires_linked_claims=True), independent of whether an allowlist is configured.
IdentityAllowlist Protocol (tri-state)
Allowlists are evaluated by a host-level pipeline (host.authorize(...), below) that calls them twice — once with the raw channel-native identity (phase="pre_link") and, if necessary, again after the link ceremony surfaces verified IdP claims (phase="post_link"). To make composition (AnyOfAllowlists, AllOfAllowlists) well-defined and to keep claim-based allowlists from accidentally denying everyone when claims are not yet available, the contract is tri-state:
class AllowlistDecision(StrEnum):
ALLOW = "allow" # accept this identity unconditionally
DENY = "deny" # reject this identity unconditionally
ABSTAIN = "abstain" # this allowlist has no opinion at this phase
# (e.g. a claim-based list during pre_link)
@dataclass(frozen=True)
class AuthorizationContext:
identity: ChannelIdentity
phase: Literal["pre_link", "post_link"]
isolation_key: str | None # None at pre_link; resolved at post_link
verified_claims: Mapping[str, str] # {} when no claims; populated post_link
claim_source: Literal["linker", "channel", "none"]
# "channel" when the channel itself emits
# verified claims (e.g. Activity Protocol
# bearer with AAD oid); "linker" when the
# IdentityLinker surfaces them; "none" otherwise.
class IdentityAllowlist(Protocol):
requires_linked_claims: bool = False # if True, host validation rejects
# configurations where neither `require_link`
# nor a claim-emitting channel can deliver
# the claims this allowlist needs.
async def evaluate(self, context: AuthorizationContext) -> AllowlistDecision: ...
ABSTAIN is not a denial — it is "this allowlist has no information yet". The host's decision pipeline (below) is what turns an all-ABSTAIN outcome into the appropriate next step (allow when open, escalate to a link ceremony when the configuration calls for one). Boolean allowlists were rejected as part of this design pass because two-state composition cannot distinguish "claim allowlist denies you" from "claim allowlist hasn't seen any claims yet" — a critical distinction for the Mixed profile.
Built-in allowlists
| Helper | Pre-link behavior | Post-link behavior | Notes |
|---|---|---|---|
AllowAll() |
ALLOW |
ALLOW |
Explicit "open" sentinel; useful for tests and for overriding a host-level default_allowlist. |
NativeIdAllowlist(channel=None, native_ids=...) |
ALLOW if (channel, native_id) is on the list; DENY if channel matches but native_id does not; ABSTAIN if channel does not match (allows mixing per-channel native lists under one AnyOfAllowlists). |
Same as pre-link — native-id allowlists do not depend on link state. | Constructor accepts native_ids: Collection[str] | Callable[[], Awaitable[Collection[str]]] so the list can be loaded asynchronously (config file, secret store). |
LinkedClaimAllowlist(claim, values) |
ABSTAIN (no claims available yet). |
ALLOW if verified_claims.get(claim) is in values; DENY otherwise. |
requires_linked_claims = True. Host construction-time validator rejects use with require_link=False on a channel that does not also emit verified claims natively — this prevents the silent-deny-everyone footgun. |
AnyOfAllowlists(*allowlists) |
ALLOW if any child ALLOWs; DENY only if all children DENY; otherwise ABSTAIN. |
Same rule. | Composition for the Mixed profile. |
AllOfAllowlists(*allowlists) |
DENY if any child DENYs; ALLOW only if all children ALLOW; otherwise ABSTAIN. |
Same rule. | E.g. require both tenancy (LinkedClaimAllowlist("tid", ...)) and group membership (LinkedClaimAllowlist("groups", ...)). |
CallableAllowlist(fn) |
Calls fn(context) and returns its result. |
Same. | Escape hatch for app-specific logic; recommended only after exhausting the structured variants. |
Host configuration: default_allowlist + explicit channel inheritance
Allowlists can be configured at the host level (AgentFrameworkHost(default_allowlist=...)) and per-channel. The channel-side default is explicit inheritance, not an implicit None:
class SomeChannel:
def __init__(
self,
*,
require_link: bool = False,
allowlist: IdentityAllowlist | Literal["inherit"] | None = "inherit",
): ...
allowlist="inherit"(default) → the host'sdefault_allowlistapplies. If the host did not set one either, the channel is open.allowlist=None→ the channel is explicitly open, even if the host has adefault_allowlist. Used to carve out a public endpoint inside an otherwise-locked-down host.allowlist=<IdentityAllowlist>→ that allowlist applies, overriding the host default. To add to the host default rather than replace it, compose explicitly:allowlist=AllOfAllowlists(host.default_allowlist, MyExtraList()).
host.authorize(...) and AuthorizationOutcome
Channels do not run the decision pipeline themselves — they call into a single host seam after extracting ChannelIdentity and any natively verified claims:
@dataclass(frozen=True)
class Allowed:
isolation_key: str
@dataclass(frozen=True)
class LinkRequired:
challenge: LinkChallenge
@dataclass(frozen=True)
class Denied:
reason_code: str # stable, machine-readable
user_message: str | None = None # safe to render publicly (group-chat-safe)
log_details: Mapping[str, Any] = {} # never shown to users; structured for audit
AuthorizationOutcome = Allowed | LinkRequired | Denied
async def host.authorize(
identity: ChannelIdentity,
*,
require_link: bool,
allowlist: IdentityAllowlist | None,
verified_claims: Mapping[str, str] | None = None,
conversation_context: ConversationContext | None = None, # for group-chat policy
) -> AuthorizationOutcome: ...
Decision order (the pipeline the host runs):
- Build
AuthorizationContext(phase="pre_link", verified_claims=verified_claims or {}, claim_source=…). decision_pre = allowlist.evaluate(context_pre)(defaults toALLOWwhenallowlist is None).decision_pre == DENY→Denied(reason_code="allowlist_denied_pre_link", ...).decision_pre == ALLOW:- If
require_link=Trueand the linker has no record yet →LinkRequired(linker.begin(identity)). - Otherwise →
Allowed(resolved_or_auto_issued_isolation_key).
- If
decision_pre == ABSTAIN:- If
require_link=Trueor the allowlist declaredrequires_linked_claims: attemptlinker.is_linked(identity, verified_claims=…).- Not linked →
LinkRequired(linker.begin(identity)). - Linked → evaluate again at
phase="post_link"with the linker-emitted claims.ALLOW→Allowed(linked_isolation_key).DENY→Denied(reason_code="allowlist_denied_post_link", ...).ABSTAINpost-link is a misconfiguration (no allowlist had an opinion even after linking); logged and treated asDenied(reason_code="allowlist_abstain_after_link").
- Not linked →
- Otherwise (open profile, no claim dependency):
Allowed(auto_issued_isolation_key).
- If
The channel renders the outcome — Allowed proceeds to ChannelRequest, LinkRequired projects the LinkChallenge through the channel's native UX (same path the link command already uses), Denied projects user_message (when set) through a short refusal. The channel never sees log_details and is responsible for not echoing reason_code to end users.
Configuration validation (fail-fast)
The host runs a startup validator across (channel.require_link, channel.allowlist) for every channel:
- If
channel.allowlist(after resolving"inherit") contains any allowlist withrequires_linked_claims=True, the channel must either haverequire_link=Trueor declare via a channel attribute that it natively emits verified claims (Channel.emits_verified_claims: bool = False). Otherwise:raise ChannelConfigurationError("LinkedClaimAllowlist requires a source of verified claims; set require_link=True on <channel> or use a channel that emits them natively"). - If
channel.allowlistcontains aLinkedClaimAllowlistand the host has noidentity_linkerconfigured: sameChannelConfigurationError. - If
channel.allowlistcontains aNativeIdAllowlist(channel=<other>)whose<other>is not a known channel on this host:ChannelConfigurationError.
These errors are raised eagerly at AgentFrameworkHost.__init__ (or host.serve(...) startup), not on the first inbound request — silent deny-everyone is the worst possible default and is not allowed.
Group chats and privacy of denial
Authorization runs per message, not per conversation: in a group chat, one allowlisted user invoking the bot does not authorize other group members for subsequent messages. The host also mirrors the LinkChallenge group-chat redirect pattern (see Multi-user conversations) for denials:
- In a 1:1 chat, the channel may render the full
user_messagefromDenied. - In a group chat, the channel renders a generic refusal in-room (e.g. "You don't have access to this bot.") and, where the channel supports it, follows up with a DM containing the longer
user_message. The fulllog_detailspayload only reaches the host's structured logs / OpenTelemetry span — never the wire.
Built-in user_message defaults are intentionally bland and tenancy-free ("You don't have access to this bot." / "Please link your account to continue.") to avoid leaking who else is in the allowlist or which tenant gates it.
v1 shipping surface
The core PR includes the channel-neutral authorization and identity-linking seam; provider-specific linker packages (for example Entra OAuth helpers) plug into it without making the core package depend on an IdP SDK:
IdentityAllowlistProtocol +AllowlistDecisionenum +AuthorizationContextdataclass.AllowAll,NativeIdAllowlist,LinkedClaimAllowlist,AnyOfAllowlists,AllOfAllowlists,CallableAllowlistbuilt-ins.IdentityLinkerProtocol,LinkedIdentity, andLinkChallengecore types. A linker resolves a channel-native identity in one call, returning either a linked identity with verified claims or a challenge for the channel to render.AuthorizationOutcome(Allowed/LinkRequired/Denied) types.AuthPolicyfactory helpers on the public surface.Host(default_allowlist=..., identity_linker=...)+ per-channelallowlist: ... | Literal["inherit"] | Noneparameter and the construction-time config validator. The validator enforces rules #1 (claim-source), #2 (linker presence — channels withrequire_link=Truemust be paired with a configuredidentity_linker; otherwise aChannelConfigurationErroris raised at construction so misconfigurations cannot ship), and #3 (NativeIdAllowlist channel typo). Combinator walking (AnyOf/AllOf) is recursive so nested misconfigurations are caught at the host level.host.authorize(identity, *, require_link, allowlist, verified_claims=None)supports open, native-id allowlist, and claim allowlist profiles end-to-end. The open path returnsAllowedwith an auto-issued<channel>:<native_id>isolation key (linear-scan registry lookup re-issues a known key when the identity has been seen before). Native-id allowlists returnAllowed/Deniedper the list. Claim-based allowlists use channel-emittedverified_claimswhen present; otherwise, when a linker is configured, the host returnsLinkRequired(challenge)for unresolved identities or evaluatesLinkedClaimAllowlistagainst the linker's verified claims for resolved identities.
LinkPolicy and confidentiality_tier
LinkPolicy — host-level decision over which channels may share an isolation_key and which channels may be a ResponseTarget for one another. Consumed by both the IdentityLinker (to refuse incompatible link attempts) and the host's response-routing layer (to filter all_linked / active / specific destinations).
LinkPolicy = Callable[[LinkPolicyContext], bool]
LinkPolicyContext carries the originating Channel (and its confidentiality_tier), the prospective destination Channel (and its confidentiality_tier), and the operation kind ("link" or "deliver"). Returns True to allow, False to refuse. Refusal during link raises a typed error to the user; refusal during deliver excludes that destination from the route set (and falls back to originating if the route set becomes empty).
| Built-in policy | Behavior |
|---|---|
AllowAllLinks() |
Default. Any pair allowed; preserves today's single-tier behavior. |
SameConfidentialityTierOnly() |
Only allows pairs whose confidentiality_tier matches (including both None). Most common multi-tier setup. |
ExplicitAllowList(allowed_pairs={("public", "corp"), ...}) |
Allows only the listed (source, target) pairs. Useful for one-directional escalation flows. |
DenyAllLinks() |
Refuses every link attempt and excludes every non-originating destination — channels share an agent target on the host but never share sessions. Equivalent to running each channel on its own host minus the deployment overhead. |
Confidentiality tiers are opaque labels — the host does not interpret them; the policy decides what they mean. Setting confidentiality_tier=None on every channel preserves single-tier behavior. Two separate hosts is always a valid alternative to using LinkPolicy; the policy exists for cases where shared deployment, shared middleware, or a shared target object are preferred over running multiple hosts.
Multi-user conversations (Telegram groups, Teams group chats and channels)
Telegram and Activity Protocol (Bot Service) both surface multi-user conversations alongside 1:1 chats — Telegram has private chats, groups, supergroups, forum topics inside supergroups, and broadcast channels; Activity Protocol has conversationType of personal, groupChat, and channel (a Teams team channel, with optional threaded replyToId). The hosting contract treats these uniformly, but channel implementations and host configuration both need to make a few explicit choices:
Identity vs. conversation are two axes, not one. ChannelIdentity.native_id is always the user (from.id / from.aadObjectId); ChannelRequest.conversation_id is the chat / channel / thread. In 1:1 chats they collapse onto the same value (Telegram chat.id == from.id); in groups they don't and must not be conflated. The default IdentityResolver keys on (channel, native_id), so a single user automatically gets one isolation_key whether they message in a group or in DM — that may or may not be what you want (see scoping below).
Conversation scoping policy. A channel exposes a conversation_scope constructor option declaring how the host should derive the resolved isolation_key for multi-user surfaces. Three built-ins:
| Scope | isolation_key derivation in multi-user conversations |
When to pick it |
|---|---|---|
per_user |
The user's isolation_key from IdentityResolver(ChannelIdentity) only — group and DM share state. |
Personal-assistant agents where the bot follows the user across surfaces and their preferences/memory should travel with them. Risky if the agent emits user-specific data in a public group. |
per_user_per_conversation (default for multi-user) |
f"{user_isolation_key}:{conversation_id}" — same user gets a different isolation_key per group / channel / topic / DM. |
Default and safest. The agent's memory of a Teams team channel is separate from its memory of the same user's DM. |
per_conversation |
f"_conv:{channel}:{conversation_id}" — every member of the group shares one isolation_key and one AgentSession. The user identity is still attached to each turn (via ChannelRequest.identity) so the agent can address users by name, but session state is shared. |
"Bot lives in this channel" deployments: meeting-notes bot, shared scratchpad, support-triage queue. |
1:1 chats always derive isolation_key from the user identity alone — the per-user-per-conversation key would just include the user's own DM and add no isolation value.
Addressing rule. Group surfaces typically don't want the bot replying to every message. Channels expose an accept_in_group constructor option:
| Mode | Semantics | Default for |
|---|---|---|
mention_only |
Accept only messages that explicitly mention the bot (@bot for Telegram, <at>botname</at> mention entity for Teams). |
Telegram groups, Teams groupChat, Teams team channels |
command_only |
Accept only registered ChannelCommand invocations (e.g. /ask …). |
— |
mention_or_command |
Either of the above. | — |
all |
Accept every inbound message. | 1:1 chats; opt-in for groups when the agent really is the only conversational participant |
Messages that don't satisfy the rule are ignored at the channel layer — no ChannelRequest is produced and the agent is never invoked. This is purely an inbound filter; outbound delivery (push / response routing) is unaffected.
Reply / originating routing. The originating ResponseTarget always replies in the same conversation the request came from — including the same Teams team-channel thread (replyToId) or Telegram forum topic (message_thread_id). Channels carry the conversation-locator details on ChannelRequest.conversation_id (and additional fields on ChannelRequest.attributes when needed, e.g. thread_id); the channel's reply path reads them back. Channels that cannot reply in-thread (rare) fall back to a fresh top-level reply in the same conversation.
ChannelPush in groups. When a non-originating ResponseTarget lands on a multi-user surface, the push must address a (user, conversation) pair: the host calls ChannelPush.push(identity, payload) where identity.attributes includes the recorded conversation_id (and thread/topic id when applicable) of the most recent observation under that scope. For per_conversation scope, every member's ChannelIdentity resolves to the same isolation_key, so the host instead picks the most recently observed conversation_id for that key and posts a single message to the conversation rather than fanning out to each user.
Linker ceremonies in groups. OAuth and one-time-code link flows MUST NOT post the challenge URL or code into a group conversation visible to other users. Channels that support groups MUST detect group context (via ChannelIdentity.attributes) and, when require_link=True triggers a LinkChallenge, redirect the rendered challenge to the user's DM (Telegram: bot DM with the user; Teams: personal scope conversation with the same user). If a DM cannot be opened (Telegram user has not started the bot, Teams personal scope not installed), the channel returns a short prompt asking the user to DM the bot and retry. Verified-claim auto-link is unaffected — when a Teams groupChat request carries an AAD-verified from.aadObjectId that already matches an existing claim in the link store, the merge happens silently with no group-visible artifact.
Confidentiality tier interaction. A Teams team channel post is visible to every member of the team; a 1:1 DM is not. Operators who care about the distinction MUST configure separate Channel instances (e.g. ActivityChannel(scopes=["personal"], confidentiality_tier="user") + ActivityChannel(scopes=["channel", "groupChat"], confidentiality_tier="team")) and apply a LinkPolicy so cross-tier ResponseTarget deliveries and identity links are filtered. The hosting layer does not infer tier from conversationType; it is an explicit deployment choice.
Telegram broadcast Channel (the Telegram product) and forum topics.
- Broadcast Channels — bots that are members of a Telegram broadcast Channel can post but generally do not receive user replies; treat as
ChannelPush-only and configure withaccept_in_group="command_only"so admin-issued commands (/announce …) are the only inbound trigger. Out of scope for v1; v1 ships group/supergroup support and leaves broadcast Channels for fast follow. - Forum topics — supergroups with topics surface
message_thread_id. TheTelegramChannelpopulatesChannelRequest.conversation_idasf"{chat_id}:{message_thread_id}"soper_user_per_conversationandper_conversationscopes naturally separate topics from each other and from the group's general thread.
Activity Protocol specifics for ActivityChannel.
conversationTypemapping:personal→ 1:1 (accept_in_group="all"rule applied),groupChatandchannel→ multi-user (defaultmention_only).- Teams team channels carry both a channel id and an optional
replyToId. The channel populatesconversation_idasf"{conversation.id}:{replyToId}"when replying in-thread is desired (per_user_per_conversationscope makes thread-isolated sessions easy); deployments that prefer a single session per Teams channel can setconversation_scope="per_conversation"and the channel will key onconversation.idalone. tenantIdis recorded onChannelIdentity.attributesso multi-tenant deployments can implement anIdentityResolverthat scopesisolation_keyby tenant (or refuses unknown tenants).- Adaptive-card submit (
Invokeactivities) flows are addressed in fast-follow alongside theActivityChannelpackage; v1 of the host contract supports them viaChannelRequest.forwarded_props, so no host-level change is needed.
ResponseTarget — directs where the host delivers the agent response. Independent of session_mode.
| Variant | Constructor | Behavior |
|---|---|---|
| Originating | ResponseTarget.originating (default) |
Synchronous response on the originating channel. |
| Active | ResponseTarget.active |
Delivered to the channel most recently observed for the resolved isolation_key. |
| Specific channel (link-store recipient) | ResponseTarget.channel("activity") |
Delivered via the named channel's ChannelPush to whichever channel-native identity is recorded for the resolved isolation_key in the link store. |
| Explicit identities | ResponseTarget.identities([ChannelIdentity("telegram", native_id="<chat_id>"), ...]) |
Delivered via each named channel's ChannelPush to the caller-supplied channel-native identity — bypasses the link store entirely. Used when the originating caller already knows the recipient's channel-native id (e.g. a server-side Responses caller relaying for a known user). The host still consults LinkPolicy for each delivery. Convenience alias: ResponseTarget.identity(ChannelIdentity(...)) for the single-identity case. |
| Multiple channels | ResponseTarget.channels(["telegram", "activity"]) |
Delivered to each named channel (link-store recipient per channel). |
| All linked | ResponseTarget.all_linked |
Delivered to every channel where the resolved isolation_key is known. |
| None | ResponseTarget.none |
Background-only — caller must poll the ContinuationToken. Forces background=True. |
ResponseTarget constructors that take at least one channel id (.channel(...), .channels([...]), .identities([...])) accept an echo_input: bool = False kwarg. When true, the host pushes the originating user's input to each non-originating destination as a HostedRunResult[AgentResponse] whose underlying messages[*].role == "user" before the agent reply (whose messages[*].role == "assistant"). Used when the developer wants downstream channels to mirror what the user said so their UI stays coherent (e.g. a workflow originating on Telegram that pushes to Teams as well — the Teams transcript shows both turns). The echo and the response are bundled into the same scheduled push task per destination (the runner-managed unit of work — see Intended targets + durable delivery); the echo is dispatched first, and an echo-push failure is logged and swallowed inside the task so a channel that drops echoes still receives the agent reply. Both pushes go through the same ChannelPush.push(identity, payload) entry point — channels distinguish the echo phase from the response phase by inspecting payload.result.messages[*].role, or (for channels that wire a response_hook) by branching on ChannelResponseContext.is_echo directly. Channels that cannot impersonate the user on their wire (most chat bots can only send as the bot) typically render echoes as a quoted / prefixed block, drop them, or rewrite them via their response_hook.
When response_target is anything other than originating, the originating channel's protocol response is the ContinuationToken (e.g. an Invocations 202 with the token in the response body and/or a polling URL header), and the actual agent response is delivered out-of-band via the destination channel(s)' ChannelPush. If the destination channel doesn't implement ChannelPush, the host falls back per the configured policy (default: deliver to originating; surfaces a warning in telemetry). The configured LinkPolicy is consulted for every destination — destinations that fail the policy (e.g. a corp-tier channel addressed from a public-tier originating request) are dropped, and if every destination is dropped the host falls back to originating.
ChannelPush (Protocol) — optional capability for channels that can deliver outbound messages without a prior request.
| Method | Type | Description |
|---|---|---|
push(identity: ChannelIdentity, payload: HostedRunResult) |
async | Proactively delivers a completed run result to the given channel-native identity (Telegram proactive message, Activity Protocol proactive message via Bot Service continueConversation, webhook callback, SSE broadcast). Channels implement this in addition to Channel; channels that cannot push omit it. |
ContinuationToken — first-class artifact for asynchronous / background runs.
| Field | Type | Description |
|---|---|---|
token |
str |
Opaque, URL-safe continuation token. The only field channels expose to callers; all other fields are implementation detail of the host's HostStateStore. Stable for the lifetime of the run record (until expiry / eviction). |
status |
Literal["queued", "running", "completed", "failed"] |
Current status. |
isolation_key |
str? |
The resolved isolation key the run is associated with. |
created_at |
datetime |
Submission time. |
completed_at |
datetime? |
Set when status is completed or failed. |
result |
HostedRunResult? |
Populated on completed. |
error |
str? |
Populated on failed. |
response_target |
ResponseTarget |
The configured delivery target (recorded for diagnostics). |
The host stores ContinuationTokens through a HostStateStore (see Host state storage). The v1 default is FileHostStateStore — one JSON file per token under a configurable directory (default ./.af-hosting/continuations/), written atomically (.tmp + os.replace) so a host crash mid-write doesn't corrupt the record. This means background runs survive host restarts: a caller that polls /responses/{continuation_token} after the process recycles still gets a valid status (and the result if the run had completed before the crash). Completed/failed entries are evicted by a configurable TTL (default 24h). InMemoryHostStateStore is available for tests / ephemeral hosts. Built-in channels expose poll routes that surface the token in their native shape (/responses/{continuation_token} returns a Responses-shaped object; /invocations/{continuation_token} returns the Invocations status envelope).
Host state storage
HostStateStore is the single persistence seam for host-execution metadata that needs to outlive a single request: continuation tokens, identity-link grants, and last-seen (isolation_key, channel) records. It is deliberately separate from ContextProvider (per-conversation context) and CheckpointStorage (workflow checkpoints) because the data shapes are structurally different — but a deployment MAY back all three with the same physical store.
| Method | Purpose |
|---|---|
put_continuation(token: ContinuationToken) / get_continuation(token: str) / delete_continuation(token: str) |
Background-run records. |
put_link_grant(grant: LinkGrant) / get_link_grant(code: str) / consume_link_grant(code: str) |
Pending identity-link grants (Entra OAuth state, one-time codes). |
record_last_seen(isolation_key: str, channel: str, identity: ChannelIdentity, ts: datetime) / get_last_seen(isolation_key: str) |
Backs ResponseTarget.active. |
V1 ships two implementations:
FileHostStateStore(directory: Path = "./.af-hosting/")— default; one JSON file per record undercontinuations/,link_grants/, plus alast_seen.jsonkeyed by isolation key. Atomic writes; per-namespace TTL cleanup (continuations 24h, link grants 15min, last-seen 30d by default). Suitable for single-node hosts and dev; works in hosted-agent environments where the working directory is persisted and isolated per agent.InMemoryHostStateStore()— testing / ephemeral; same protocol, no persistence.
Pluggable v1-fast-follow implementations (Cosmos, SQL, Redis) plug into the same protocol — see req #24.
In the Python core package, the host-level state_dir shorthand reserves a
links component for this identity-link store. Passing a single path derives
state_dir/links/; the HostStatePaths mapping form accepts links=... for
placing link-store data on a separate volume. The core host offers that path to
identity linkers that implement SupportsLinkStorePath; linkers that own a
provider-specific store can ignore it and be configured directly.
ChannelCommand / ChannelCommandContext / CommandHandler — cross-channel native command model (per PR #5393).
| Type | Fields | Description |
|---|---|---|
ChannelCommand |
name, description, handle, expose_in_ui=True, metadata={} |
Transport-neutral command descriptor. |
ChannelCommandContext |
session, state, raw_event, reply(...), run(request) |
Runtime context for command handlers. |
CommandHandler |
Callable[[ChannelCommandContext], Awaitable[None] | None] |
Command implementation; may reply locally, mutate state, or invoke the agent. |
HostedRunResult / HostedStreamResult — outbound results from the host.
| Type | Fields | Description |
|---|---|---|
HostedRunResult[TResult] |
result: TResult, session: AgentSession | None |
One-shot outcome. result carries the target's full-fidelity output unchanged: HostedRunResult[AgentResponse] for agent targets (channels read result.messages, result.text, result.value, result.response_id, result.usage_details, … directly off the underlying response), HostedRunResult[WorkflowRunResult] for workflow targets (channels iterate result.get_outputs() and inspect result.get_final_state()). The host never pre-shapes, flattens, or filters — multi-modality and structured outputs survive end-to-end and each channel (through its response_hook and its native serializer) decides what subset its wire renders. The echo-input phase synthesises an HostedRunResult[AgentResponse] wrapping the originating user turn so the same delivery machinery applies. session carries the resolved per-isolation_key AgentSession (None for workflows, which do not own session state in the agent sense). Treat instances as immutable — the host clones per-destination via result.replace(result=...) before invoking each channel's response_hook; replace() is shallow, so channels that need to mutate result itself are responsible for their own deep copy. |
HostedStreamResult |
updates: ResponseStream[...], raw_events: AsyncIterable[Any] | None, session: AgentSession? |
Streaming outcome. updates is the normalized stream of AgentRunResponseUpdate (lossless for messages, function calls, usage) and is the happy path for Responses, Invocations, Telegram, and most channels. raw_events is an optional passthrough seam onto the underlying agent event stream (before update normalization) for channels whose protocol carries domain events the framework does not model — e.g. AG-UI's StateSnapshotEvent / StateDeltaEvent / ToolCallStartEvent. Channels that consume raw_events bear responsibility for the full event translation; the request still flows through context.stream(...) so session resolution, identity, push, and policy continue to apply. None when the host has no raw upstream (e.g. a workflow-only target produced from cached events). |
The host does not emit protocol events directly — channels translate HostedRunResult/HostedStreamResult into Responses events, Invocations SSE, webhook callbacks, or platform messages.
ChannelResponseHook / ChannelResponseContext — dev-supplied post-processing seam applied per destination before push.
| Type | Shape | Description |
|---|---|---|
ChannelResponseHook |
Callable[[HostedRunResult[Any], *, context: ChannelResponseContext], HostedRunResult[Any] | Awaitable[HostedRunResult[Any]]] |
Stored as a response_hook attribute on a channel instance — duck-typed, not part of the Channel Protocol. Receives a per-destination clone of the HostedRunResult and returns a (possibly rewritten) replacement. Hooks rebind result via HostedRunResult.replace(result=...) rather than mutating it in place. Common uses: flatten multi-modal output to text for a text-only wire, filter out tool-call contents, project a workflow WorkflowRunResult into a channel-friendly AgentResponse for text-only channels, attach citation entities, decide an Adaptive Card vs plain-text presentation. The hook signature stays Any-typed in the envelope's TResult so a single channel can serve both agent (HostedRunResult[AgentResponse]) and workflow (HostedRunResult[WorkflowRunResult]) payloads; channels narrow at hook entry if they want static checking. |
ChannelResponseContext |
request: ChannelRequest, channel_name: str, destination_identity: ChannelIdentity, originating: bool, is_echo: bool |
Per-destination context passed to a hook. originating=False for push deliveries (current scope of the host's _deliver_response); is_echo=True when this invocation is for the ResponseTarget.echo_input user-message phase rather than the agent reply phase. |
apply_response_hook(hook, result, *, context) |
helper | Standardised invocation convention so channels (and the host's delivery layer) all call hooks the same way. |
The host runs each destination's hook on a cloned HostedRunResult, so a hook that rebinds result cannot leak into the payload another destination observes. The clone is shallow — channels that need to mutate result itself (rather than rebind it via replace()) are responsible for their own deep copy.
Built-in channel constructors
class ResponsesChannel(Channel):
def __init__(
self,
*,
path: str = "/responses",
run_hook: ChannelRunHook | None = None,
expose_conversations: bool = True,
transports: Sequence[Literal["http", "websocket"]] = ("http",),
websocket_path: str = "/ws",
options: object | None = None,
) -> None: ...
class InvocationsChannel(Channel):
def __init__(
self,
*,
path: str = "/invocations",
run_hook: ChannelRunHook | None = None,
openapi_spec: dict[str, Any] | None = None,
) -> None: ...
class TelegramChannel(Channel):
def __init__(
self,
*,
bot_token: str,
transport: Literal["webhook", "polling"] = "webhook",
path: str = "/telegram",
run_hook: ChannelRunHook | None = None,
commands: Sequence[ChannelCommand] = (),
register_native_commands: bool = True,
require_link: bool = False,
) -> None: ...
options on ResponsesChannel is intentionally loosely typed in this draft because the option-mapping boundary is still settling. If it becomes a formal type later, it should be Agent Framework-owned, not imported from agentserver.
Conversation history for the Responses channel
The Responses channel does not introduce its own history seam. Conversation history for every channel — Responses, Invocations, Telegram, Activity Protocol — flows through the agent's standard core HistoryProvider (agent_framework._sessions.HistoryProvider). The Responses channel is a caller-supplied session channel (see Channel session-carriage models): it parses previous_response_id (and/or conversation_id) off the inbound request and projects it into ChannelSession.key. The host then resolves an AgentSession for that key and the agent's HistoryProvider does the load / append exactly as it would for any other session.
POST /responses { "previous_response_id": "resp_018f…", "input": [...] }
-> ResponsesChannel parses previous_response_id
-> ChannelRequest.session = ChannelSession(key="resp_018f…")
-> host resolves AgentSession(id="resp_018f…")
-> agent.HistoryProvider.load_messages(session=…) # if load_messages=True
-> agent.run(input, session=…)
-> agent.HistoryProvider.save_messages(session=…, new_messages)
-> ResponsesChannel serializes the result with response_id="resp_018f…+1"
This means any AF HistoryProvider backs Responses out of the box — FileHistoryProvider, an in-memory provider, a future CosmosHistoryProvider, etc. The wire previous_response_id is just a session id with channel-defined formatting; nothing in the provider has to know "this is a Responses session".
The Responses store parameter
The OpenAI Responses API exposes a store boolean on every request. Its meaning in the official SDK is "service-side: persist this response so a later call can reference it via previous_response_id." In the hosting world this gets more interesting because there are three independent places a turn can end up persisted:
- Service-side — the upstream provider's response store (e.g. OpenAI's hosted response store, accessible by
previous_response_idagainst that provider directly). Controlled by thestoreflag on the agent's underlyingChatClientat construction time. - Hosted-agent storage — the
HistoryProvider(s) attached to the agent (FileHistoryProvider,FoundryHostedAgentHistoryProvider, in-memory, dual-write, …). Controlled by the host'ssession_modedirective, whichrun_hookcan rewrite per request. - Caller-side — the API caller keeps the
response_idreturned by the host and chains future calls withprevious_response_id. Always available; out of host scope.
These axes are independent. The same wire store value can land in any combination of them — or none — depending on (a) how the developer assembled the agent (HistoryProvider attached or not? ChatClient configured with its own store=True or not?) and (b) what the channel's run_hook does with the value. The point of the matrix below is that store does not have a single canonical meaning at the hosted-agent layer — the developer of the hosted agent decides what it means.
| Caller sends | Service-side (underlying ChatClient's own store) |
Hosted-agent storage (agent's HistoryProvider) |
Caller-side (caller chains previous_response_id) |
|---|---|---|---|
store=true (or omitted; OpenAI default is true) |
Writes iff the ChatClient was constructed to honor store=true against the upstream service. The host forwards the wire value into the chat client's options but does not look at it itself. |
Default: loads and writes via the configured HistoryProvider (session_mode="auto"). Developer overrides (via run_hook): session_mode="disabled" to suppress (compliance hold, ephemeral one-shots); session_mode="required" to fail closed if no session can be resolved instead of auto-issuing. |
Always available — the host returns a chained response_id the caller may keep and re-send as previous_response_id. |
store=false |
Typically suppresses the service-side write — but the exact behavior depends on the ChatClient (some providers ignore the per-request flag, some honor it, some require a different opt-out). The host does not interpret it on the chat client's behalf. |
Default: still loads and writes via the configured HistoryProvider — store=false is not auto-translated into a session-disable. The HistoryProvider is configured on the agent for app-level reasons (audit, replay, multi-channel continuity) the API caller has no business unilaterally overriding. Developer overrides (via run_hook): session_mode="disabled" to honor caller intent (the path most apps that expose store=false as a real "stateless" guarantee will take); session_mode="required" (Scenario 3) to ignore caller intent and force host-managed sessions; conditional rules (e.g. honor store=false only from internal callers). |
Always available — and the default fallback when both server-side surfaces are suppressed. |
The same store=false request can therefore end up persisted in:
- service-side only (chat client honors the flag → no service-side write;
HistoryProvidernot attached → no hosted-agent write; caller keepsresponse_id), - hosted-agent storage only (chat client honors the flag → no service-side write;
HistoryProviderattached andrun_hookdoes not override → host writes anyway), - both (chat client ignores the flag → service-side write happens;
HistoryProviderattached and not overridden → hosted-agent write also happens), - neither (chat client honors the flag and
run_hooktranslates it intosession_mode="disabled"→ only the caller's local copy exists).
Two design properties fall out of this:
storeis forwarded, not auto-mapped to host policy. The caller'sstorevalue is forwarded into the chat client's options (where the upstream provider's ownstoresemantics apply), but it is not translated into asession_modedirective against the agent'sHistoryProviderby default. Collapsing the two — for example to makestore=falsea real end-to-end "stateless" guarantee — is an explicit developer choice expressed inrun_hook.- Documenting
storesemantics is a per-deployment responsibility. Because the resolved persistence depends on three independent developer decisions, the meaning ofstore=true/store=falseagainst any given hosted agent is something the deployment must document for its callers — there is no framework-level guarantee beyond "the wire value is forwarded to the chat client, and the host'sHistoryProviderruns by default unlessrun_hooksays otherwise." - Richer storage vocabulary via
extra_body. A single boolean is often too coarse to express what a deployment actually wants to offer. The OpenAI Responses request envelope supports anextra_bodymapping (the official Python SDK exposes it on every call as a passthrough into the request JSON); theResponsesChannelparses unknown body keys ontoChannelRequest.attributes, sorun_hookcan read deployment-specific knobs from there and translate them intosession_mode, the chat client'sstoreflag, or anything else. Examples a deployment might expose:extra_body={"af_store": "audit_only"}to write to theHistoryProviderbut suppress the service-side mirror;{"af_store": "ephemeral"}to skip both server-side surfaces;{"af_store": "replay_safe"}to forcesession_mode="required"and reject calls without a resolvable session. The framework does not standardize these names — they are part of the deployment's documented contract with its callers, on top of the standardstoreflag.
FoundryHostedAgentHistoryProvider — Foundry-backed history
For users who want the conversation persisted in the same Foundry response store that azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider writes to (so e.g. Foundry Workbench can replay the conversation, or other Foundry tools can introspect it), a new provider is added — proposed name FoundryHostedAgentHistoryProvider — implementing the standard HistoryProvider Protocol and built on top of the Foundry response-store SDK that ships in azure.ai.agentserver (so the wire contract, auth, and isolation headers stay aligned with the SDK without re-implementation). Shipped in agent-framework-foundry-hosting, attached the same way any other history provider is attached to an agent:
agent = Agent(
client=client,
history_provider=FoundryHostedAgentHistoryProvider(
endpoint=os.environ["FOUNDRY_ENDPOINT"],
load_messages=True,
),
)
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
The provider implements the standard HistoryProvider interface — there is no Responses-specific Protocol in between. It is also valid for any other channel (Telegram, Invocations, …) — Foundry storage simply becomes the chosen backend.
Foundry's storage backend keys writes off two platform-injected request headers (x-agent-user-isolation-key, x-agent-chat-isolation-key) rather than the request body. The Responses and Invocations channels parse both headers off the inbound request and forward them as an opaque mapping on ChannelRequest.attributes["isolation"] ({"user_key", "chat_key"}); the host's per-request bind_request_context then passes that value to FoundryHostedAgentHistoryProvider.bind_request_context(isolation=...), which the provider applies to its storage calls. Channels never import IsolationContext; the provider accepts both an IsolationContext instance and a plain mapping. When the headers are absent (local dev outside the Hosted Agents runtime) the attribute is omitted and storage falls back to non-isolated reads/writes, so the same code path works in both environments.
Multi-provider composition
The existing AF convention applies: an agent may compose multiple HistoryProviders, but only one carries load_messages=True. Common patterns:
- Single store.
FileHistoryProvider(load_messages=True)— local dev. OrFoundryHostedAgentHistoryProvider(load_messages=True)— Foundry-backed prod. - Audit dual-write.
FoundryHostedAgentHistoryProvider(load_messages=True)+CosmosHistoryProvider(load_messages=False)— Foundry is the source of truth used to reconstruct context for the LLM; Cosmos receives a write-only audit copy. - Mirror to Foundry for Workbench replay only. Conversely, an in-house store can hold
load_messages=TruewhileFoundryHostedAgentHistoryProvider(load_messages=False)mirrors writes into Foundry purely so the conversation shows up in Foundry tooling.
The choice of where to store, and whether to dual-write, is fully the developer's. The channel does not need to know which backing store(s) the agent is using.
Channel-owned per-thread state
Some channel protocols carry non-message durable state attached to the conversation — most notably AG-UI's per-thread state object, mutated mid-stream via StateSnapshotEvent / StateDeltaEvent (JSON-Patch-shaped) and read by the front-end on the next turn. This is not message history, so it does not belong on HistoryProvider; but it has the same lifetime, isolation, and "opaque to the host" properties as messages, so the framework already has the right primitive: ContextProvider.
HistoryProvider is only one concrete ContextProvider (the one that uses the per-source state: dict[str, Any] slot to hold messages). Channels with non-message per-thread state SHOULD ship their own ContextProvider subclass and write into the same per-source state slot.
Sketch (for AG-UI; the same pattern applies to any event-rich front-end):
from agent_framework import ContextProvider, ContextProviderState
class AgUiStateProvider(ContextProvider):
"""Per-thread non-message state for AG-UI front-ends.
Persists the AG-UI ``state`` object scoped by ``source_id`` (the
AgentSession id). Reads from ``ChannelRequest.client_state`` before
the run, exposes the current value to the agent via Context, and lets
the channel diff it after the run to emit StateSnapshotEvent /
StateDeltaEvent on the wire.
"""
state_key = "ag_ui_state" # slot in the per-source state dict
async def before_run(self, context, *, source_id, **kw):
slot = context.state.setdefault(source_id, {})
# If the request supplied a fresh client_state, seed/replace it.
if (incoming := context.request.client_state) is not None:
slot[self.state_key] = dict(incoming)
# Expose the live value to the agent (e.g. into context.metadata).
async def after_run(self, context, *, source_id, **kw):
# The current value lives in context.state[source_id][self.state_key];
# the channel reads it and emits StateSnapshotEvent / StateDeltaEvent.
...
Composition rules are unchanged: one HistoryProvider carries load_messages=True, additional ContextProviders (including AgUiStateProvider) attach alongside. Backing storage is whatever the user wires — in-memory for dev, the same physical store as messages for prod. No new storage protocol is introduced for channel state; it shares the same per-source state slot that HistoryProvider uses.
Storage taxonomy
To make the picture explicit: there are exactly three distinct storage seams in the hosting design, each with a clear scope. The first two are usually backed by the same physical store the user wires; they stay distinct as protocols because the data shapes differ.
| Seam | Scope | Examples |
|---|---|---|
ContextProvider (per-conversation) |
Per-source_id data the agent needs at run time. Messages (via HistoryProvider), AG-UI per-thread state (via AgUiStateProvider), or any future per-conversation extension. The only public per-conversation seam. |
FileHistoryProvider, FoundryHostedAgentHistoryProvider, AgUiStateProvider |
| Host-level pluggable store (per-host) | ContinuationTokens for background runs, identity-link grants, last-seen (isolation_key, channel) records. File-based by default in v1 (FileHostStateStore, atomic JSON writes under ./.af-hosting/); InMemoryHostStateStore for tests; pluggable for Cosmos / SQL / Redis adapters in v1 fast follow (req #24). MAY be backed by the same physical store as ContextProvider, but the protocol is distinct because the data is host-execution metadata, not per-conversation context. |
FileHostStateStore (v1 default), InMemoryHostStateStore, future Cosmos / SQL / Redis adapters |
CheckpointStorage (workflow runtime) |
Workflow executor frames so a workflow can resume after process restart. Structurally distinct from both seams above (the data is workflow-runtime state, not session/identity state). MAY share a physical backend, but the protocol stays separate. | FileCheckpointStorage, future CosmosCheckpointStorage |
Concretely, this means an app deploying onto e.g. Foundry storage can run all three against the same Foundry backend and still have three orthogonal protocol surfaces — one per concern — instead of one universal store everything accidentally collides in.
Channels surface per-request transport state (response ids, isolation keys, future signals) on ChannelRequest.attributes; the host's bind_request_context forwards those attributes as kwargs to each ContextProvider.bind_request_context call so providers can apply them to their reads and writes. Providers SHOULD accept **_ to ignore unknown attributes for forward-compat. This keeps channel↔provider coupling to a documented attribute name (e.g. "isolation") instead of requiring providers to install ASGI middleware.
The ResponsesChannel exposes both an HTTP transport ({path}/v1/...) and an optional WebSocket transport ({path}{websocket_path}, default /responses/ws) controlled by transports. The WS transport carries the same Responses request/event model as the HTTP+SSE variant — clients open a single connection per conversation and send/receive Responses frames as JSON messages. Both transports go through the same run_hook, the same default mapping, and the same ChannelRequest shape; the channel codec is responsible for framing only. Auth is reused from the HTTP transport (Authorization header on the Upgrade request); subprotocol negotiation is open (see Open Questions).
Default invocation behavior by channel
Each built-in channel owns a default mapping from its protocol request model into a ChannelRequest. That mapping flows through the optional run_hook before the host resolves session behavior and invokes the target.
| Channel | Default mapping |
|---|---|
ResponsesChannel |
Forwards relevant caller settings (e.g. temperature, store) into ChannelRequest.options so the underlying chat client receives them; does not map store=false to session_mode="disabled" by default — see The Responses store parameter for the full matrix and the developer-override path. The same default mapping is used for both HTTP and WebSocket transports — WS frames are decoded into the same Responses request model before invocation. |
InvocationsChannel |
Maps the request body into input, options, and session behavior for the hosted target. |
TelegramChannel |
Maps incoming messages or commands into input, stream, and session defaults appropriate for the chat. |
ASGI server portability
The hosting architecture is coupled to ASGI/Starlette, not to Uvicorn specifically.
host.appis the canonical portability surface.host.serve(...)is only the default convenience path (lazy-importsuvicorn).- Because
host.appis a standard Starlette/ASGI app, it can run on Hypercorn, Daphne, Granian, or Gunicorn-with-Uvicorn-workers. - ASGI WebSocket scope/frames are first-class: any channel may contribute
WebSocketRoutes alongside HTTP routes, and the chosen ASGI server must support the WebSocket scope (Uvicorn, Hypercorn, Daphne, and Granian all do).
The packaging question for uvicorn (required dependency vs optional extra) is therefore a convenience choice, not an architectural constraint. See Open Questions.
Error Responses
| Status | Condition | Notes |
|---|---|---|
400 Bad Request |
Channel-specific protocol validation failure | Owned by the channel codec. |
401 Unauthorized / 403 Forbidden |
Channel-specific auth/signature validation failure | Owned by channel middleware (e.g. Telegram secret token, Invocations auth). |
404 Not Found |
Route not contributed by any channel | Standard Starlette behavior. |
409 Conflict |
Session-resolution conflict with session_mode="required" and no resolvable session |
Host-level. |
422 Unprocessable Entity |
run_hook raised a validation error |
Channel surfaces the hook's error per protocol conventions. |
Terminology
- Host (
AgentFrameworkHost): The Python object that owns one Starlette app, one hostable target (an agent or a workflow), and a sequence of channels. Provideshost.app(canonical ASGI surface) andhost.serve(...)(uvicorn convenience). NamedAgentFrameworkHostrather thanAgentHostbecause the target is not restricted to agents. - Hostable target: The executable object the host fronts — either a
SupportsAgentRun-compatible agent or aWorkflow. The host detects the kind and dispatches to the appropriate execution seam; channels remain unchanged. - Channel: A pluggable component that contributes routes, middleware, commands, and lifecycle hooks to a host. One channel = one external protocol surface (Responses, Invocations, Telegram, …). Used interchangeably with "head" in earlier discussions; Channel is the canonical name.
ChannelRequest: The host-neutral, normalized invocation envelope produced by a channel before the host calls the target's execution seam. Carriesinput,options,session,session_mode, and channel-specificattributes.ChannelSession: A small session hint with a stablekey, an optional protocol-visibleconversation_id, and an opaqueisolation_key. The host resolves it into anAgentSession; storage specifics are deferred.isolation_key: An opaque partition boundary aligned with hosted-agent terminology — may represent a user, tenant, chat, or other scope without baking direct identity semantics into the generic host.- Channel-native identity (
ChannelIdentity): The user/account identifier the channel observes from its own platform (Telegramfrom.id, Teamsfrom.aadObjectId, WhatsApp phone number, Slack user id). Always per-channel; never assumed to align across channels. Distinct from the conversation locator (ChannelRequest.conversation_id/ChannelSession.conversation_id) — in multi-user surfaces (Telegram groups, Teams group chats and channels) the two never coincide. See Multi-user conversations. IdentityResolver: Host-level callable that maps aChannelIdentityto anisolation_key. The default resolver auto-issues a fresh, stableisolation_keythe first time a(channel, native_id)pair is seen and persists it in the host's identity store, so every end user automatically gets a per-user partition on first contact through any channel — without app code. Linking (seeIdentityLinker) merges the second channel's auto-issued key onto the first channel'sisolation_key, so cross-channel continuity is a one-shot operation, not a per-channel mapping hook. Apps that already own an identity namespace (corporate user id, tenant-scoped account id) can supply a custom resolver that returns those values directly.IdentityLinker: Host-level component that runs a connect ceremony — typically OAuth, MFA, or a signed one-time code — to associate a newChannelIdentitywith an existingisolation_key. Contributes its own routes (e.g. OAuth callback) and lifecycle to the host. A built-inlink/connectChannelCommandis exposed automatically when one is configured. On successful ceremony completion, also stores any verified IdP claim recovered from the proof (e.g. Entra IDoid) so subsequent channels that supply the same claim can be auto-merged onto the sameisolation_keysilently. Combined withChannel(require_link=True), this enables an "authenticate before chatting" enforcement model where the first channel forces the OAuth ceremony and every other channel using the same IdP joins the same session without a second/link.LinkChallenge: The protocol-neutral artifact returned byIdentityLinker.begin(...)describing what the user must do to complete the ceremony — typically one of: a URL to visit (OAuth), a short code to enter on the other channel (one-time code), or an MFA prompt.ResponseTarget: Per-request directive onChannelRequestcontrolling where the response is delivered:originating(default),active, a specific channel, a list of channels,all_linked, ornone. Independent ofsession_mode.ChannelPush: Optional channel capability for proactive outbound delivery — Telegram proactive message, Activity Protocol proactive message via Azure Bot Service, webhook callback, SSE broadcast. Required to be the destination of a non-originatingResponseTarget.- Active channel: The channel most recently observed for a given
isolation_key. Tracked by the host on every successfully resolved request; consumed byResponseTarget.active. ContinuationToken: First-class artifact for background/asynchronous runs, returned immediately fromhost.run_in_background(request). Carries an opaque, URL-safetokenplusstatus,isolation_key,result/error, and the configuredresponse_target. Persisted viaHostStateStore(file-based by default in v1) so background runs survive host restarts. Host pushes the result to the response target when ready and serves it via channel poll routes.- Background run: A
ChannelRequestsubmitted viahost.run_in_background(request)(or any request withbackground=True). The originating call returns aContinuationTokenimmediately; the response is delivered later via the configuredResponseTargetand/or polled by token. HostStateStore: Single persistence seam for host-execution metadata — continuation tokens, identity-link grants, last-seen records. V1 defaultFileHostStateStore(atomic JSON writes under./.af-hosting/);InMemoryHostStateStorefor tests; pluggable for Cosmos / SQL / Redis (fast follow, req #24). Distinct fromContextProvider(per-conversation) andCheckpointStorage(workflow), but a deployment MAY back all three with the same physical store.session_mode: Per-request directive (auto|required|disabled) that controls whether the host resolves a session before invoking the target. Letsrun_hooks express explicit policy — e.g. translating Responsesstore=falseintosession_mode="disabled"to honor the caller's "don't store" intent at theHistoryProviderlayer (the channel does not do this automatically — see The Responses store parameter).confidentiality_tier(channel-level): Opaque label ("corp","public","internal", …) declared on aChanneland consumed by the host'sLinkPolicy. Two channels with different confidentiality tiers can share an agent target on one host while remaining session-isolated.LinkPolicy: Host-level decision over which channel pairs may share anisolation_key(link) and which channel pairs may beResponseTargetsource/destination for one another (deliver). Built-in variants: allow-all (default), same-tier-only, explicit allow-list, deny-all. See LinkPolicy and confidentiality_tier for the full contract and built-ins table.ChannelContribution: What a channel returns fromcontribute(...)— routes, middleware, commands, andon_startup/on_shutdownhooks. The host aggregates contributions into one Starlette app.ChannelCommand: A transport-neutral command descriptor (name,description,handle). Message channels project these into native command surfaces — Telegram bot commands, future Activity Protocol slash commands / adaptive cards, WhatsApp menus.ChannelRunHook: Per-request callable on built-in channels. Runs after the channel's defaultChannelRequestis produced, before session resolution. The escape hatch for forcing or forbidding session use, requiring extra options, adapting to targets likeA2AAgent, and reshaping a channel's free-form input into the typed inputs aWorkflowtarget expects.- Native command registration: The startup-time projection of
ChannelCommandmetadata into a platform's native command catalog (e.g. Telegramset_my_commands(...)). SupportsAgentRun: The existing framework agent execution seam (run(..., session=..., stream=...)) — the contract the host uses when the hostable target is an agent.Workflow: The framework workflow execution seam — the contract the host uses when the hostable target is a workflow. The host wraps the workflow's outputs into the sameHostedRunResult/HostedStreamResultshape so channels do not need to distinguish.
Runtime modes
The host runs in one of two operational shapes, declared (or auto-detected) via a single runtime_mode parameter. The parameter is advisory — it sets defaults for the seams below; the developer can override any individual choice.
AgentFrameworkHost(
target=my_agent,
channels=[...],
runtime_mode=None, # None → auto-detect; "long_running" | "ephemeral" to force
)
| Value | Shape | When to use |
|---|---|---|
"long_running" |
Always-on container / process. Owns its own scheduler. Survives across many requests. | Local dev, OpenClaw-style hosted deployments, classic web-app rollouts on AKS / App Service / Container Apps. |
"ephemeral" |
Scale-to-zero / per-request lifecycle. Process may terminate between requests; cold-start cost on each one. | Foundry Hosted Agent, Azure Functions consumption plan, AWS Lambda, and similar serverless runtimes. |
None (default) |
Auto-detect. The host inspects environment markers at construction; falls back to "long_running" when nothing is detected. |
The default. Recommended for portable code that works locally and ships to a serverless target. |
Auto-detection. When runtime_mode=None, the host checks for known deployment markers in this order and picks "ephemeral" on the first hit:
| Marker | Meaning |
|---|---|
FOUNDRY_HOSTING_ENVIRONMENT (env var) |
Running inside Foundry Hosted Agent. |
AZURE_FUNCTIONS_ENVIRONMENT (env var) |
Running inside the Azure Functions worker. |
AWS_LAMBDA_FUNCTION_NAME (env var) |
Running inside an AWS Lambda. |
If none of the markers match, the host defaults to "long_running" (a sensible local-dev / container default). Additional markers may be added without bumping the API; the list is documented and overridable via the runtime_mode parameter itself.
Defaults selected by mode. The mode drives the default selection for these seams. Each is independently overridable:
| Concern | "long_running" default |
"ephemeral" default |
|---|---|---|
HostStateStore |
InMemoryHostStateStore (process owns state) |
FileHostStateStore (atomic JSON under ./.af-hosting/; survives single-node restart) |
ContinuationToken persistence |
In-memory acceptable | Persistence required (file / Cosmos / Foundry) |
DurableTaskRunner |
InProcessTaskRunner (asyncio + bounded retry) |
Adapter expected (agent-framework-hosting-durabletask, Foundry, …); falls back to InProcessTaskRunner with a startup warning when none configured |
| Background runs (req #14) | Owned by the long-running worker via InProcessTaskRunner |
Hand off to the durable runner so the process can terminate between requests |
Channel polling (e.g. Telegram getUpdates) |
Natural fit — on_startup spawns the poller, on_shutdown cancels it |
Requires an external scheduled trigger or webhook transport; polling channels emit a startup warning when paired with "ephemeral" |
IdentityLinker short-lived grants |
In-memory TTL fine | Must persist via HostStateStore |
IdentityAllowlist lookup |
In-memory cache fine | Persisted source or external IdP claim resolution |
| Health checks + readiness probes | First-class | Less relevant — runtime manages liveness |
| Per-channel polling-worker isolation | Important — leaks compound over days/weeks (see channels_vs_openclaw.md) |
N/A — process recycles between requests |
| Process-recycle expectations | Days/weeks | Per-request |
| Memory/leak concerns | Important | Less relevant |
Detection failures. Auto-detection is best-effort. If a deployment uses a custom runtime not in the marker list, callers SHOULD set runtime_mode="ephemeral" (or "long_running") explicitly. The host logs the detected mode at startup so misdetection is visible in normal operation.
Why advisory and not enforced. Most knobs make sense in both modes (e.g. a developer running a "long-running" container may still want FileHostStateStore for state durability across deploys); enforcing strict defaults per mode would force every override to fight a config error. The selected defaults are a starting point.
Hero Code Samples
Common prerequisite: Every sample below calls
host.serve(...), which lazy-importsuvicorn. Installuvicorn(e.g.pip install uvicorn) — or the correspondingagent-framework-hosting[serve]extra if the package ships one (see Open Question #2) — alongside the per-sample dependencies listed in each scenario's Prerequisites block. Samples that usehost.appdirectly (handed to Hypercorn/Daphne/Granian/Gunicorn+uvicorn workers) do not requireuvicorn.
Scenario 1: Expose one agent on the Responses API
A developer has an agent and wants to expose it as the OpenAI-compatible Responses API on localhost:8000 with no manual server bootstrap.
Prerequisites: This sample assumes:
agent-framework-hostingandagent-framework-hosting-responsesare installed- An
OPENAI_API_KEYis available in the environment
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework.hosting import AgentFrameworkHost, ResponsesChannel
agent = Agent(
name="WeatherAgent",
instructions="You are a helpful weather agent.",
client=OpenAIChatClient(model="gpt-4.1-mini"),
)
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel()],
)
if __name__ == "__main__":
host.serve(host="localhost", port=8000)
This exposes the Responses route under /responses. No manual uvicorn import, no protocol handlers written by the user.
Scenario 2: Expose Responses + Invocations on one host with shared Starlette middleware
Same agent, both protocols, with CORS applied at the host level.
Prerequisites: This sample assumes:
agent-framework-hosting,-responses, and-invocationsare installed- A Foundry project with a
gpt-4.1model deployment
from azure.identity import AzureCliCredential
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.hosting import AgentFrameworkHost, InvocationsChannel, ResponsesChannel
agent = Agent(
name="TravelAgent",
instructions="Help users plan travel and keep answers concise.",
client=FoundryChatClient(
project_endpoint="https://my-project.services.ai.azure.com/api/projects/travel",
model="gpt-4.1",
credential=AzureCliCredential(),
),
)
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(), # -> /responses
InvocationsChannel(), # -> /invocations
],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["https://chat.contoso.com"],
allow_methods=["*"],
allow_headers=["*"],
),
],
)
# Hand the canonical ASGI app to any server, or use the convenience method.
app = host.app # for Hypercorn / Granian / Gunicorn+uvicorn workers
host.serve(host="localhost", port=8000)
Scenario 3: Per-request run hook on the Responses channel
The developer wants to enforce that every Responses call sets temperature, and to harden session handling so that session_mode="required" (fail if no session can be resolved) — explicitly ignoring caller store=false since the channel's default already keeps the agent's HistoryProvider active regardless of that wire flag (see The Responses store parameter). None of this is part of the official Responses spec, but all of it is valid app policy.
Prerequisites: This sample assumes:
- The Responses channel is wired into an
AgentFrameworkHost(see Scenario 1)
from dataclasses import replace
from agent_framework.hosting import (
AgentFrameworkHost,
ChannelRequest,
ResponsesChannel,
)
def responses_policy(request: ChannelRequest, **kwargs) -> ChannelRequest:
if request.options is None or request.options.temperature is None:
raise ValueError("This host requires temperature on every Responses call.")
# Harden session handling: even when the caller sends store=false, keep host-managed
# sessions and fail closed instead of auto-issuing. The HistoryProvider would already
# run under the default "auto" mode; "required" upgrades that to a hard error if no
# session can be resolved (e.g. missing previous_response_id and no resolver match).
return replace(request, session_mode="required")
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=responses_policy)],
)
host.serve(host="localhost", port=8000)
The hook runs after the channel produces its default ChannelRequest and before the host resolves session behavior and calls SupportsAgentRun.run(...). The same shape works to adapt to targets like A2AAgent — strip or remap channel-derived options that the target does not consume.
Scenario 4: Telegram channel with native command catalog (polling)
A developer wants to expose the same agent as a Telegram bot, with first-class native commands (/start, /new, /sessions, …) registered into Telegram's command menu at startup. Modeled after PR #5393.
Prerequisites: This sample assumes:
agent-framework-hosting-telegramis installedTELEGRAM_BOT_TOKENis set in the environment
import os
from agent_framework.hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
TelegramChannel,
)
async def handle_start(context: ChannelCommandContext) -> None:
await context.reply(
"Hi! Commands: /new, /sessions, /todo, /memories, /reminders, "
"/resume, /cancel, /reasoning, /tokens."
)
async def handle_noop(context: ChannelCommandContext) -> None:
await context.reply("Command received.")
TELEGRAM_COMMANDS = [
ChannelCommand("start", "Introduce the bot", handle_start),
ChannelCommand("new", "Start a new local session", handle_noop),
ChannelCommand("sessions", "List local sessions", handle_noop),
ChannelCommand("todo", "List todos for the active session", handle_noop),
ChannelCommand("memories", "List memory topics for the active session", handle_noop),
ChannelCommand("reminders", "List reminders for the active session", handle_noop),
ChannelCommand("resume", "Resume the latest pending or previous session", handle_noop),
ChannelCommand("cancel", "Cancel the active response", handle_noop),
ChannelCommand("reasoning", "Toggle the transient reasoning preview", handle_noop),
ChannelCommand("tokens", "Toggle token usage details", handle_noop),
]
telegram = TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
transport="polling",
commands=TELEGRAM_COMMANDS,
register_native_commands=True,
)
host = AgentFrameworkHost(target=agent, channels=[telegram])
host.serve(host="localhost", port=8000)
This mirrors the important shape from PR #5393: command metadata is declared once, the channel registers it into Telegram's native menu at startup (set_my_commands(...)), and runtime command dispatch stays channel-local.
Scenario 5: Telegram webhook mode on the same host as Responses + Invocations
Same agent, three channels, one Starlette app, one process.
Prerequisites: Same as Scenario 4, plus a public HTTPS URL for the webhook.
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(), # -> /responses
InvocationsChannel(), # -> /invocations
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
transport="webhook", # -> /telegram/webhook
commands=TELEGRAM_COMMANDS,
),
],
)
host.serve(host="0.0.0.0", port=8000)
Webhook transport contributes /telegram/webhook by default; the command catalog remains identical to the polling sample.
Scenario 6: Linking a new channel to an existing identity via OAuth
A developer wants every Telegram chat to be authenticated up front via OAuth (Microsoft Entra ID) before the agent will respond, and wants Teams chats from the same Entra ID user to be auto-linked to the existing session — no second /link ceremony, just sign in once on the first channel and the rest follow automatically. This delivers cross-channel chat continuity as a side-effect of identity linking; Scenario 7 covers the alternative pattern where a trusted server-side relay supplies identity directly without a link ceremony.
sequenceDiagram
autonumber
actor User
participant Tg as TelegramChannel
participant Host
participant Linker as IdentityLinker<br/>(EntraOAuth)
participant IdP as OAuth provider
participant Store as HostStateStore<br/>(identity_links)
participant Act as ActivityChannel
User->>Tg: /link
Tg->>Host: ChannelRequest(identity=tg:12345)
Host->>Linker: begin(identity=tg:12345)
Linker-->>Host: LinkChallenge(url, state)
Host->>Tg: response_hook → push challenge URL
Tg-->>User: "click here to sign in"
User->>IdP: sign in (browser)
IdP-->>User: redirect with code
User->>Linker: /callback?code=…&state=…
Linker->>IdP: exchange code → tokens + claims
IdP-->>Linker: claims (oid, email, …)
Linker->>Store: persist link(tg:12345 ↔ linked_claims)
Linker-->>User: "linked ✅"
Note over User: later, on Teams (same Entra OID)
User->>Act: hello
Act->>Host: ChannelRequest(identity=teams-aad-oid)
Host->>Store: lookup linked_claims by oid
Store-->>Host: existing isolation_key (matches tg:12345)
Host->>Host: same session as Telegram
Prerequisites: This sample assumes:
agent-framework-hosting,agent-framework-hosting-telegram, and the (future)agent-framework-hosting-activitychannel are installed- An OAuth provider is configured (Microsoft Entra ID in this example)
import os
from agent_framework.hosting import (
AgentFrameworkHost,
OAuthIdentityLinker,
TelegramChannel,
)
# The OAuth linker contributes its own /identity/oauth/microsoft/{start,callback}
# routes to the host. On successful completion, the host's built-in identity
# store atomically records BOTH the originating channel-native identity AND the
# verified IdP claim (Entra ID object id) so future channels that authenticate
# the same IdP account can auto-link without a second ceremony.
linker = OAuthIdentityLinker(
provider="microsoft",
client_id=os.environ["AAD_CLIENT_ID"],
client_secret=os.environ["AAD_CLIENT_SECRET"],
)
host = AgentFrameworkHost(
target=agent,
identity_linker=linker,
channels=[
# require_link=True gates the channel: any inbound message from an
# un-linked ChannelIdentity is short-circuited to a LinkChallenge reply
# instead of being dispatched to the agent.
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
transport="webhook",
require_link=True,
),
# ActivityChannel(app_id=..., require_link=True), # future — same flag
],
)
host.serve(host="0.0.0.0", port=8000)
The flow:
alicesends her first message on Telegram. TheTelegramChannelextractsChannelIdentity(channel="telegram", native_id="<chat_id>")and asks the linkeris_linked(...). It is not. Becauserequire_link=True, the channel does not invoke the agent; instead it askslinker.begin(channel_identity)for aLinkChallenge, renders the challenge URL into Telegram (clickable button), and returns.aliceclicks the button, signs in with Microsoft Entra ID, and the OAuth callback hits the linker's route.linker.complete(...)verifies the authorization code and records two things atomically in the identity store:(channel="telegram", native_id="<chat_id>") → isolation_key="hk_018f…a3"verified_claim("microsoft.oid", "<aad-object-id>") → isolation_key="hk_018f…a3"
alicereplies on Telegram. The channel sees the link is now present, resolves the existingisolation_key, and forwards the message to the agent normally. From here on, Telegram chats are routed without further ceremony.- The next day,
aliceopens Teams. TheActivityChannelextracts both the channel-native identity (activity,<aad-oid>) and the verified IdP claim from the inbound activity (Teams already authenticates with Entra ID via Bot Service, so the AAD object id is trusted). It asks the linkeris_linked(...). The(activity, <aad-oid>)pair is not in the store — but the verified claim("microsoft.oid", "<aad-object-id>")is. The linker auto-merges(activity, <aad-oid>) → isolation_key="hk_018f…a3"without any user-visible/linkceremony. - From the next turn on, both Telegram and Teams resolve to the same
isolation_keyand the sameAgentSession. The agent sees the conversation history from both channels as one continuous thread.
The two enabling pieces:
require_link: boolon the channel — whenTrue, the channel checks the linker before dispatching every inbound request. Un-linked identities are short-circuited to a renderedLinkChallengeinstead of an agent invocation. Default isFalse(the opportunistic flow below).- Verified IdP claims in the linker's identity store — when an OAuth ceremony completes, the linker records the verified identity claim (e.g.
(microsoft.oid, <oid>)) alongside the channel-native identity. Channels that can supply the same kind of verified claim from their own auth context (Teams via the AAD bearer on the activity, future M365 channels via the same bearer, …) get auto-linked silently on first contact when their claim matches an existing entry. This is what makes "sign in once on Telegram, Teams just works" possible without any per-channel link ceremony.
Variant — opportunistic linking (require_link=False). Leave the flag at its default and the channel will dispatch un-linked identities straight to the agent (the host's default resolver auto-issues a fresh isolation_key for them). The user can later run the link ChannelCommand manually to merge that auto-issued key onto an existing one. This is the lower-friction onboarding flow at the cost of allowing pre-link conversations to exist in their own isolated session until merged.
Variant — alternative ceremony. Swapping the linker for OneTimeCodeIdentityLinker(...) changes the ceremony to "complete /link on channel A, get a 6-digit code, run /link 482931 on channel B"; with require_link=True the channel just renders the code-entry instructions instead of an OAuth URL. Apps with their own corporate identity namespace can additionally pass a custom identity_resolver so the post-link isolation_key is the corporate user id instead of the host-issued opaque key. Channels themselves are unchanged across these variants — only the linker and (optionally) the resolver change.
Scenario 7: Trusted server-side caller relays a Responses request and pushes the answer back to the user's Telegram chat
A developer runs an internal application server that already knows its end users (e.g. via an SSO session) and wants to expose two surfaces against the same agent: the OpenAI-compatible Responses API (so the application backend can drive the agent programmatically on behalf of the signed-in user) and Telegram (so the same end user can also chat with the agent directly). When the application backend submits a Responses call, it should be possible to (a) link that call to the same isolation_key as the user's existing Telegram chats — so the agent sees one continuous conversation history — and optionally (b) have the agent's response pushed back to the user's Telegram chat instead of (or in addition to) being returned synchronously on the Responses HTTP call.
This works without an IdentityLinker because the application backend is a trusted relay: it already authenticated the user through its own SSO and knows both the user's app-internal id and (because the user has previously connected their Telegram account in the application's own settings page) the user's Telegram chat_id. The host just needs to be told.
sequenceDiagram
autonumber
actor Backend as Server-side backend
participant Resp as ResponsesChannel
participant Host
participant Hook as run_hook<br/>(responses_relay_hook)
participant Store as HostStateStore<br/>(continuations)
participant Target as Agent
participant Runner as DurableTaskRunner
participant Tg as TelegramChannel
Backend->>Resp: POST /v1/responses<br/>extra_body.hosting.push_to_telegram_chat_id=<chat_id>
Resp->>Host: ChannelRequest(...)
Host->>Hook: run_hook(request, context)
Hook->>Hook: rewrite to<br/>background=True,<br/>response_target=identities([tg:<chat_id>])
Host->>Store: write continuation(token, status=in_progress)
Host-->>Resp: ContinuationToken (token)
Resp-->>Backend: 200 with continuation token
Note over Host,Target: background task
Host->>Target: run (async)
Target-->>Host: AgentResponse
Host->>Store: continuation.complete(token, result)
Host->>Runner: schedule("hosting.push",<br/>payload for tg:<chat_id>)
Runner->>Host: _handle_push_task(payload)
Host->>Tg: response_hook → push
Tg-->>User: answer arrives in Telegram chat
Prerequisites: This sample assumes:
agent-framework-hosting,agent-framework-hosting-responses, andagent-framework-hosting-telegramare installed- The application backend can attach two extra fields to its Responses call: an
app_user_id(the user's stable id in the application's own namespace) and, optionally, apush_to_telegram_chat_id(the user's known Telegram chat id from the application's own database)
import os
from dataclasses import replace
from agent_framework.hosting import (
AgentFrameworkHost,
ChannelIdentity,
ChannelRequest,
IdentityResolver,
ResponseTarget,
ResponsesChannel,
TelegramChannel,
)
# A custom identity resolver that promotes the app's own user id to the
# isolation_key whenever a channel can supply one. The Telegram channel exposes
# the chat_id (pre-registered in the application's settings page → so the
# application maps chat_id → app_user_id and tells the host); the Responses
# channel exposes the app_user_id directly via extra_body (see run_hook below).
async def app_identity_resolver(identity: ChannelIdentity, **_) -> str | None:
# Both channels populate ChannelIdentity.attributes["app_user_id"] — see
# the run hooks below.
return identity.attributes.get("app_user_id")
# Telegram channel maps Telegram chat_id → app_user_id from the application's
# pre-registered chat-id table. Cached locally; in real apps this is whatever
# lookup matches the application's own user-account schema.
KNOWN_TELEGRAM_USERS: dict[str, str] = {
"<chat_id_of_alice>": "user_alice",
# ...
}
async def telegram_promote_app_user(request: ChannelRequest, **_) -> ChannelRequest:
chat_id = request.identity.native_id
app_user_id = KNOWN_TELEGRAM_USERS.get(chat_id)
if app_user_id is None:
return request # falls back to host's auto-issued isolation_key
return replace(
request,
identity=replace(
request.identity,
attributes={**request.identity.attributes, "app_user_id": app_user_id},
),
)
# The application backend POSTs to /responses with
#
# {
# "model": "...",
# "input": "...",
# "extra_body": {
# "hosting": {
# "app_user_id": "user_alice", # who this request is for
# "push_to_telegram_chat_id": "<chat_id>", # optional
# }
# }
# }
#
# The Responses channel surfaces extra_body["hosting"] on
# ChannelRequest.attributes["hosting"]; this run_hook reads it and rewrites
# both the identity (so the request resolves to the same isolation_key as the
# user's Telegram chats) and the response_target (so the answer is pushed to
# Telegram in addition to / instead of the synchronous Responses reply).
async def responses_relay_hook(request: ChannelRequest, **_) -> ChannelRequest:
hosting = request.attributes.get("hosting", {})
app_user_id = hosting.get("app_user_id")
push_chat_id = hosting.get("push_to_telegram_chat_id")
if app_user_id is None:
return request # plain Responses call, no relay → keep defaults
# Promote app_user_id onto the identity so the resolver returns it as
# isolation_key.
new_identity = replace(
request.identity,
attributes={**request.identity.attributes, "app_user_id": app_user_id},
)
# If the caller also supplied a Telegram chat id, push the answer there
# via ResponseTarget.identities (explicit recipient — bypasses the link
# store, which is empty for this user since no link ceremony ran). The
# Responses HTTP call returns a ContinuationToken so the application
# backend can correlate.
if push_chat_id:
return replace(
request,
identity=new_identity,
response_target=ResponseTarget.identities([
ChannelIdentity(channel="telegram", native_id=push_chat_id),
]),
background=True,
)
return replace(request, identity=new_identity)
host = AgentFrameworkHost(
target=agent,
identity_resolver=IdentityResolver(app_identity_resolver),
channels=[
ResponsesChannel(run_hook=responses_relay_hook),
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
transport="webhook",
run_hook=telegram_promote_app_user,
),
],
)
host.serve(host="0.0.0.0", port=8000)
The flow:
- Alice has previously connected her Telegram account on the application's settings page; the application stored
chat_id_of_alice → user_aliceinKNOWN_TELEGRAM_USERS(a real deployment uses a database). - Alice opens the application's web UI and types a question. The application backend (signed in as
user_alice) calls the Responses API mounted on this host withextra_body={"hosting": {"app_user_id": "user_alice"}}(and nopush_to_telegram_chat_id). Theresponses_relay_hookpromotesapp_user_idonto the identity, the resolver returnsisolation_key="user_alice", the agent runs, and the answer is returned synchronously over HTTP. The agent'sHistoryProviderappends both turns to the session keyed byuser_alice. - Later, Alice messages the same agent on Telegram from her registered chat. The Telegram channel's
run_hookpromotesapp_user_id="user_alice"onto the identity (because her chat_id is in the known-users table), the resolver returns the sameisolation_key="user_alice", the agent loads the same session — and sees the earlier turn from the web UI. One continuous conversation across two channels, no link ceremony required, noIdentityLinkerconfigured. - Now Alice walks away from her desk. The application backend wants to fire a long-running task on her behalf and have the answer reach her on Telegram. It calls the Responses API with
extra_body={"hosting": {"app_user_id": "user_alice", "push_to_telegram_chat_id": "<chat_id_of_alice>"}}. Theresponses_relay_hookrewrites the request tobackground=Trueandresponse_target=ResponseTarget.identities([ChannelIdentity("telegram", "<chat_id_of_alice>")]). The Responses HTTP call returns aContinuationTokenimmediately (so the application backend can correlate); when the agent completes, the host callsTelegramChannel.push(ChannelIdentity("telegram", "<chat_id_of_alice>"), result)and the answer arrives in Alice's Telegram chat.
The two enabling pieces:
extra_body["hosting"]as a developer-controlled relay envelope. The Responses channel surfaces an opaquehostingblock fromextra_bodyontoChannelRequest.attributes["hosting"]. The hosting core does not define what goes in there — the developer decides what their trusted backend may carry (hereapp_user_idandpush_to_telegram_chat_id) and reads it in theirrun_hook. This is the same pattern thestore=table calls out for richer per-call control.ResponseTarget.identities([...])for explicit caller-known recipients. This bypasses the link store and pushes to a channel-native identity the caller already knows. Use it when the originating caller is a trusted relay that authenticated the user through some other means (corporate SSO, an internal API key bound to a user) and just needs the host to dispatch.LinkPolicyis still consulted per delivery, so a corp-tier Responses call cannot smuggle a public-tier Telegram push if the policy disallows it.
Variant — same scenario with an IdentityLinker configured. If the host does have an IdentityLinker (Scenario 6), the application backend doesn't need to maintain its own chat_id → app_user_id table at all: when Alice runs /link once on Telegram, the linker records the channel-native identity against isolation_key="user_alice" (resolved from the Entra OAuth claim that matches the application's own SSO). After that, the run hook can simply use ResponseTarget.channel("telegram") (link-store recipient) instead of ResponseTarget.identities([...]). The explicit-identities variant remains useful when the application owns identity end-to-end and prefers not to delegate to a host-level linker.
Scenario 8: Background run with cross-channel response delivery
A developer wants the user to start a long-running task on Telegram and pick up the response on Teams (whichever channel the user happens to be on when the result is ready). The originating Telegram message returns a ContinuationToken immediately; when the agent completes, the host pushes the result to the user's currently active channel via ChannelPush. A poll route is also exposed for callers that prefer polling.
sequenceDiagram
autonumber
actor User
participant Tg as TelegramChannel
participant Host
participant Hook as run_hook
participant Store as HostStateStore<br/>(continuations · last_seen)
participant Target as Agent
participant Runner as DurableTaskRunner
participant Act as ActivityChannel
User->>Tg: long-running ask
Tg->>Host: ChannelRequest(identity=tg:12345)
Host->>Hook: run_hook
Hook->>Hook: background=True,<br/>response_target=active
Host->>Store: write continuation(in_progress)
Host-->>Tg: ContinuationToken
Tg-->>User: "working on it…"
Note over User: user opens Teams,<br/>last_seen updates to "activity"
User->>Act: hello on Teams
Act->>Host: ChannelRequest(identity=teams-aad-oid)
Host->>Store: record_last_seen(isolation_key, activity, now)
Note over Host,Target: background completes
Target-->>Host: AgentResponse
Host->>Store: get_last_seen(isolation_key) → activity
Host->>Runner: schedule("hosting.push",<br/>payload for activity)
Runner->>Host: _handle_push_task
Host->>Act: push
Act-->>User: answer arrives on Teams
Prerequisites: This sample assumes:
agent-framework-hosting,agent-framework-hosting-telegram, and the (future)agent-framework-hosting-activitychannel are installed- The user is already linked across Telegram and Teams (Scenario 6)
import os
from dataclasses import replace
from agent_framework.hosting import (
AgentFrameworkHost,
ChannelRequest,
ResponseTarget,
TelegramChannel,
)
# Override the Telegram channel default: any inbound message becomes a
# background run delivered to the user's currently active channel.
async def telegram_background(request: ChannelRequest, **kwargs) -> ChannelRequest:
return replace(
request,
background=True,
response_target=ResponseTarget.active,
)
host = AgentFrameworkHost(
target=agent,
identity_linker=linker, # from Scenario 6
channels=[
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
transport="webhook",
run_hook=telegram_background,
),
# ActivityChannel(...), # future
],
)
host.serve(host="0.0.0.0", port=8000)
The flow:
alicesends a Telegram message that triggers a long-running tool. The Telegram channel produces aChannelRequest; the hook flipsbackground=Trueand setsresponse_target=ResponseTarget.active.host.run_in_background(request)returns aContinuationToken(token="ct_018f…", status="queued"). The Telegram channel acknowledges with a short "Working on it…" reply that includes the token (it could equally render a "Cancel" inline button bound to the token).- The host runs the target asynchronously. When complete, it resolves
ResponseTarget.activeagainst the host-tracked last-seen channel forisolation_key="alice@contoso.com". Ifaliceis currently on Teams, the host callsActivityChannel.push(channel_identity, hosted_run_result); if she is still on Telegram, it callsTelegramChannel.push(...)(so the same setup gracefully degrades to "reply on Telegram if she never switched"). ContinuationTokenis updated tostatus="completed"with the populatedresult. Any caller can pollGET /telegram/runs/{continuation_token}(or the equivalent route the channel exposes) to retrieve the run state by id.
Variants without changing channel code:
ResponseTarget.channel("activity")— always deliver to Teams, regardless of where the user is.ResponseTarget.all_linked— broadcast to every channelalicehas linked.ResponseTarget.none— fully detached: caller pollshost.get_continuation(token)(or the channel's poll route); no proactive push.background=Falsewithresponse_target=ResponseTarget.active— synchronous wait, but result still routed away from the originating channel (rare; mostly useful for pipelines where the originating call is a programmatic trigger and the human user lives elsewhere).
If the chosen destination channel does not implement ChannelPush (e.g. Responses), the host falls back to the originating channel and records the fallback in telemetry. This makes the Responses + background-run combo work as "submit on Responses, poll on Responses" without surprising silent drops.
Scenario 9: Hosting a Workflow instead of an agent (with checkpoint storage)
The host shape is unchanged when the target is a Workflow; the result wrapper narrows to HostedRunResult[WorkflowRunResult] and response_hook carries the projection that lets text-only channels render workflow output.
sequenceDiagram
autonumber
actor User
participant Channel
participant Host
participant Workflow
participant Store as HostStateStore<br/>(workflow_checkpoints)
participant Hook as response_hook<br/>(per-channel)
User->>Channel: message
Channel->>Host: ChannelRequest
Host->>Workflow: run(messages)
loop per executor / event
Workflow->>Store: write checkpoint
Workflow-->>Host: WorkflowEvent
end
Workflow-->>Host: WorkflowRunResult
Host->>Host: wrap → HostedRunResult[WorkflowRunResult]<br/>(get_outputs, get_final_state, …)
alt text-only channel
Host->>Hook: response_hook(result, context)
Hook->>Hook: result.replace(<br/>result=AgentResponse(<br/>text=workflow.get_outputs()[-1]))
Hook-->>Host: HostedRunResult[AgentResponse]
Host->>Channel: push (sync or via runner)
else card-capable channel
Host->>Hook: response_hook(result, context)
Hook->>Hook: render adaptive card<br/>from workflow get_outputs
Hook-->>Host: HostedRunResult[Any]
Host->>Channel: push
end
Channel-->>User: reply (channel-native)
Prerequisites: This sample assumes:
agent-framework-hostingandagent-framework-hosting-invocationsare installed- A
Workflowdefinition with typed inputs (OrderIntakeInputs)- A directory writable by the host process for workflow checkpoints
from dataclasses import dataclass, replace
from pathlib import Path
from agent_framework import FileCheckpointStorage, WorkflowBuilder
from agent_framework.hosting import (
AgentFrameworkHost,
ChannelRequest,
InvocationsChannel,
)
@dataclass
class OrderIntakeInputs:
customer_id: str
sku: str
quantity: int
# Build the workflow with a CheckpointStorage so individual executor frames
# are persisted as the workflow runs. FileCheckpointStorage writes one file
# per checkpoint under the configured directory; survives host restarts.
checkpoint_storage = FileCheckpointStorage(directory=Path("./.af-hosting/checkpoints/"))
workflow = (
WorkflowBuilder(checkpoint_storage=checkpoint_storage)
.add_executor(...) # application-defined
.build()
)
def adapt_to_workflow_inputs(request: ChannelRequest, *, protocol_request=None, **kwargs) -> ChannelRequest:
# The channel produces a default ChannelRequest with text input. The workflow
# needs typed OrderIntakeInputs — the hook is the adapter point. The same
# hook is the place to surface a caller-supplied checkpoint id (to resume
# an interrupted run) by promoting it onto request.attributes; the host's
# workflow dispatch reads it on the way to Workflow.run(...).
payload = protocol_request # raw Invocations request body
inputs = OrderIntakeInputs(
customer_id=payload["customer_id"],
sku=payload["sku"],
quantity=int(payload["quantity"]),
)
new_attrs = dict(request.attributes)
if checkpoint_id := payload.get("resume_from_checkpoint"):
new_attrs["workflow.checkpoint_id"] = checkpoint_id
return replace(request, input=inputs, attributes=new_attrs)
host = AgentFrameworkHost(
target=workflow,
channels=[
InvocationsChannel(run_hook=adapt_to_workflow_inputs),
],
)
host.serve(host="localhost", port=8000)
The host detects that target is a Workflow and dispatches the resulting ChannelRequest.input to Workflow.run(...) instead of SupportsAgentRun.run(...). The channel does not need to know which kind of target it is fronting — HostedRunResult and HostedStreamResult are normalized across both seams. The same workflow target could equally be exposed on Telegram or a Responses channel by supplying the appropriate run_hook to translate inbound chat messages into typed workflow inputs.
Checkpoint storage is wired onto the workflow itself (via WorkflowBuilder(checkpoint_storage=...) or per-run via Workflow.run(..., checkpoint_storage=...)), not on the host. The host treats it as workflow-runtime state — structurally distinct from the HostStateStore (which persists ContinuationTokens, identity-link grants, and last-seen records — host-execution metadata, not workflow internals) and from ContextProvider (per-conversation context). All three protocols stay separate, but a deployment MAY back them with the same physical store. When request.attributes["workflow.checkpoint_id"] is set (as the run hook does above when the caller supplies resume_from_checkpoint), the host's workflow dispatch path passes it through to Workflow.run(checkpoint_id=...) so the workflow resumes from that frame instead of running from scratch — useful for long-running intake flows that survive host restarts or retries.
Scenario 10: Authoring a new channel package
The shape any new channel follows: parse external protocol → produce default ChannelRequest → optionally apply hook → context.run(...) / context.stream(...) → serialize back.
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from agent_framework.hosting import (
Channel,
ChannelContext,
ChannelContribution,
ChannelRequest,
ChannelSession,
)
class MyWebhookChannel:
name = "mywebhook"
def __init__(self, *, path: str = "/mywebhook") -> None:
self._path = path
def contribute(self, context: ChannelContext) -> ChannelContribution:
async def endpoint(request: Request) -> JSONResponse:
payload = await request.json()
channel_request = ChannelRequest(
channel=self.name,
operation="message.create",
input=payload["text"],
session=ChannelSession(
key=payload["thread_id"],
isolation_key=payload["account_id"],
),
)
result = await context.run(channel_request)
# See "Result is rich, not just text" below — `result.text` is the
# plain-text projection; this channel chooses to also surface
# citations and any tool-call traces it cares about. The exact
# serialization is the channel's call.
return JSONResponse(_render_for_mywebhook(result))
return ChannelContribution(routes=[Route(f"{self._path}/inbound", endpoint, methods=["POST"])])
Result is rich, not just text. result here is a HostedRunResult[TResult] — a thin generic envelope around the target's full-fidelity output. For agent targets TResult narrows to AgentResponse, so channels read everything the target produced directly off result.result:
- the full
messages: list[ChatMessage]thread the agent produced this turn — each message holds an ordered list of typedContents(seeContentsin core):TextContent,DataContent(inline base64 blobs),UriContent(URLs to images/audio/files),FunctionCallContentandFunctionResultContent(tool-call traces),HostedFileContent/HostedVectorStoreContent(provider-side file/vector references),UsageContent(token usage),ErrorContent,TextReasoningContent(reasoning traces), and channel-extensible custom content kinds. Each content also hasadditional_propertiesfor provider-specific extensions (citations, image alt text, source spans, …), value: T | None— the typed structured output when the agent returned one (e.g. via response-format / structured-output features),response_id,usage_details: UsageDetails | None,raw_representation, and per-messageadditional_propertiescarrying provider-native extras.
For workflow targets TResult is WorkflowRunResult, so result.result.get_outputs() iterates the per-executor output payloads and result.result.get_final_state() exposes terminal-state info. The host never collapses or pre-shapes workflow outputs — channels (and developer-supplied response_hooks) own the projection, since "what counts as a renderable output" is wire-format-specific.
A channel author is free to project this into whatever the channel's native shape supports. Examples:
- The built-in Telegram channel renders
textsegments with Telegram'sMarkdownV2parse mode (escaping the special set), uploadsDataContentimages viasendPhotoand audio viasendAudioas separate Telegram messages in the same chat, and emits inline-button keyboards fromFunctionCallContenttraces when the channel is configured to surface tool calls as user-confirmable actions. Citations attached to aTextContent.additional_properties["citations"]slot are rendered as numbered footnote links the user can tap. - The built-in Responses channel preserves the full content-list shape on the wire — every
ChatMessageround-trips as a Responses-shaped output item so callers can inspect the typed mix of text, function-call traces, image/file outputs, reasoning, and structured-outputvalues exactly as the agent produced them. There is no lossy collapse to a single text field. - A channel fronting a chat UI can render
TextContentas full GitHub-Flavored Markdown / HTML (tables, code fences with syntax highlighting, math),DataContentandUriContentas inline images/audio/video players,FunctionCallContent/FunctionResultContentas collapsible "tool ran" cards, andTextReasoningContentas a collapsible reasoning panel — all from the sameresult. - A voice channel can route
TextContentthrough TTS, playDataContent(audio/*)directly, and surfaceFunctionCallContentonly as audio earcons (or skip them entirely) — the sameresultobject drives a completely different surface. - A richly-typed RPC channel can return
result.result.value(the structured output) directly when the workflow / agent produced one, and fall back to a text projection only when no typed output is available.
The host imposes no projection — channels read result.result.text for a convenience plain-text rollup on agent targets, but are encouraged to lean on the full underlying payload when their protocol supports more.
Information Design
Canonical flow
The default request/response shape — single channel, originating response, no fan-out. Authorization runs before run_hook; response_hook runs per-destination (here just one).
sequenceDiagram
autonumber
actor User
participant Channel as Channel<br/>(inbound)
participant Host
participant Auth as host.authorize
participant Target as Agent / Workflow
participant Annot as _annotate_intended_targets
User->>Channel: native payload (webhook / poll / HTTP body)
Channel->>Channel: parse → ChannelRequest<br/>(identity, conversation_id, content, response_target=originating)
Channel->>Host: dispatch(ChannelRequest)
Host->>Auth: authorize(identity, require_link, allowlist)
alt Denied / LinkRequired
Auth-->>Host: Denied(reason_code, user_message)<br/>or LinkRequired(challenge)
Host-->>Channel: render denial / link challenge<br/>(channel-appropriate UX)
Channel-->>User: short refusal in-room
else Allowed(isolation_key)
Host->>Host: resolve session via StateStore
Host->>Host: run_hook(request, context)
Host->>Target: target.run(messages, session=...)
Target-->>Host: AgentResponse / WorkflowRunResult
Host->>Host: wrap → HostedRunResult[TResult]
Host->>Annot: write hosting.intended_targets<br/>onto assistant message
Host->>Channel: response_hook(result, context)
Channel->>Channel: shape to native payload
Channel-->>User: reply on originating wire
end
The textual trace of the same flow (showing more of the per-step bookkeeping):
external request/event
-> channel-specific parsing + validation
-> ChannelIdentity extraction (per-channel native id)
-> default channel invocation mapping
-> optional run_hook (dev-supplied; default no-op)
-> ChannelRequest (carries response_target, background, echo_input)
-> AgentFrameworkHost / ChannelContext
-> identity_resolver(ChannelIdentity) -> isolation_key
-> host records (isolation_key, channel, now) as last-seen (for ResponseTarget.active)
-> AgentSession resolution (per session_mode, scoped by isolation_key)
-> target execution seam (Agent.run / Workflow.run)
-> HostedRunResult[AgentResponse] | HostedRunResult[WorkflowRunResult]
(full-fidelity result carried unchanged; no pre-shaping by the host)
-> [foreground] fan-out:
for each destination resolved from ResponseTarget:
-> clone HostedRunResult envelope (per-destination isolation; shallow copy)
-> optional channel response_hook (dev-supplied; default = identity)
-> hook receives ChannelResponseContext(request, channel_name, destination_identity, originating, is_echo)
-> hook may rebind result via HostedRunResult.replace(result=...)
(e.g. project a WorkflowRunResult to an AgentResponse for a text-only wire)
-> channel-native serialization (channel chooses what content types / outputs it can render)
-> channel.push(identity, shaped_payload) | originating return value
if ResponseTarget.echo_input is True:
each non-originating destination receives the user's input first
(synthesised as a HostedRunResult[AgentResponse] with a role="user" message),
then the agent reply. Both pushes execute inside the same scheduled
push task; an echo-push failure is logged and swallowed so the
response push on the same destination is still attempted.
-> [background or response_target != originating]
-> ContinuationToken returned immediately to originating channel
-> target executes asynchronously
-> on completion, the same fan-out (clone + response_hook + push) applies
-> ContinuationToken updated; available via host.get_continuation(token) and channel poll routes
Full-fidelity contract. The host never collapses agent / workflow output. HostedRunResult[TResult] carries the target output unchanged: agent targets see the full AgentResponse (multi-modal messages, value, usage_details, response_id, …); workflow targets see the full WorkflowRunResult (per-executor outputs via get_outputs(), terminal state via get_final_state()). Each channel — through its response_hook and its own serializer — decides what subset its wire can carry. A text-only channel iterates result.result.messages (or projects the workflow's outputs into a single text turn via a response hook); a card-capable channel inspects the underlying contents directly.
Per-destination cloning. Before invoking a channel's response_hook, the host clones the HostedRunResult envelope so one channel's replace(result=...) cannot leak into the payload another destination observes. The clone is shallow — channels that need to mutate result itself (rather than rebind it) own the deep copy.
response_hook is a channel-level convention, not part of the Channel Protocol. Channels expose a response_hook attribute (callable accepting (result, *, context: ChannelResponseContext) -> HostedRunResult[Any] | Awaitable[HostedRunResult[Any]]). The host duck-types this attribute. Adding hook support to an existing channel package does not break the public Channel Protocol.
A parallel link ceremony flow runs out-of-band when a user invokes the host-provided link/connect command on a channel:
channel /link command
-> linker.begin(ChannelIdentity) -> LinkChallenge
-> channel-specific rendering (URL, code, MFA prompt)
-> user completes the ceremony out-of-band (browser, second channel, MFA app)
-> linker callback/verification route
-> linker.complete(challenge_id, proof) -> isolation_key
-> host atomically associates (channel, native_id) -> isolation_key
-> subsequent requests resolve to the linked AgentSession
Inbound ownership
| Concern | Owned by | Notes |
|---|---|---|
| HTTP / WebSocket route shape | Channel package | e.g. /responses, /responses/ws, /invocations, /telegram/webhook — channels may contribute either or both |
| Protocol request model | Channel package | e.g. Responses items (HTTP body or WS frames), Invocations body, Telegram webhook payload |
| Signature/auth validation | Channel package or host middleware | channel-specific unless generic Starlette middleware |
| Request-to-agent invocation mapping | Channel package + optional run_hook |
forwards caller parameters into ChannelRequest.options, chooses session_mode, can enforce extra app policy |
| Native command catalog | Channel package using host-defined ChannelCommand |
e.g. Telegram bot commands, future Activity Protocol slash-command / adaptive-card surfaces, WhatsApp menus |
| Command registration at startup | Channel package | e.g. Telegram set_my_commands(...) |
| Command dispatch | Channel package | commands may reply locally, manipulate channel-owned state, or invoke the agent |
| Normalized input to the agent | Host core | ChannelRequest.input reuses AgentRunInputs |
| Session resolution | Host core | based on ChannelSession + ChannelRequest.session_mode; storage specifics deferred |
| Channel-native identity extraction | Channel package | populates ChannelIdentity(channel, native_id, attributes) per request |
Identity resolution (native_id → isolation_key) |
Host core via IdentityResolver |
default auto-issues and persists a per-user isolation_key on first contact per (channel, native_id); user-supplied resolver can return app-owned identities directly |
Identity store ((channel, native_id) → isolation_key) |
Host core via HostStateStore |
file-based by default in v1 (FileHostStateStore); pluggable for Cosmos / SQL / Redis in fast follow (req #24). Owns auto-issuance and atomic merge-on-link. |
| Identity link ceremony (OAuth / MFA / one-time code) | Host core via IdentityLinker |
linker contributes its own routes + lifecycle; channels surface a built-in link/connect command |
| Authorization (allowlist + link enforcement) | Host core via host.authorize(...) + per-channel IdentityAllowlist |
tri-state allowlist evaluated pre- and post-link; combines with require_link to produce one of three named profiles (open / forced-link / allowlist); see Authorization profiles and the IdentityAllowlist seam |
| Link & delivery policy across confidentiality tiers | Host core via LinkPolicy |
consulted at link time (refuse incompatible link attempts) and at delivery time (drop incompatible ResponseTarget destinations); built-in policies cover all-allow, same-tier, explicit allow-list, deny-all |
| Active-channel tracking | Host core | updated on every successfully resolved request; consumed by ResponseTarget.active |
| Response-target resolution | Host core | translates ResponseTarget (originating, active, specific, list, all_linked, none) into an ordered set of (channel, ChannelIdentity) deliveries |
| Proactive outbound delivery | Channel package via optional ChannelPush capability |
channels that can push (Telegram, Activity Protocol via Bot Service, webhook, SSE) implement push(identity, result); channels that can't are only valid as originating targets |
| Per-delivery audit + replay state | Host core writes intent-only — the resolved destination set onto the assistant Message.additional_properties["hosting"]["intended_targets"] (immutable, single write). Operational state (attempts, retries, last error, success timestamp) lives in the DurableTaskRunner and is observed via the runner's own backend. |
Replay across host restarts is a property of the configured runner (native for durable adapters; not supported for InProcessTaskRunner). See Intended targets + durable delivery and Durable task runner. |
| Background-run lifecycle | Host core | owns ContinuationToken issuance, async execution, completion notification; persists via HostStateStore (file-based default — survives restarts) |
| Run poll routes | Channel package | each channel exposes its own protocol-shaped poll route (/responses/{continuation_token}, /invocations/{continuation_token}) backed by host.get_continuation(token) |
| Conversation history (all channels — Responses, Invocations, Telegram, Activity Protocol, …) | Agent's core HistoryProvider (agent_framework._sessions.HistoryProvider) |
Channels project their wire id (previous_response_id, conversation_id, request body session_id, host-tracked alias, …) into ChannelSession.key; the host resolves an AgentSession and the agent's HistoryProvider does the load / append. No channel-specific history seam. Multi-provider composition (with a single load_messages=True) is the standard AF convention; see Conversation history for the Responses channel for the Foundry-backed variant. |
Channel-owned non-message per-thread state (e.g. AG-UI client_state) |
Channel-shipped ContextProvider subclass written into the same per-source state slot |
Reuses the existing ContextProvider seam — not a new storage protocol. Channel reads ChannelRequest.client_state in before_run, lets the agent observe/mutate the slot, then reads the post-run value in after_run to emit channel-specific events (e.g. AG-UI StateSnapshotEvent / StateDeltaEvent). Composition rules unchanged (one HistoryProvider carries load_messages=True; additional ContextProviders attach alongside). See Channel-owned per-thread state. |
| Agent invocation | Host core | always through the target's execution seam — SupportsAgentRun.run(...) for agent targets, Workflow.run(...) for workflow targets |
| Protocol response/event model | Channel package | core returns agent results; channel serializes them |
| ASGI server bootstrap | Host core convenience | host.serve(...) for default uvicorn path; host.app for custom hosting |
Channel session-carriage models
Channels split into two families based on who owns the session identifier across requests. This distinction is invisible to the agent target, but it changes which host-side mechanisms are load-bearing for that channel.
| Model | Examples | ChannelSession.key source |
How a caller starts a new thread |
|---|---|---|---|
| Caller-supplied session | Responses (previous_response_id / conversation_id), Invocations, A2A, MCP — generally any HTTP/RPC-shaped channel |
The wire payload carries it; the channel parses it into ChannelSession.key. None means "ephemeral / fresh thread". |
Omit the previous id (or send a fresh one). The caller is in control. |
| Host-tracked session | Telegram, Activity Protocol via Azure Bot Service (Teams/Web Chat/Slack/…), WhatsApp — generally any chat surface whose protocol carries identity (chat_id, AAD oid, from.id) but no per-conversation key |
The channel leaves ChannelSession.key = None and lets the host's per-isolation_key alias decide which AgentSession to resolve (rule #8 below). |
The channel surfaces a /new-style command (a ChannelCommand) that calls host.reset_session(isolation_key); the host's session-id alias rotates. There is no in-band way for the user to address a specific past thread. |
Identity is an orthogonal axis (anonymous vs. identified). The realized cells in v1 are:
| Anonymous | Identified | |
|---|---|---|
| Caller-supplied session | ✓ — bare curl /responses + previous_response_id. The id effectively is the identity (the resolver may project previous_response_id into the isolation_key for that turn). |
✓ — Responses + safety_identifier, or any caller-supplied channel behind a JWT/OAuth bearer that the resolver maps to an isolation_key. |
| Host-tracked session | n/a in v1 | ✓ — Telegram / Activity Protocol (Bot Service) / WhatsApp. The channel always authenticates; the resolver maps (channel, native_id) to isolation_key. |
Channel-author guidance. When implementing a new channel:
- If your upstream protocol carries a per-conversation identifier on every request, populate
ChannelSession.keyfrom it. You are a caller-supplied channel.host.reset_session(...)is not the right primitive for your/new-equivalent (your callers control that by simply omitting the previous id). Cross-channel linking viaIdentityLinkeris opt-in and depends on whether you also extract a stable identity (header, JWT, etc.) intoChannelIdentity. - If your upstream protocol carries identity but no per-conversation key, leave
ChannelSession.key = None. You are a host-tracked channel. To support "start a fresh thread", expose a channel-native command (Telegram/new, Teams adaptive-card button, …) that invokeshost.reset_session(isolation_key)— the host alias rotation does the rest, and prior history remains addressable under its previous session id. You are the canonical case for cross-channel linking; populateChannelIdentityfaithfully soIdentityLinkerandResponseTarget.active/.all_linkedcan find your users.
Mixing on one host. A single AgentFrameworkHost can mount channels of both families. A user can chat on Telegram (host-tracked) and have it linked via IdentityLinker to a Responses-channel session keyed by previous_response_id; in that case the linker's identity merge collapses both sides onto the same isolation_key and the host-tracked channel's alias becomes a peer of the caller-supplied previous_response_id for the same AgentSession. This is the v1 mechanism for "agent built on Responses, exposed to humans on Telegram, with continuity across both".
Session resolution rules
- If
ChannelRequest.session_mode == "disabled", the host bypasses session resolution and calls the target withsession=None. - If
session_mode == "auto", the host resolvesChannelSession.keyto anAgentSession, scoped byisolation_keywhen supplied. - If
session_mode == "auto"and no key is supplied, the host may create an ephemeral session. - If
session_mode == "required", the host must resolve or create a usable session before invoking the target. - Cross-channel resolution rule: when two channels mounted on the same
AgentFrameworkHostproduce the sameisolation_key(and either both omitkeyor both produce equivalent keys derived fromisolation_key), the host resolves them to the sameAgentSession. This is the v1 mechanism for cross-channel chat continuity (e.g. Telegram → Teams against the same conversation history). The canonical path for translating a channel's native per-channel identifier (Telegramchat_id, Teams AAD object id, …) into the stableisolation_keyis the host-levelIdentityResolver(per-channelrun_hookmapping is supported as a lower-level alternative). When the channel-native identity is not yet linked, theIdentityLinkerruns a connect ceremony (OAuth, MFA, signed one-time code) to associate it with an existingisolation_key. - The first spec does not standardize a cross-package storage API; cross-host/cross-process continuity is deferred to the pluggable session store (req #24), which also persists identity-link grants beyond the host process lifetime.
- Responses and other conversation-aware channels may still own protocol-specific conversation/item storage above this layer.
- Session rotation (
reset_session). The host exposesreset_session(isolation_key)so host-tracked channels (see Channel session-carriage models) can implement "start a fresh thread" commands (e.g. Telegram/new). The default behavior rotates the active session id alias (<isolation_key>→<isolation_key>#<short-uuid>) rather than deleting on-disk history: prior history remains addressable by its original session id while subsequent runs for thatisolation_keyresolve to a brand-newAgentSession. Apps that want destructive reset can layer that on top by calling into their ownHistoryProvider. Caller-supplied channels do not callreset_session; their callers branch threads by sending a fresh / noprevious_response_id(or equivalent) on the next request.
Channel metadata persisted onto stored messages
When the host invokes the target, it does not pass the raw ChannelRequest.input directly. It first wraps the input into a Message(role="user", contents=[...]) whose additional_properties["hosting"] carries an envelope describing where the message came from and where its response should go. This makes the resulting conversation history self-describing for any HistoryProvider (FileHistoryProvider, future Cosmos/Foundry providers, …) without that provider having to know anything channel-specific.
{
"channel": "telegram", // ChannelRequest.channel
"identity": { // populated from ChannelRequest.identity
"channel": "telegram",
"native_id": "<telegram-chat-id>",
"attributes": { /* channel-specific */ }
},
"response_target": { // populated from ChannelRequest.response_target
"kind": "originating",
"targets": [] // [(channel, native_id), ...] for explicit targets
}
}
Round-trip is guaranteed by Message.to_dict() / Message.from_dict(). Future providers that key on protocol shape (e.g. a Responses previous_response_id-keyed store) can read this envelope to reconstruct cross-channel context without needing a separate channel-metadata sidecar.
FoundryHostedAgentHistoryProvider round-trips the entire additional_properties["hosting"] namespace (and any other AF-side namespace) through the Foundry response store via a single opaque agent_framework container key written onto each OutputItem. Because the schema is now intent-only (no per-destination mutation after the initial write — see Intended targets + durable delivery), no service-side additions to the Foundry storage SDK are required for it to round-trip.
Intended targets + durable delivery
The inbound envelope above captures the caller's intent. The assistant Message produced by the host carries a parallel envelope that records the resolved destination set — what the host actually intended to deliver to, after ResponseTarget resolution and LinkPolicy filtering. This is a single write, never mutated. Operational state for each push attempt (status, attempts, retries, last error, channel-issued id) lives in the DurableTaskRunner — not on the message — because the runner is the component that performs and (when durable) retries the push.
The shape of the fan-out — synchronous on the originating wire, scheduled via the runner for every non-originating destination — is the same in every multi-target scenario (all_linked, active, channels([...]), identities([...])):
sequenceDiagram
autonumber
actor User
participant Tg as TelegramChannel<br/>(originating)
participant Host
participant Target as Agent
participant Runner as DurableTaskRunner
participant Annot as _annotate_intended_targets
participant Act as ActivityChannel<br/>(linked)
participant Resp as ResponsesChannel<br/>(linked)
User->>Tg: message
Tg->>Host: ChannelRequest(<br/>identity=tg:12345,<br/>response_target=all_linked)
Host->>Host: resolve isolation_key
Host->>Target: run
Target-->>Host: AgentResponse
Host->>Annot: hosting.intended_targets =<br/>[tg, activity, responses]
par originating — synchronous
Host->>Tg: response_hook → push (sync)
Tg-->>User: reply on Telegram
and non-originating — durable
Host->>Runner: schedule("hosting.push",<br/>payload for activity)
Runner->>Host: _handle_push_task(payload)
Host->>Act: response_hook → push
Act-->>User: reply in Teams (or wherever)
and
Host->>Runner: schedule("hosting.push",<br/>payload for responses)
Runner->>Host: _handle_push_task(payload)
Host->>Resp: response_hook → push
end
Schema on Message.additional_properties["hosting"] for a host-produced assistant message:
{
"originating": { // mirror of the inbound envelope above
"channel": "telegram",
"identity": { "channel": "telegram", "native_id": "12345", "attributes": {} },
"response_target": { "kind": "all_linked", "targets": [] }
},
"intended_targets": [
{ "destination": { "channel": "activity", "native_id": "29:abc..." } },
{ "destination": { "channel": "telegram", "native_id": "12345" } }
],
"skipped_targets": [ // optional — present only when LinkPolicy excluded something
{
"destination": { "channel": "corp-only", "native_id": "..." },
"reason": "link_policy" // link_policy | no_push_capability
}
]
}
Lifecycle the host follows:
- After
ResponseTargetresolution andLinkPolicyfiltering, the host writes the assistantMessageonce, with the resolvedintended_targets[](every destination it will attempt) and an optionalskipped_targets[]for destinations dropped at resolution time (so audit can show why a resolved-by-ResponseTargetdestination did not receive the message —link_policyorno_push_capability). This write is immutable. - For each non-originating destination, the host schedules a
"hosting.push"task via the configuredDurableTaskRunner. The runner is responsible for attempting, retrying per itsRetryPolicy, and (for durable runners) surviving host restarts. The push handler resolves the channel, runs the channel'sresponse_hook, and callsChannelPush.push(...). - Operational delivery state — attempt count, last error, success timestamp, channel-issued message id — lives in the runner's own log. Replay across host restarts is a property of the runner (native for durable runners; not supported for the in-process runner). Operators who want a queryable delivery dashboard can read it from their runner backend's observability surface (TaskHub, Foundry durable tasks, …) — the host does not project it back onto the message.
The originating destination (when ResponseTarget includes it) is not routed through the runner. It is rendered synchronously on the originating channel's wire; the host-internal _deliver_response helper returns bool (True if any push was scheduled / delivered, False otherwise) for the channel's own bookkeeping. Per-destination delivery outcomes are not collated back to the caller — durable runners surface them in their own logs / dashboards, and the in-process runner logs failures with structured fields. See Built-in routes for the synchronous return contract.
Why intent-only on the message, with operational state in the runner? A single immutable write keeps the message store as the source of truth for "what the host intended", without requiring providers to implement in-place mutation (no
SupportsDeliveryTrackingcapability, no Foundryupdate_itemservice ask). Per-destination retry, replay, and failure surfacing become responsibilities of the runner, which is the right component because it owns the work queue. Operators who already use a durable runner (TaskHub, Foundry durable tasks) get observability through the runner's existing tooling rather than through a parallel ETL on the message store.
Durable task runner
The host delegates non-originating push fan-out — and, in v1 fast-follow, background runs — to a pluggable DurableTaskRunner. The runner is the component that owns "this work needs to happen; retry on failure; survive (or don't survive) restarts depending on which runner you chose". Channel packages never see it directly; they just implement ChannelPush.push(...).
from typing import Protocol, Callable, Awaitable, Mapping, Any, Literal
from dataclasses import dataclass
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 5
initial_backoff_seconds: float = 1.0
backoff_multiplier: float = 2.0
max_backoff_seconds: float = 60.0
@dataclass(frozen=True)
class TaskHandle:
task_id: str # opaque, runner-issued
name: str # the registered handler name
TaskStatus = Literal["scheduled", "running", "succeeded", "failed", "cancelled"]
class DurableTaskRunner(Protocol):
def register(
self,
name: str,
handler: Callable[[Mapping[str, Any]], Awaitable[None]],
) -> None: ...
async def schedule(
self,
name: str,
payload: Mapping[str, Any],
*,
retry_policy: RetryPolicy | None = None,
) -> TaskHandle: ...
async def get(self, handle: TaskHandle) -> TaskStatus | None: ...
The host registers an internal handler "hosting.push" at startup. Each non-originating destination becomes a single runner.schedule("hosting.push", payload) call. The handler:
- Resolves the channel from
payload["channel_id"]. - Clones the
HostedRunResultand runs the channel'sresponse_hook(if any). - Calls
ChannelPush.push(identity, shaped_result). - Returns normally on success. On exception, the runner records the failure and either schedules a retry per
RetryPolicyor marks the taskfailed(terminal).
Built-in runner shipped in core:
| Runner | Persistence | Replay across restarts | Default for |
|---|---|---|---|
InProcessTaskRunner |
None — asyncio.create_task + in-process retry |
No (in-flight tasks lost on process death) | runtime_mode="long_running" |
Adapter packages (deferred to v1 Fast Follow; no runtime dep from core):
| Package | Backend | Notes |
|---|---|---|
agent-framework-hosting-durabletask |
agent-framework-durabletask (gRPC TaskHub) |
Suits ephemeral deployments that already run a Durable Task sidecar. |
agent-framework-hosting-foundry (extension) |
Foundry durable-task API | Deferred until the FHA durable-task surface is finalized. |
| (possibly) SQLite-outbox runner | SQLite under the existing HostStateStore root |
Lowest-dep "survives single-node restart" option for ephemeral hosts without an external sidecar. |
Default selection follows Runtime modes. long_running defaults to InProcessTaskRunner. ephemeral is strict: if durable_task_runner is not configured and allow_in_process_runner=True is not opted in, the host raises RuntimeError at construction — falling back to the in-process runner in an ephemeral environment would silently drop in-flight pushes on the next scale-to-zero. The allow_in_process_runner=True escape hatch is intentionally noisy (warning) and meant for local dev / smoke tests.
Codec contract for durable serialisation
When a DurableTaskRunner is configured for a deployment that uses out-of-process scheduling (e.g. a sidecar / gRPC TaskHub), task payloads must be JSON-serialisable end to end. Two pieces of the contract enforce this:
DurableTaskRunner.payload_mode— a class-level attribute declared by each runner implementation:OBJECT— the in-process runner; payloads pass Python objects by reference. No serialisation required.JSON— out-of-process runners; payloads must round-trip through JSON.
ChannelPushCodec— a Protocol exposed by push-capable channels whose payloads are not natively JSON-serialisable. The codec definesencode(payload) -> Mapping[str, Any]/decode(envelope) -> Anyso the channel owns the over-the-wire shape of its push payloads. Channels without exotic payloads can leave the codec unset and rely on the host's defaultdataclasses.asdict-style encode.
At construction the host runs _validate_runner_codec_pairing: if the configured runner declares payload_mode == JSON and any push-capable channel does not expose a codec, the host raises ChannelConfigurationError so the misconfiguration is caught before traffic. On the consumer side _handle_push_task accepts both OBJECT-mode (in-memory object) and JSON-mode ({"type": "push", ...} envelope) shapes so the same handler serves both runner backends.
sequenceDiagram
autonumber
participant Host
participant Codec as ChannelPushCodec<br/>(on the push channel)
participant Runner as Durable runner<br/>(payload_mode=JSON)
participant Worker as Runner worker<br/>(may run after host scaled to zero)
participant Channel as Push channel
participant External as External service<br/>(Telegram / Bot Framework / …)
Note over Host,Runner: construction-time:<br/>_validate_runner_codec_pairing<br/>(refuse JSON runner + codec-less channel)
Host->>Codec: encode(payload) → JSON-safe Mapping
Host->>Runner: schedule("hosting.push",<br/>{"type": "push", "channel": "tg",<br/>"payload": <encoded>})
Runner->>Runner: persist task
Runner-->>Host: TaskHandle
Host-->>Host: synchronous return path<br/>(originating already delivered)
Note over Worker: ... host may scale to zero ...
Worker->>Runner: dequeue task
Worker->>Host: invoke "hosting.push" handler<br/>(JSON envelope)
Host->>Host: _handle_push_task<br/>detect envelope shape (OBJECT or JSON)
Host->>Codec: decode(payload) → in-memory object
Host->>Channel: ChannelPush.push(identity, result)
Channel->>External: native API call
alt success
External-->>Channel: ok
Channel-->>Worker: handler returns
Worker->>Runner: mark task succeeded
else transient failure
External-->>Channel: 5xx
Channel-->>Worker: raise
Worker->>Runner: retry per RetryPolicy
else terminal failure
Worker->>Runner: max_attempts → mark failed<br/>(log only)
end
In-process runner shutdown drain
InProcessTaskRunner ships a two-phase shutdown driven by shutdown_grace_seconds (default 5.0):
- After lifespan shutdown signals, in-flight
"hosting.push"tasks are given the grace period to finish — during which retries keep happening — so a clean Ctrl-C does not abandon work that is one network call away from completing. - When the grace expires, remaining tasks are cancelled and their
CancelledErroris swallowed (not logged as a failure — it is the expected shutdown shape).
This is purely operational hygiene for the long_running default; durable adapters get this behaviour for free from their backends.
Echo idempotency on retry
When ResponseTarget.channel(name, echo_input=True) is set, the host packages an echo (role="user") push and the agent reply (role="assistant") into the same "hosting.push" task per non-originating destination. The handler tracks an echo_done cursor on the task state and short-circuits the echo phase on retry: a retry that fires after the echo succeeded but before the response push completed will not double-echo the user's message. The cursor lives on the runner-owned task state, not the message — same principle as the broader "intent only on the message, operational state in the runner" rule.
sequenceDiagram
autonumber
participant Runner
participant Host
participant Channel
participant External
Runner->>Host: _handle_push_task(<br/>echo, response,<br/>state={echo_done: False})
Host->>Host: read echo_done → False
Host->>Channel: response_hook(echo, is_echo=True)
Channel->>External: push user message
External-->>Channel: ok
Host->>Runner: persist state.echo_done = True
Host->>Channel: response_hook(response, is_echo=False)
Channel->>External: push assistant reply
External-->>Channel: 5xx (transient)
Channel-->>Host: raise
Runner->>Runner: retry per RetryPolicy<br/>(backoff)
Runner->>Host: _handle_push_task(<br/>echo, response,<br/>state={echo_done: True})
Host->>Host: read echo_done → True (skip echo)
Host->>Channel: response_hook(response, is_echo=False)
Channel->>External: push assistant reply
External-->>Channel: ok
Host-->>Runner: handler returns → succeeded
Reference and Parity Plan
The new core sits below the conceptual boundary of today's top-level Responses/Invocations host wrappers but is implemented in Agent Framework-owned code. Existing top-level agentserver hosts inform behavior, naming, and parity targets — without becoming runtime dependencies of the hosting core. Individual channel packages MAY consume lower-level building blocks shipped in azure.ai.agentserver (e.g. FoundryHostedAgentHistoryProvider builds on the Foundry response-store SDK).
| Existing code area | Proposed treatment | Why |
|---|---|---|
SupportsAgentRun.run(..., session=..., stream=...) |
Reuse directly in core for agent targets | Already the correct Python execution seam |
Workflow.run(...) and workflow streaming events |
Reuse directly in core for workflow targets; normalize outputs into HostedRunResult/HostedStreamResult |
Lets channels stay target-agnostic |
| Session resolution logic in current hosting layers | Implement in core, using current behavior as reference | Host behavior, not protocol behavior |
| Starlette app assembly and route aggregation | Implement in core, referencing current servers | Needed by every channel |
PR #5393 Telegram BOT_COMMANDS, CommandHandler(...), set_my_commands(...) |
Reference for the generic ChannelCommand capability |
Clearest current prior art for native command catalogs + runtime dispatch |
agent_framework_foundry_hosting._to_chat_options |
Inspiration for Responses channel-owned mapping | Still protocol-specific |
agent_framework_foundry_hosting._items_to_messages / _output_item_to_message |
Inspiration / parity reference in Responses channel codec | Useful, not generic hosting |
agent_framework_foundry_hosting._to_outputs and ResponseEventStream |
Inspiration for Responses event mapping; the new Responses channel owns its own AF-native serialization rather than reusing top-level agentserver host wrappers |
Responses-specific serialization |
azure.ai.agentserver.responses.ResponseContext.get_history() + Store |
Folded into the agent's normal core HistoryProvider flow. The Responses channel projects previous_response_id / conversation_id into ChannelSession.key; the agent's HistoryProvider does the load / append exactly as for any other session. No Responses-specific history Protocol. |
One uniform history seam across channels — the developer chooses where to store, and may compose multiple providers under the standard "single load_messages=True" rule. |
azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider (HTTP-backed Foundry storage with IsolationContext user/chat headers) |
Wrapped by a native FoundryHostedAgentHistoryProvider in agent-framework-foundry-hosting that builds on top of the SDK and exposes the standard core HistoryProvider Protocol. Agents attach it the same way they attach FileHistoryProvider. |
Lets the Foundry response store back conversations driven through the new host, while keeping the channel agnostic to the storage backend. The provider owns a runtime dependency on azure.ai.agentserver (for the storage SDK) so it stays aligned with the SDK's wire contract, auth, and isolation headers without duplication. Same provider also works for non-Responses channels (Telegram, Invocations, …) so the choice is "where do I want history persisted" rather than "which channel am I exposing". |
agent_framework_foundry_hosting._invocations.InvocationsHostServer._sessions (in-process dict[str, AgentSession]) |
Replace with the host's normal ChannelSession.key → AgentSession resolution; agent history flows through its own (optional) core HistoryProvider(load_messages=True) |
Invocations does not need a protocol-shaped history seam — confirmed by today's foundry hosting which keeps no Store on the Invocations side |
ResponsesAgentServerHost / InvocationAgentServerHost top-level wrappers |
Conceptual prior art only | Sit too high; encode protocol ownership |
| Workflow checkpoint behavior in current Responses hosting | Defer; reference only for future work | Needs separate design if it becomes shared |
Dependencies & Commitment Status
| Dependency | Team | DRI | Status |
|---|---|---|---|
SupportsAgentRun execution seam |
Agent Framework Core (Python) | TBD | Committed (existing) |
Workflow execution seam |
Agent Framework Core (Python) | TBD | Committed (existing); host wraps workflow outputs into HostedRunResult/HostedStreamResult |
AgentSession / conversation primitives |
Agent Framework Core (Python) | TBD | Committed (existing); cross-package storage standardization deferred |
| Starlette | External (BSD-licensed) | n/a | Committed; required runtime dep of agent-framework-hosting |
| Uvicorn | External (BSD-licensed) | n/a | Open Question — required dep vs optional extra (see Open Questions) |
agent-framework-foundry-hosting parity reference |
Agent Framework Hosting | TBD | Reference-only, no runtime dependency |
FoundryHostedAgentHistoryProvider (in agent-framework-foundry-hosting, built on azure.ai.agentserver.responses.store._foundry_provider.FoundryStorageProvider) |
Agent Framework Foundry | TBD | Proposed v1 deliverable so Foundry-defined (and any other) agents can use Foundry's response store as a HistoryProvider through the new host. Implements the standard core HistoryProvider Protocol — usable from any channel, no Responses-specific Protocol. Owns a runtime dep on azure.ai.agentserver for the storage SDK. |
| PR #5393 Telegram sample (commands, polling/webhook patterns) | Agent Framework | PR author | Reference-only; informs ChannelCommand and TelegramChannel design |
| Telegram Bot API SDK | External | n/a | Committed (runtime dep of agent-framework-hosting-telegram) |
microsoft/teams.py SDK (microsoft-teams-apps, microsoft-teams-api, microsoft-teams-cards) |
External (MIT, Microsoft) | n/a | Proposed runtime dep of agent-framework-hosting-teams (req #29). The SDK already ships a "Build an agent using Microsoft Agent Framework" guide and a pluggable HttpServerAdapter, so the hosting package mounts the SDK's App into the host's Starlette app and reuses its Adaptive Cards / Streaming / Citations / Feedback / Suggested-prompts / Dialogs / Message-Extensions / SSO surface instead of re-implementing them. |
agent-framework-ag-ui, -a2a, -devui |
Agent Framework | various | Out of scope for first implementation; future convergence kept as a possibility |
Open Questions
| # | Question | On Point | Notes |
|---|---|---|---|
| 5 | How much of the Responses Conversations API should the Responses channel own vs a future shared conversation utility? | Eng / PM | Tied to whether session storage gets standardized. |
| 6 | Should a later phase define a pluggable session store interface? | Eng | Needs to be designed holistically across all storage axes — sessions, messages, identity links, run-state / continuation tokens, workflow checkpoints — rather than per-axis. Tracked as v1 fast-follow / requirement #23. |
| 8 | Should command scopes / projection metadata become first-class — e.g. private-chat-only vs group-chat-visible commands, or per-locale descriptions? | Eng / PM | Telegram's BotCommandScope and language_code would need to be representable cross-channel. |
| 10 | Is "Channel" the GA name? "Head" was used interchangeably during design discussions. | PM | "Channel" chosen for the spec; confirm before public docs. |
| 12 | Should ChannelRequest.session_mode grow additional values (e.g. "shared" for multi-channel session sharing) or stay closed at three? |
Eng | The taxonomy needs a dedicated design exercise covering all known channel session-shape patterns; revisit after that exercise. |
| 14 | Where do issued link grants live — short-lived in-memory state on the host, the same pluggable session store (#24), or a separate identity store? | Eng | Resolved as part of the HostStateStore seam (see Host state storage). Link grants live alongside continuation tokens and last-seen records in the v1 file-based default (FileHostStateStore → link_grants/ namespace, 15min TTL). Pluggable Cosmos / SQL / Redis adapters tracked in req #24. → Move to Resolved Questions in next pass. |
| 17 | Should ResponseTarget.active honor a configurable time window (last seen within N minutes) and what is the fallback when the window has expired before the response is ready — originating, all_linked, drop with ContinuationToken status="failed"? |
PM / Eng | Likely yes with sensible default (e.g. 24h fall back to originating); per-request override via the run hook. |
| 22 | For the Responses WebSocket transport, what subprotocol identifier (if any) should be advertised on the Upgrade and how is auth conveyed — Authorization header on the upgrade, a Sec-WebSocket-Protocol token, or a query-string-bound short-lived token? |
Eng / PM | Aligning with whatever OpenAI ships for Responses WS is preferable; keep the codec swappable so the channel can track upstream changes without breaking the host contract. |
Resolved Questions (decisions log)
Original numbering preserved so external references (checkpoints, ADR cross-links) still resolve. Decisions captured here may imply spec-body changes elsewhere — see Decisions-driven follow-ups below.
| # | Question | Decision |
|---|---|---|
| 1 | Final distribution package names? | agent-framework-hosting with suffixes (-responses, -invocations, -telegram, …). Public imports stay at agent_framework.hosting. |
| 2 | uvicorn required vs optional extra? |
Use hypercorn instead of uvicorn; the serve extra remains optional. host.app is still the canonical server-agnostic ASGI surface. |
| 3 | Keep HostedRunResult wrapper or return AgentResponse directly? |
Keep HostedRunResult, now shaped as a generic typed envelope HostedRunResult[TResult] (see Q31). It wraps both AgentResponse and WorkflowRunResult, and carries host-run metadata (resolved session) alongside the full-fidelity target output. |
| 4 | Where do generic auth helpers live? | Only the mechanisms live in core. Concrete implementations sit in their own packages when they pull dependencies; dep-free helpers may live in hosting. |
| 7 | protocol_request typed (Any) or typed kwargs? |
Keep Any. |
| 9 | Allow nested routers / path=""? |
Yes. The host developer is responsible for ensuring routes do not overlap. |
| 11 | Should the host support multiple targets? | No — final. Solve a layer above (an external router that owns multiple single-target hosts). |
| 13 | Which identity linkers ship in phase 1? | Entra linker (in the Entra package) + one-time-code linker (in core). Drop MFA for now; investigating additional linkers tracked as a follow-up. |
| 15 | Identity resolver invoked once on host vs per channel? | Once on the host with ChannelIdentity(channel, native_id, ...). |
| 16 | Should IdentityLinker and Channel share a base Contributor protocol? |
A linker is a Channel — specialised. Use the single Channel-shaped contract; collapse IdentityLinker into a Channel specialisation. |
| 18 | Contract for ChannelPush failures? |
The DurableTaskRunner owns retry and final-failure semantics, per its RetryPolicy. Push handler exceptions are caught by the runner, which retries with backoff and ultimately marks the task failed when max_attempts is exhausted. Downstream push outcomes live in the runner's own log — there is no per-destination status surfaced on the message and no synchronous failure object returned to the caller. The host's internal _deliver_response helper returns bool (whether any work was scheduled) for the originating channel; observability for downstream pushes comes from the runner backend (TaskHub, Foundry durable tasks, log fields on InProcessTaskRunner). The earlier DeliveryReport value type has been removed. See Intended targets + durable delivery and Durable task runner. |
| 19 | host.run_in_background(...) notify callback? |
Programmatic non-channel delivery will be expressed via the continuation_token mechanism (see Q20), not a separate notify callback. |
| 20 | Storage / TTL of ContinuationTokens? |
Done in this revision. ContinuationToken is the type, with an opaque token: str field that channels surface to callers; equivalent continuation-token support is added to the Invocations channel alongside the existing Responses behaviour. Push-capable channels can still use it; default behaviour remains "push on completion", but the developer can choose other UX (poll-after-push, hybrid, …). Persistence is the HostStateStore seam — v1 default is FileHostStateStore (atomic JSON writes, 24h TTL on completed entries), so background runs survive host restarts. |
| 21 | Partial-failure surfacing for all_linked? |
Runner-only. Originating-destination outcome is rendered synchronously on the originating channel's wire; the host's _deliver_response helper returns bool for the channel's own bookkeeping. Non-originating destinations are scheduled as "hosting.push" tasks on the DurableTaskRunner; per-task outcome (success / retried / terminal-failure) is observable via the runner's backend (TaskHub, Foundry durable tasks, structured log fields on InProcessTaskRunner). The host does not collate per-destination status back onto the message and no longer emits a DeliveryReport. |
| 23 | Share one backing store contract for host-level vs ContextProvider? |
Stay separate protocols (current draft direction confirmed). A deployment may still bind both onto the same physical backend. |
| 24 | Where does the Foundry history provider live? | Tentative name FoundryHostedAgentHistoryProvider, in the foundry-hosting package (shares the dependency). Confirm with Foundry package owners before launch. |
| 25 | Channel.confidentiality_tier opaque vs enum? |
Keep as str? for now; can revisit before Release. |
| 26 | Where does the delivery-replay mechanism live? | In the DurableTaskRunner. Durable adapters (TaskHub, Foundry durable tasks) provide retry-with-backoff and survive host restarts natively — replay is "the runner keeps retrying until max_attempts is exhausted or the push succeeds". The built-in InProcessTaskRunner retries within the process but does not survive restarts (in-flight tasks are lost). Operator-driven replay (host.replay(task_handle)) is out of scope for v1; the runner's own surface is sufficient for the common case. |
| 28 | Should the host collapse agent / workflow output to text? | No. HostedRunResult[TResult] carries the target output unchanged — full AgentResponse (with its multi-modal messages, value, usage_details) for agent targets, full WorkflowRunResult (with its get_outputs() / get_final_state()) for workflow targets. Channels decide what subset their wire renders; a response_hook may rebind result (e.g. project a workflow output into an AgentResponse for a text-only wire) via HostedRunResult.replace(result=...). The host never loses fidelity it has, and never restricts modality. |
| 29 | How do channels do per-destination post-processing (text flattening, card rendering, citation attachment) without breaking the Channel Protocol? |
Channels expose a response_hook instance attribute (callable accepting (result, *, context: ChannelResponseContext) -> HostedRunResult[Any] | Awaitable[HostedRunResult[Any]]). The host duck-types this attribute and applies it on a per-destination clone of the HostedRunResult envelope before push. The Channel Protocol stays a small name / path / contribute contract — adding hook support to a new channel does not require Protocol changes. |
| 30 | Should non-originating destinations also see the user's input message, not just the agent reply? | Opt-in via ResponseTarget.channel(name, echo_input=True) (and the same kwarg on .channels([...]) / .identities([...])). The host synthesises a HostedRunResult[AgentResponse] wrapping the user's input as a role="user" message and bundles it into the same scheduled push task as the agent reply per non-originating destination; the echo is dispatched first inside the task and an echo-push failure is logged and swallowed so the response push on the same destination is still attempted. Channels can transform or drop echoes via their response_hook (which receives is_echo=True for the echo phase). |
| 31 | Should HostedRunResult be flattened (text / messages) or carry the full target output? |
Carry the full target output, generically typed. HostedRunResult[TResult] exposes a single result: TResult field — AgentResponse for agent targets, WorkflowRunResult for workflow targets — plus an optional session: AgentSession | None. Earlier drafts carried a flattened messages: list[Message] projection alongside raw_response; this lost workflow-specific affordances (get_outputs(), get_final_state(), structured per-executor payloads) and forced the host to pre-shape data only some channels needed. The generic envelope keeps the host modality-agnostic, lets channels read the canonical accessor on the underlying type (result.messages, result.value, result.get_outputs(), …), and gives channel authors static typing where they want it. |
| 32 | Should authorization (per-channel allowlist) ship as a single auth_mode enum or as two orthogonal parameters? |
Two orthogonal parameters (require_link: bool + allowlist: IdentityAllowlist | Literal["inherit"] | None = "inherit") plus named AuthPolicy factories for the three common combinations. A single enum collapses require_link and allowlist into one axis and cannot express the Mixed profile (AnyOfAllowlists(NativeIdAllowlist, LinkedClaimAllowlist) with require_link=False — native ids bypass auth, everyone else is funneled into linking) without re-introducing per-value sub-parameters that would defeat the point. Composition is built on a tri-state AllowlistDecision (ALLOW / DENY / ABSTAIN) rather than a boolean, because boolean composition cannot distinguish "claim allowlist denies you" from "claim allowlist hasn't seen any claims yet" — a critical distinction for the Mixed profile. LinkedClaimAllowlist is rejected at host startup if no source of verified claims is available (config validator, fail-fast), preventing the silent-deny-everyone footgun. Group-chat denials apply the same DM-redirect pattern as LinkChallenge (short generic refusal in-room, fuller user_message in DM, structured log_details only in logs). Shipping in two waves: the Protocol + NativeIdAllowlist + config validator ship with the next core PR; full host.authorize(...) pipeline + LinkedClaimAllowlist enforcement land with the IdentityLinker core PR. See Authorization profiles and the IdentityAllowlist seam. |
| 33 | How does the host decide whether it is running long-running vs ephemeral? | Single runtime_mode parameter on AgentFrameworkHost, defaulting to None for auto-detection. Auto-detect inspects known deployment markers (FOUNDRY_HOSTING_ENVIRONMENT, AZURE_FUNCTIONS_ENVIRONMENT, AWS_LAMBDA_FUNCTION_NAME) and picks "ephemeral" on the first hit; otherwise falls back to "long_running" (sensible local-dev / always-on default). The mode is advisory — it drives defaults for HostStateStore, DurableTaskRunner, identity-link state, and similar seams, but every individual choice remains overridable. Detected mode is logged at startup so misdetection is visible. See Runtime modes. |
| 34 | How does delivery to non-originating destinations actually happen — synchronously in the originating request handler, or out-of-band? | Out-of-band via a DurableTaskRunner. The host registers an internal handler "hosting.push" at startup; each non-originating destination becomes a single runner.schedule("hosting.push", payload) call. The originating destination (when ResponseTarget includes it) is still rendered synchronously on the originating channel's wire — only fan-out goes through the runner. Default runner is InProcessTaskRunner (asyncio + bounded retry, no cross-restart persistence — suitable for long_running). Durable adapter packages (agent-framework-hosting-durabletask, future Foundry adapter) plug into the same Protocol for ephemeral deployments. See Durable task runner. |
| 35 | What is the audit shape on the assistant message — full per-destination state machine, or intent only? | Intent only. Message.additional_properties["hosting"]["intended_targets"] is a single immutable write that records the resolved destination set (after ResponseTarget + LinkPolicy filtering). Operational state — attempt count, last error, success timestamp, channel-issued id — lives in the DurableTaskRunner and is observed via the runner's backend. This eliminates the previous deliveries[] status state machine (pending/delivered/failed/skipped), the SupportsDeliveryTracking provider capability, and the Foundry update_item service ask. See Intended targets + durable delivery. |
| 36 | What happens when runtime_mode="ephemeral" and no durable_task_runner is configured? |
Raise at construction. Silently falling back to InProcessTaskRunner in an ephemeral environment would drop every in-flight push on the next scale-to-zero — a footgun. The host raises RuntimeError unless allow_in_process_runner=True is opted in (warning logged). The opt-in is intended for local-dev / smoke tests where the developer accepts the in-flight loss. See Durable task runner. |
| 37 | What is the wire contract for push payloads under a durable (out-of-process) runner? | A two-piece contract. Each DurableTaskRunner declares its payload_mode (OBJECT for in-process pass-by-reference; JSON for runners that round-trip through JSON). Channels that ship non-JSON-native payloads expose a ChannelPushCodec (encode / decode). At construction the host runs _validate_runner_codec_pairing and refuses a JSON-mode runner paired with codec-less push channels. The push handler accepts both OBJECT and JSON envelope shapes so the same handler serves both runner backends. See Codec contract for durable serialisation. |
| 38 | Should DeliveryReport remain as a per-destination return value? |
No — removed. Operational state lives in the runner; observability comes from the runner's backend (TaskHub, Foundry durable tasks, structured log fields on InProcessTaskRunner). The host's internal _deliver_response helper now returns bool (whether any work was scheduled / delivered) for the originating channel's own bookkeeping. Removing the value type collapses the public surface and removes a coupling point that would have needed a "schedule-time failure" subtype to round-trip durable failures back to the caller — failures live where they originate (the runner), not on a parallel object passed back through the synchronous return. |
| 39 | How is double-echo avoided when a push task retries after the echo phase succeeded but the response phase failed? | An echo_done cursor on the runner-owned task state. When echo_input=True, the "hosting.push" handler packages both the echo (role="user") and the assistant reply into the same task; on the first attempt the handler dispatches the echo, sets echo_done=True on the task state, and then dispatches the reply. A retry that fires after the echo succeeded but the reply failed reads the cursor and short-circuits the echo phase. The cursor lives in the runner — same principle as the broader "intent only on the message, operational state in the runner" rule. See Echo idempotency on retry. |
| 40 | What happens to in-flight "hosting.push" tasks on a clean InProcessTaskRunner shutdown? |
Two-phase drain. A shutdown_grace_seconds window (default 5.0) lets in-flight retries finish; remaining tasks are then cancelled and CancelledError is swallowed (not logged as a failure — it is the expected shutdown shape). Operators with longer worst-case retry chains can extend the grace via the constructor. Durable adapters get equivalent behaviour from their backends. See In-process runner shutdown drain. |
Decisions-driven follow-ups
The following resolutions imply prose / API edits elsewhere in the spec body (not just the table above). Captured here so they aren't lost; the edits themselves are deferred to a separate pass.
- Q2 — Switch all install /
host.serve()references fromuvicorntohypercorn. - Q3 — ✅ Done.
HostedRunResult[TResult]is now generic over the target output type; see Q31 below for the rationale. - Q11 — Strip any remaining "multi-target hedge" language from the spec body.
- Q13 — Update the linker catalogue: Entra (in Entra package) + one-time-code (in core); remove MFA references.
- Q16 — Collapse
IdentityLinkerinto a Channel specialisation in the spec body (architecture diagrams, contracts, examples). - Q20 — ✅ Done.
ContinuationTokentype carries an opaquetoken: str; routes use/{continuation_token}; Invocations channel gets equivalent continuation-token support; persistence viaHostStateStore(v1 default file-based). - Q32 — Spec text added (see Authorization profiles and the IdentityAllowlist seam and req #22). The core PR includes
IdentityAllowlistProtocol,AllowlistDecisionenum,AuthorizationContext,AllowAll/NativeIdAllowlist/LinkedClaimAllowlist/AnyOfAllowlists/AllOfAllowlists/CallableAllowlistbuilt-ins,IdentityLinkerProtocol,LinkedIdentity,LinkChallenge,AuthPolicyfactories,Allowed/LinkRequired/Deniedoutcomes,Host(default_allowlist=..., identity_linker=...)+ per-channelallowlistparameter, construction-time validator (rules #1 + #2 + #3 —require_link=Truewithoutidentity_linkernow raises), andhost.authorize(...)for open, native-id, and linked-claim profiles. Provider-specific linkers (for example Entra OAuth helpers) are separate channel/helper packages. - Q36 / Q37 / Q38 / Q39 / Q40 — Spec text added: strict-ephemeral default +
allow_in_process_runneropt-in in §Durable task runner; new sub-sections Codec contract for durable serialisation, In-process runner shutdown drain, Echo idempotency on retry;DeliveryReportreferences purged from §Intended targets + durable delivery and Qs 18 / 21. Code lands in this core PR:DurableTaskPayloadMode+ChannelPushCodec+PushPayloadNotSerializableexception in_types.py;_validate_runner_codec_pairing+ dual-mode_handle_push_task+_build_push_payload+echo_donecursor +_annotate_intended_targetsin_host.py;shutdown_grace_seconds+ 2-phase drain in_runner.py. - Q33 / Q34 / Q35 — Spec text added: new top-level §Runtime modes, rewritten §Intended targets + durable delivery, new §Durable task runner. Code lands in this core PR:
DurableTaskRunnerProtocol +InProcessTaskRunner+runtime_modeconstructor parameter + auto-detection. Durable runner adapters (agent-framework-hosting-durabletask, Foundry adapter) are separate follow-up packages tracked under §Decisions-driven follow-ups. Bumping req #14 (background runs) to share the same runner is a non-goal of this PR — theContinuationTokenmachinery and the runner can be wired together in a later pass without re-shaping either contract.