Skip to content

feat: Microsoft Teams connector#1518

Open
vuyiv wants to merge 34 commits into
chenhg5:mainfrom
lovelysystems:feature/teams-connector
Open

feat: Microsoft Teams connector#1518
vuyiv wants to merge 34 commits into
chenhg5:mainfrom
lovelysystems:feature/teams-connector

Conversation

@vuyiv

@vuyiv vuyiv commented Jul 9, 2026

Copy link
Copy Markdown

Users can now drive their coding agent from Microsoft Teams.

What

  • Adds a native Teams platform adapter that terminates the Bot Framework directly — inbound JWT validation, the outbound Bot Connector REST client (AAD client-credentials), and the Teams streaming-message protocol — with no Microsoft Go SDK dependency.
  • Streaming Adaptive Card replies (native "AI generated" label), @mention-to-engage with per-thread follow, session_scope, inbound 1:1 files/images, outbound inline images, and interactive permission / AskUserQuestion prompts as buttons.
  • Requires a public HTTPS webhook (inherent to Bot Framework) and a single-tenant Azure Bot; setup in docs/teams.md, config in config.example.toml.

Risks

  • Self-contained under platform/teams/ — no changes to core/ or other platforms, so blast radius is limited to the new adapter.
  • Operationally heavier than outbound-connection platforms: needs a reachable HTTPS endpoint and Azure Bot (single-tenant) setup.
Decisions
  • Outbound images ride inline as base64 data-URI attachments; the size limit is enforced by catching the Bot Connector's 413 and degrading to a notice, not a guessed byte cap — matches Microsoft's own SDK guidance. A host-a-URL path for very large images is deferred.
  • Permission / AskUserQuestion prompts render as standalone interactive cards, not folded into the streaming answer card — matches every other platform and avoids a mid-turn prompt being buried in the answer.
  • Single-tenant only (tenant_id required) — Azure deprecated multi-tenant bot creation after 2025-07-31; inbound JWT is restricted to the Bot Framework channel issuer.
  • The outbound serviceUrl is bound to the request's JWT claim, with an optional host allowlist for defense-in-depth.
AI Attribution
  • 32 of 33 commits co-authored with Claude.
  • Scope: the entire platform/teams/ connector, its tests, and docs.
