Microsoft Teams Connector
Native Microsoft Teams bot connector that bridges Teams messages to agenticore’s completions engine in-process — the third transport on the shared ProgressSink pipeline, alongside the Telegram connector and the SSE /v1/chat/completions path.
Unlike Telegram (outbound long-polling), Teams is push-based: the Bot Framework Connector service POSTs activities to a public HTTPS endpoint. The connector therefore exposes an inbound route rather than a polling loop.
Enable
Set environment variables:
TEAMS_APP_ID=<bot Entra application (client) id>
TEAMS_APP_PASSWORD=<bot client credential> # referenced by env only
Optional:
TEAMS_OWNER_AAD_ID=<Entra object id> # restrict to one user (owner filter);
# unset = any authenticated user
TEAMS_TENANT_ID=<tenant id> # single-tenant bots; omit for multi-tenant
TEAMS_SYSTEM_PROMPT=<custom system prompt>
TEAMS_FORMATTING_HINT=<override> # Teams-surface formatting guidance added
# to the system prompt; set empty to disable
TEAMS_MAX_MESSAGES=20 # conversation history depth
TEAMS_CONVERSATION_TTL=86400 # history TTL in seconds
TEAMS_SKIP_JWT_VALIDATION=1 # LOCAL DEV ONLY — skip inbound JWT check
When TEAMS_APP_ID + TEAMS_APP_PASSWORD are set, the ASGI app registers POST /api/messages at startup. Point your Azure Bot resource’s messaging endpoint at https://<host>/api/messages.
Progress visibility (Reasoning / Tool call / Tool result messages) is controlled by the sticky stream_config, scoped per conversation ({AGENTIHUB_AGENT}:teams:{conversation_id}) so a /hide-tools in one chat is independent of the SSE/Telegram transports and of every other Teams chat of the same bot. Send /show-thinking, /hide-tools, /show-all, etc. to the bot to toggle for that conversation only. See SSE streaming for the full token list.
A Teams-surface formatting hint is appended to the system prompt by default, steering the model away from markdown that Teams mobile can’t render (headings, tables, list markers). Override it with TEAMS_FORMATTING_HINT, or set that env var empty to disable it.
Why titled messages, not a reasoning panel
Teams has no native reasoning/thinking panel anywhere in the platform (unlike LibreChat/OpenWebUI, which render reasoning_content over SSE automatically). So this connector renders intermediate agent activity as ordinary titled chat messages, gated by the visibility flags:
| Canonical event | Rendered as |
|---|---|
narration | posted as-is (the model’s interleaved text) |
thinking | **Reasoning:**\n<text> |
tool_call | **Tool call:** <name> + a fenced args preview |
tool_result | **Tool result[ (error)]:** + a fenced (truncated) body |
final | the answer, as its own message(s) |
error | **Error:** <message> |
thinking and narration arrive as many small deltas. The sink coalesces each block into one message (flushed on a block boundary or when the buffer passes _FLUSH_CHARS), so it never posts one message per token — Teams caps sends per conversation per hour.
Architecture
agenticore/connectors/teams.py
│
├── is_enabled() — env gate (TEAMS_APP_ID + TEAMS_APP_PASSWORD)
├── authenticate_request() — validate the inbound Bot Framework JWT
│ (PyJWT + JWKS; no botbuilder SDK)
├── handle_activity() — per-message core: owner filter → resolve
│ stream_config → typing → sink → completions
├── TeamsProgressSink — ProgressSink → titled Teams messages
├── TeamsClient — Bot Connector REST (httpx + OAuth2 token)
├── ConversationStore — per-conversation history
└── ConversationRef — serviceUrl + ids + bot/user accounts
server.py registers POST /api/messages on the existing ASGI app (no second listener). The handler validates the JWT, then dispatches to handle_activity.
Auth (no heavy SDK)
- Inbound: the Bot Framework JWT is validated with
PyJWT+PyJWKClientagainst the Bot Framework OpenID metadata — issuerhttps://api.botframework.com, audience = the bot app id, RS256, with aserviceurl-claim cross-check. BothPyJWT[crypto]andhttpxare already core agenticore dependencies, so the connector needs no optional extra (unlike Telegram’saiogram). - Outbound: a bot access token is acquired via OAuth2 client-credentials (
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token, scopehttps://api.botframework.com/.default), cached until shortly before expiry, and used toPOST {serviceUrl}/v3/conversations/{id}/activities.
Scope (v1)
- 1:1 DMs only. Owner-filtered on
from.aadObjectId. - Final answer and intermediate events are sent as normal messages.
- Not yet: native Teams token streaming (the
streaminfoprotocol), channels/group chats, Adaptive Card tool UI. These are planned v2.
Concurrency
Turns are serialized per conversation by an in-process lock — a second message that arrives while a turn is running gets a short “still working” reply rather than starting a colliding turn (concurrent turns in one conversation would share a Claude --resume session and Redis event stream).
Agent-mode conversation turns intentionally do not acquire the MAX_PARALLEL_JOBS semaphore. That gate governs worktree/dispatch jobs; interactive conversation turns are bounded per conversation as above, matching the Telegram connector and the agent-mode REST path.
Limitations
- Commercial Azure cloud only. Sovereign clouds (GCC High / DoD) use a different login authority (
login.microsoftonline.us), Bot Connector resource (api.botframework.us), and service hosts (botframework.azure.us); the token endpoint, JWT issuer, andserviceUrlallowlist here accept none of them. Gov-cloud support is out of scope. - Teams mobile markdown. Teams mobile does not render markdown headings, tables, or list markers. The connector appends a formatting hint to the system prompt (see
TEAMS_FORMATTING_HINT) to steer the model toward paragraphs, bold, and fenced code — it does not post-process the model’s output.
Out of scope (operator-owned)
Azure Bot resource creation, Entra app registration, and public HTTPS ingress exposure are handled outside agenticore. The connector only consumes /api/messages and validates the JWT.
Testing
tests/unit/test_teams_connector.py covers the sink event→message mapping, delta coalescing, the owner filter, the env-gate, and the handle_activity command paths with a faked Bot Connector client (no network).