Summary
Please provide a first-class, supported way to mount the Teams messaging endpoint onto an externally-owned ASGI app, where the host owns the server lifecycle and route registration is available synchronously (decoupled from async plugin initialization).
Today the SDK is designed around the App owning (or at least driving) the HTTP server through an HttpServerAdapter. There is a "bring your own Starlette" pattern in examples/http-adapters/src/starlette_adapter.py, but it only works by calling the async App.initialize() to register routes, and the example adapter is not shipped in the package. That makes clean integration into a host that already owns the ASGI app, the port, and the startup/shutdown lifecycle surprisingly hard.
Motivation / use case
We are building a multi-channel agent host (the Microsoft Agent Framework) where a single ASGI/Starlette application is owned by the host and serves many channels behind one public URL (Bot Service, Telegram, Discord, A2A, MCP, ...). Each channel contributes routes + startup/shutdown hooks; the host mounts them all and owns binding/serving.
To embed Teams there we want to:
- Reuse the SDK's JWT validation, activity parsing, dispatch, and streaming (we don't want to reimplement Bot Framework auth).
- Not let the SDK own the server — the host binds the port and runs uvicorn.
- Obtain the registered routes synchronously at "contribution" time (before the event loop starts serving), because the host needs the route table up front, while async plugin
on_init work must run later at startup.
What we had to do today (and why it's painful)
Because route registration currently happens inside HttpServer.initialize(...) which is only invoked from the async App.initialize(), and because the only Starlette adapter is an example (not importable), we ended up:
- Writing a custom capturing
HttpServerAdapter whose register_route just records (method, path) -> handler, and whose serve_static / start / stop are no-ops (host owns those).
- Calling the private
app.server.initialize(...) ourselves to force synchronous route registration.
- Manually wiring
app.server.on_request = app._process_activity_event (private) because that wiring normally happens inside App.initialize(), which we deliberately skip at route-collection time.
- Re-implementing the Starlette↔
HttpRequest/HttpResponse translation, then calling the async App.initialize() later (at host startup) purely to run plugin on_init hooks — guarding against the SDK's private _initialized re-running the sync step.
This works but reaches into private internals (server._adapter, server.initialize, _process_activity_event, _initialized) and is fragile against minor SDK changes.
Proposed API (illustrative)
A public, lifecycle-free integration seam. For example, any of:
app.register_routes() -> list[tuple[HttpMethod, str, HttpRouteHandler]] — synchronously perform auth setup + route registration and return the route table, without owning a server and without running async plugin init.
- Split
App.initialize() so that the synchronous "register routes / wire dispatcher" step is a separate, public, sync-callable method from the async plugin on_init step (e.g. app.register_routes() (sync) + await app.start_plugins() (async)).
- A shipped (not example-only) ASGI/Starlette adapter that supports "bring your own app, host owns lifecycle", with route registration callable without
await app.start() and clearly separated from plugin init.
Concretely, the host wants something like:
app = App(client_id=..., client_secret=..., messaging_endpoint="/teams/messages")
routes = app.register_routes() # sync: auth + messaging endpoint, NO server, NO async plugin init
host.mount(routes) # host owns the ASGI app + port
# ... at host startup:
await app.start_plugins() # async: plugin on_init only; never binds a port
Acceptance criteria
- A documented, non-private way to obtain/register the Teams messaging route(s) onto a caller-owned ASGI app.
- Route registration callable synchronously (or at least separable from the async plugin-init step), so a host can build its route table before serving.
- The SDK never tries to bind a port / own start-stop in this mode.
- A shipped Starlette (and/or FastAPI) "bring your own app" adapter, so consumers don't copy the
examples/ adapter.
Happy to contribute a PR if the team is open to one of these shapes.
Summary
Please provide a first-class, supported way to mount the Teams messaging endpoint onto an externally-owned ASGI app, where the host owns the server lifecycle and route registration is available synchronously (decoupled from async plugin initialization).
Today the SDK is designed around the App owning (or at least driving) the HTTP server through an
HttpServerAdapter. There is a "bring your own Starlette" pattern inexamples/http-adapters/src/starlette_adapter.py, but it only works by calling the asyncApp.initialize()to register routes, and the example adapter is not shipped in the package. That makes clean integration into a host that already owns the ASGI app, the port, and the startup/shutdown lifecycle surprisingly hard.Motivation / use case
We are building a multi-channel agent host (the Microsoft Agent Framework) where a single ASGI/Starlette application is owned by the host and serves many channels behind one public URL (Bot Service, Telegram, Discord, A2A, MCP, ...). Each channel contributes routes + startup/shutdown hooks; the host mounts them all and owns binding/serving.
To embed Teams there we want to:
on_initwork must run later at startup.What we had to do today (and why it's painful)
Because route registration currently happens inside
HttpServer.initialize(...)which is only invoked from the asyncApp.initialize(), and because the only Starlette adapter is an example (not importable), we ended up:HttpServerAdapterwhoseregister_routejust records(method, path) -> handler, and whoseserve_static/start/stopare no-ops (host owns those).app.server.initialize(...)ourselves to force synchronous route registration.app.server.on_request = app._process_activity_event(private) because that wiring normally happens insideApp.initialize(), which we deliberately skip at route-collection time.HttpRequest/HttpResponsetranslation, then calling the asyncApp.initialize()later (at host startup) purely to run pluginon_inithooks — guarding against the SDK's private_initializedre-running the sync step.This works but reaches into private internals (
server._adapter,server.initialize,_process_activity_event,_initialized) and is fragile against minor SDK changes.Proposed API (illustrative)
A public, lifecycle-free integration seam. For example, any of:
app.register_routes() -> list[tuple[HttpMethod, str, HttpRouteHandler]]— synchronously perform auth setup + route registration and return the route table, without owning a server and without running async plugin init.App.initialize()so that the synchronous "register routes / wire dispatcher" step is a separate, public, sync-callable method from the async pluginon_initstep (e.g.app.register_routes()(sync) +await app.start_plugins()(async)).await app.start()and clearly separated from plugin init.Concretely, the host wants something like:
Acceptance criteria
examples/adapter.Happy to contribute a PR if the team is open to one of these shapes.