Commit message
feat: Microsoft Teams connector (#1518)

- Native Bot Framework adapter: inbound JWT, outbound Bot Connector REST (AAD), streaming Adaptive Card protocol
- Streaming card replies with native AI label; @mention-to-engage; session_scope; inbound 1:1 media
- Outbound inline images (Bot-Connector-413-governed sizing); interactive permission/AskUserQuestion card buttons
- Public HTTPS webhook required; single-tenant

Co-Authored-By: Claude <noreply@anthropic.com>

#lovelyreviewdesc | base: a23fb90 | head: 0a6bfe7

vuyiv and others added 30 commits July 6, 2026 14:08
Add a Microsoft Teams platform connector as a Bot Framework bot:

- Inbound webhook with Bot Framework JWT + serviceURL-claim validation
  and an allow_from AAD-object-id allowlist.
- Outbound Bot Connector REST client with AAD client-credentials auth.
- Streaming Adaptive Card replies (working card -> answer, native
  "AI generated" label), rendering uniformly in channels and 1:1.
- Engagement: @mention engages a reply thread, then follows it without
  re-mentioning; messages @mentioning others-but-not-the-bot are ignored;
  the bot never acts on its own activity. Engagement persists across restarts.
- session_scope (thread default | channel | user) selects session grouping;
  channel scope keys on the channel root.
- Teams-Markdown FormattingInstructions; text-based permission prompts.

Contained entirely in platform/teams/ plus plugin registration, Makefile,
config.example, docs, and the JWT/OAuth2 deps — no core/ changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bound the AAD token fetch with a timeout-bearing HTTP client — it runs
  before the connector's own http timeout, so an unreachable login endpoint
  could otherwise hang a turn goroutine indefinitely.
- Give the webhook server a bounded shutdown context.
- Fix the self-message guard to match the bot's "28:<appId>" channelAccount
  form (the bare-appID compare never matched); harden its test accordingly.
- Remove dead fields: activity.ReplyToID and replyContext.sessionKey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Ack 202 and run the agent turn on a background goroutine (JWT validation
  stays synchronous before the ack). A slow turn under a synchronous ack could
  exceed Bot Framework's ~15s window and trigger a retry + duplicate dispatch;
  acking first matches the M365 Agents SDK's background-queue model.
- Add an optional service_url_allowlist (comma-separated hosts, empty = any
  JWT-validated host). When set, an activity whose serviceUrl host is off the
  list is dropped before the bot's bearer token is POSTed to it — defense in
  depth against a forged serviceURL. Host-based match so regional paths pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security hardening from the Fable security re-analysis:

- Accept only the Bot Framework channel issuer (api.botframework.com) for
  inbound activity tokens; drop the AAD tenant-issuer path. That path exists in
  the SDK for agent-to-agent/skill invocation, which this messaging connector
  does not offer. Restricting to the channel issuer keeps the activity `From`
  channel-signed, so allow_from and the serviceURL binding rest on a trusted
  identity instead of caller-supplied body fields (closes the spoof + fail-open
  residuals). tenant_id remains required for the outbound token authority.
- Cap concurrent async dispatch with a semaphore (maxConcurrentDispatch); at
  capacity the webhook sheds with 503 so the Bot Connector retries, rather than
  spawning unbounded agent turns — matching the SDK's bounded background queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… = none)

Make the placeholder-card label a config option instead of a hardcoded
constant. Empty (the default) renders a label-less card — no built-in fallback
string. config.example suggests "💭 Thinking…". Drops the defaultCardWorkingText
constant and the loadingCard empty-string fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add inboundAttachment parsing (file-download-info + image classification)
and an isPersonal() gate on the activity, so a 1:1 file/image can be
surfaced to the connector. Distinct from the outbound card attachment type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add connector.fetch (size-capped GET, optional bearer) + fetchOutcome so
inbound attachments can be downloaded best-effort; add max_attachment_bytes
config (default 20 MiB). The bearer token is attached only on request, never
to a pre-authed file downloadUrl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In a personal chat, download file/image attachments and populate
Message.Images/Files so the engine hands them to the agent; a file-only
message (empty text) now dispatches. Images fetch the bot token only when
the contentUrl shares the JWT-bound serviceURL host; data: URIs decode
inline. Channel/group attachments stay ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a 1:1 attachment download fails or exceeds max_attachment_bytes, reply
a brief notice instead of dropping it silently; the turn still proceeds with
whatever text and other attachments succeeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… cap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d media

Code-review follow-ups (both P3):
- A file-download attachment with an empty/unparseable downloadUrl is now
  counted as a failed download, so the user gets a notice instead of a
  silently dropped turn.
- Dispatch now gates on a processable attachment (file or image), so a 1:1
  message whose only attachment is an unhandled type no longer forces an
  empty-content turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…efault

- teams.md: native AI label is present from the first card render, not only at finalize
- config.example.toml: allow_from default is unset/empty (behaves like "*"), not "*"
- CLAUDE.md: add no_teams to the build-tag list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bot now sends images back to Teams as inline base64 attachments, threaded
to the originating activity. Oversize images degrade to a text notice
instead of failing the turn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gation test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eSender

Capture the non-obvious constraint surfaced this session: images/files reach
platforms only via the external send socket, never through agent reply Events,
and the 0600 daemon-owned socket blocks run_as_user agents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Permission and single-select AskUserQuestion prompts now render as
Action.Submit buttons folded into the live streaming card (or a standalone
card when none is active), instead of leaking as a separate plain-text message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbound Action.Submit perm:* values now map to allow/deny/allow-all text so
'allow all' is not mis-resolved as a one-time allow; askq:* forwarded verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SendImage now lets Teams define the size limit: it attempts the send and, on a
413 (errActivityTooLarge), degrades to the oversize notice. The former 1 MiB
notice-trigger became a generous 20 MiB pathological-size guard, so images Teams
would accept (observed working well above 1 MiB) are no longer pre-rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vuyiv and others added 3 commits July 9, 2026 12:01
…lded

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing folds prompts into the streaming card anymore, so the activeCards
registry, promptUpdate, and their lifecycle wiring are unused. Removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vuyiv vuyiv requested a review from chenhg5 as a code owner July 9, 2026 10:57
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant