microsoft / microsoft/teams.py
Support hosting the Teams endpoint inside a caller-owned ASGI app (sync route registration, host owns lifecycle)
@heyitsaamir is already working on this.
Since Jun 3, 2026.
- Dominant language
- Python
- Stars
- 65
- Forks
- 30
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 23
Description
## 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:
1. Reuse the SDK's JWT validation, activity parsing, dispatch, and streaming (we don't want to reimplement Bot Framework auth).
2. **Not** let the SDK own the server — the host binds the port and runs uvicorn.
3. 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:
```python
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.