Component
forge-plugins (channel plugins, markdown converter)
Scope
Large (new module / architectural change)
Problem statement
The Phase 4 design previously removed MS Teams support because the standard Bot Framework integration requires a public HTTPS endpoint registered with Azure Bot Service and inbound webhook delivery — which violates Forge's first design principle: outbound-only, no public URLs, no tunnels, no inbound webhooks.
Two facts have since changed the analysis:
- Microsoft Graph
chats/getAllMessages/delta enables outbound-only polling — same shape as Telegram long-polling, no inbound endpoint required.
- Teams APIs are no longer metered. As of 2025-08-25, Teams APIs (including
getAllMessages/delta) are no longer billed per-message; the model=A/B query parameter is ignored; no Azure billing subscription required. This removes the previous per-message cost blocker.
The polling approach is therefore additive to the architecture, not a violation of it. The Slack/Telegram outbound-only invariant is preserved.
Proposed solution
Add a first-party MS Teams channel adapter at forge-plugins/channels/msteams/ that authenticates as a single Teams user via OAuth2 (delegated refresh-token flow or client credentials), polls /users/{id}/chats/getAllMessages/delta on a 5-second cadence, dispatches mentions / DMs to the A2A router, and posts replies via POST /chats/{id}/messages.
One-sentence operational model:
An Entra ID app authenticates as a single Teams user (the "agent identity"), polls Graph delta on 5s cadence, dispatches new mention/DM messages to the A2A router, and posts replies via Graph.
Architecture decision: what this is NOT
- ❌ Bot Framework activity handlers /
/api/messages inbound webhook
- ❌ Azure Bot Service registration
- ❌ Adaptive Cards (text + HTML subset only)
- ❌ Channel messages in Teams (this targets chats: 1:1, group, meeting chats — not channels inside teams; the v1.0 channels endpoint has a known Microsoft bug with
@odata.nextLink calls)
- ❌ Application permissions / multi-tenant (delegated permissions only — single Entra ID app, single user inbox)
File changes
| Path |
Action |
Purpose |
forge-plugins/channels/msteams/msteams.go |
CREATE |
Adapter implementing ChannelPlugin. Owns polling loop, delta cursor, message dispatch. |
forge-plugins/channels/msteams/auth.go |
CREATE |
Graph OAuth2 token acquisition (delegated refresh-token + client credentials flows). |
forge-plugins/channels/msteams/graph.go |
CREATE |
Typed Graph API client: getAllMessages/delta, POST chats/{id}/messages, /me. |
forge-plugins/channels/msteams/dedup.go |
CREATE |
Message-ID dedup ring (sliding window of last 1000 IDs). |
forge-plugins/channels/msteams/admission.go |
CREATE |
Mention/DM admission logic + self-loop guard (mirrors Slack admitBotEvent). |
forge-plugins/channels/msteams/cursor.go |
CREATE |
Delta cursor persistence (.forge/channels/msteams-cursor.json, atomic rename). |
forge-plugins/channels/msteams/msteams_test.go |
CREATE |
Unit tests with httptest.Server stub for Graph. |
forge-plugins/channels/markdown/teams.go |
CREATE |
Markdown → Teams HTML subset + plain-text extraction + 24 KB split. |
forge-cli/cmd/channel_msteams.go |
CREATE |
forge channel add msteams interactive setup wizard. |
forge-cli/cmd/serve.go, run.go |
EDIT |
Add msteams to the --with allowlist. |
forge-core/security/capabilities.go |
EDIT |
Add msteams capability bundle: graph.microsoft.com, login.microsoftonline.com. |
templates/msteams-config.yaml.tmpl |
CREATE |
Channel config template emitted by the wizard. |
docs/channels/msteams.md |
CREATE |
User-facing setup guide. |
FORGE_PROJECT_DESIGN.md |
EDIT |
Channel Connectors table row; remove any prior "Teams not implemented" note. |
Zero edits required to: k8s_stage.go, requirements_stage.go, template_data.go, K8s manifest templates, cmd/package.go, forge-cli/build/channels_stage.go, forge-core/channels/env.go. The existing ChannelsStage design (PR #54) handles new channels through config files alone — msteams-config.yaml's _env-suffix settings are auto-discovered.
Config surface — msteams-config.yaml
Emitted by forge channel add msteams:
adapter: msteams
settings:
tenant_id_env: MSTEAMS_TENANT_ID
client_id_env: MSTEAMS_CLIENT_ID
client_secret_env: MSTEAMS_CLIENT_SECRET
auth_flow: delegated # \"delegated\" (default) or \"client_credentials\"
refresh_token_env: MSTEAMS_REFRESH_TOKEN # delegated flow only
user_id_env: MSTEAMS_USER_ID
# graph_base_url_env: MSTEAMS_GRAPH_BASE_URL # sovereign clouds (US Gov, China)
poll_interval_seconds: 5 # floor 3, default 5, ceiling 60
admit: mention_or_dm # \"mention\" / \"dm\" / \"mention_or_dm\"
allow_bot_ids: []
forge.yaml surface stays minimal — identical to Slack/Telegram:
Tool / behaviour catalog (per-method spec)
Start() sequence:
- Resolve config envs via shared
channels.ResolveEnvVars
- Acquire access token via
auth.Manager (delegated or client_credentials)
GET /me (delegated) or /users/{userID} (client_creds) → cache ownUserID + ownDisplay
- Load
.forge/channels/msteams-cursor.json or initialise from $filter=lastModifiedDateTime gt <now>
- Start poll goroutine
Poll loop:
- Follow
@odata.nextLink to drain the current batch in one tick (no mid-batch sleep)
- Persist
@odata.deltaLink (caught-up cursor) — not nextLink (pagination state)
- Inflight dedup ring tolerates restart mid-batch
handleMessage() admission order (mirrors Slack):
- Self-loop guard (
from.user.id == ownUserID) — hard rule, beats allowlist
- Dedup
- Bot admission (
from.application non-nil → must be in allow_bot_ids)
- Mode filter (
mention / dm / mention_or_dm)
- HTML → plain (
TeamsHTMLToPlain) for the LLM
- Strip own @-mention from prompt
- Dispatch via
router.Dispatch
SendResponse():
- Body ≤ 24 KB →
PostChatMessage with contentType: \"html\"
- Body > 24 KB →
SplitSummaryAndReport + hosted-content attachment (research-report.md)
- Attachment fails → fall back to chunked sends
Markdown converter (new forge-plugins/channels/markdown/teams.go)
Mirrors the Telegram/Slack pattern already in the package:
func MarkdownToTeamsHTML(md string) string
func TeamsHTMLToPlain(html string) string
func SplitMessageTeams(text string) []string // 24 KB cap (Teams limit ~28 KB; leave headroom for HTML tags)
func ExtractMention(body string, mentions []TeamsMention, userID string) bool
Conversion table covered: bold, italic, inline code, fenced code, links, ordered/unordered lists, headings, @mentions, blockquotes.
Error handling
| Graph status |
Behaviour |
401 |
Force auth.Refresh(), retry once. Still 401 → 60s backoff. |
403 |
Log with docs/channels/msteams.md remediation hint. 60s backoff. |
429 |
Respect Retry-After. Min 10s, max 300s. |
410 Gone (delta cursor expired) |
Critical — discard cursor, re-init from $filter=lastModifiedDateTime gt <now>. WARN log. Must never crash. |
5xx / network |
Exponential backoff 5s → 10s → 20s → 40s → 60s capped; reset on first success. |
400 on nextLink (known DeltaToken bug) |
ERROR + URL log, fall back to re-init from "now". |
Capability bundle
forge-core/security/capabilities.go:
\"msteams\": {
\"graph.microsoft.com\",
\"login.microsoftonline.com\",
},
Sovereign clouds (US Gov: graph.microsoft.us, login.microsoftonline.us / China: microsoftgraph.chinacloudapi.cn, login.chinacloudapi.cn) stay out of the default bundle — operators add them via egress.allowed_domains to avoid widening the surface for the 99% of users on commercial cloud.
Attachment uploads (hosted contents)
Teams has no files.upload equivalent for chats. Decision: use hosted contents (inline base64 in attachments[].contentUrl), not OneDrive uploads. Rationale: OneDrive would require Files.ReadWrite scope + *.sharepoint.com / *.onedrive.com egress + extra consent. Hosted contents keep the egress surface to two domains.
4 MB cap per message. Beyond that → fall back to chunked text. Documented in user guide.
CLI wizard — forge channel add msteams
Mirrors forge channel add slack UX (same bubbletea framework, same step component pattern):
- Welcome panel — link to
docs/channels/msteams.md for Entra app registration steps
- Tenant ID (GUID validation)
- Client ID (GUID validation)
- Client secret (hidden input)
- Auth flow radio:
delegated (default) / client_credentials
- Delegated path: device-code flow against
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode, poll until user completes consent, capture refresh token
- Client-creds path: prompt for
MSTEAMS_USER_ID (the agent user's objectId)
- Validate live:
GET /me or /users/{userID}, display displayName + userPrincipalName for confirmation
- Write
.env + msteams-config.yaml + add msteams to channels: in forge.yaml. Print egress domains being added.
Anti-patterns (do NOT do)
| ❌ Anti-pattern |
✅ Correct approach |
Wrap Microsoft Bot Framework SDK / bf-cli |
Direct HTTP to graph.microsoft.com via net/http + EgressEnforcer |
| Expose any inbound port for Teams webhooks |
Outbound HTTPS only. Adapter binds nothing. |
| Hardcode the Graph base URL |
Read from graph_base_url_env so sovereign clouds work |
Store client secret or refresh token in forge.yaml |
Secrets in .env (local) or K8s Secret (container). Config holds env-var names with _env suffix. |
Use model=A / model=B query parameters |
Metering era ended 2025-08-25. The model param is ignored. Omit it. |
Poll getAllMessages (non-delta) on every tick |
Use getAllMessages/delta with persisted @odata.deltaLink. Non-delta is first-run only. |
| Log the access token, refresh token, or full message bodies |
Redact tokens. Body content at DEBUG only, never INFO. |
| Forward all received messages to the agent |
Filter by mention OR DM-chat. Drop self-authored. |
| Treat Teams body as markdown |
Teams returns HTML in body.content when contentType: \"html\" — strip and convert to plain |
| Reuse Telegram's 4096-char split |
Teams enforces ~28 KB body limit. Use 24 KB threshold to leave HTML-tag headroom. |
Call /me on every poll |
Call once at startup; cache userID + displayName for adapter lifetime |
| Embed tenant ID in OAuth authority as a literal |
Read from tenant_id_env; keep the multi-tenant seam clean |
Done criteria
Phase A — Skeleton compiles:
Phase B — Graph client + auth:
Phase C — Markdown converter:
Phase D — Admission + dedup:
Phase E — End-to-end against a real tenant (manual):
Phase F — Egress conformance:
Open questions (for resolution before implementation)
- Delegated vs client credentials default? Recommendation:
delegated. Lowest-friction onboarding via device-code flow; most users won't have admin consent rights.
- Mention-strip behaviour: strip
@AgentName text from the prompt before LLM, or pass through? Recommendation: strip (Slack passes through, but Teams renders mentions as <at id=\"0\">…</at> — the literal display name doesn't help the LLM).
- Schedule delivery target format:
ChannelTarget is the Graph chat.id (URL-safe string like 19:meeting_XXX@thread.v2). Confirm the scheduler prompt handles arbitrary string targets (it should, but verify).
msteams-channels (in-team channel messages) as follow-up? Recommendation: yes. The v1.0 /teams/{id}/channels/{id}/messages/delta endpoint has the known DeltaToken bug. Ship chat-only first; team-channel polling is a separate workstream blocked on the Microsoft bug.
- Telemetry: emit a new
channel_poll audit event? Recommendation: no — egress_allowed already covers per-call observability.
Alternatives considered
- Microsoft Bot Framework webhooks — rejected: requires public HTTPS endpoint + Azure Bot Service registration; violates outbound-only invariant.
- Subscription-based change notifications (
/subscriptions resource) — also requires a public endpoint for delivery, plus subscription renewal management. Same architectural objection.
- OneDrive attachment uploads — rejected for v1: requires
Files.ReadWrite scope, two extra egress domains, additional consent step. Hosted contents keep the surface minimal.
Out of scope (deferred)
- Channels-in-teams (
/teams/{id}/channels/{id}) polling — blocked on Microsoft bug, separate workstream
- Adaptive Cards — Phase 2 enhancement once text path is stable
- Multi-tenant single-adapter support — current design is single-tenant per adapter; multi-tenant lives at the Forge Hub layer
- Teams meeting transcript/recording APIs (require M365 Copilot license, separate metered model)
- Reaction events (
reactionAdded, etc.) — delta returns updated reactions but use case isn't established
- File attachments received from users (only outbound attachments in scope for v1)
- GCC High / DoD clouds —
MSTEAMS_GRAPH_BASE_URL override is the seam; explicit testing is out of scope
Reference patterns
Mirror these existing adapters (do not modify them):
Estimated effort
1.5–2 weeks. The bulk of complexity is in the Graph client + OAuth flows + wizard UX; the adapter shell, admission logic, and markdown converter are mechanical mirrors of existing patterns.
Design doc update (post-implementation)
FORGE_PROJECT_DESIGN.md:
- Channel Connectors table — add row:
MS Teams | Graph polling | 3002 | Outbound HTTPS poll on /chats/getAllMessages/delta. Delegated OAuth2, mention/DM admission, HTML body conversion, hosted-content attachments.
- Instruction Documents table — add row for
FORGE_MSTEAMS_CHANNEL_GRAPH_POLLING.md as the source spec for this feature
- What's NOT Implemented section — remove any prior Teams entry
Component
forge-plugins (channel plugins, markdown converter)
Scope
Large (new module / architectural change)
Problem statement
The Phase 4 design previously removed MS Teams support because the standard Bot Framework integration requires a public HTTPS endpoint registered with Azure Bot Service and inbound webhook delivery — which violates Forge's first design principle: outbound-only, no public URLs, no tunnels, no inbound webhooks.
Two facts have since changed the analysis:
chats/getAllMessages/deltaenables outbound-only polling — same shape as Telegram long-polling, no inbound endpoint required.getAllMessages/delta) are no longer billed per-message; themodel=A/Bquery parameter is ignored; no Azure billing subscription required. This removes the previous per-message cost blocker.The polling approach is therefore additive to the architecture, not a violation of it. The Slack/Telegram outbound-only invariant is preserved.
Proposed solution
Add a first-party MS Teams channel adapter at
forge-plugins/channels/msteams/that authenticates as a single Teams user via OAuth2 (delegated refresh-token flow or client credentials), polls/users/{id}/chats/getAllMessages/deltaon a 5-second cadence, dispatches mentions / DMs to the A2A router, and posts replies viaPOST /chats/{id}/messages.One-sentence operational model:
Architecture decision: what this is NOT
/api/messagesinbound webhook@odata.nextLinkcalls)File changes
forge-plugins/channels/msteams/msteams.goChannelPlugin. Owns polling loop, delta cursor, message dispatch.forge-plugins/channels/msteams/auth.goforge-plugins/channels/msteams/graph.gogetAllMessages/delta,POST chats/{id}/messages,/me.forge-plugins/channels/msteams/dedup.goforge-plugins/channels/msteams/admission.goadmitBotEvent).forge-plugins/channels/msteams/cursor.go.forge/channels/msteams-cursor.json, atomic rename).forge-plugins/channels/msteams/msteams_test.gohttptest.Serverstub for Graph.forge-plugins/channels/markdown/teams.goforge-cli/cmd/channel_msteams.goforge channel add msteamsinteractive setup wizard.forge-cli/cmd/serve.go,run.gomsteamsto the--withallowlist.forge-core/security/capabilities.gomsteamscapability bundle:graph.microsoft.com,login.microsoftonline.com.templates/msteams-config.yaml.tmpldocs/channels/msteams.mdFORGE_PROJECT_DESIGN.mdZero edits required to:
k8s_stage.go,requirements_stage.go,template_data.go, K8s manifest templates,cmd/package.go,forge-cli/build/channels_stage.go,forge-core/channels/env.go. The existingChannelsStagedesign (PR #54) handles new channels through config files alone —msteams-config.yaml's_env-suffix settings are auto-discovered.Config surface —
msteams-config.yamlEmitted by
forge channel add msteams:forge.yamlsurface stays minimal — identical to Slack/Telegram:Tool / behaviour catalog (per-method spec)
Start()sequence:channels.ResolveEnvVarsauth.Manager(delegated or client_credentials)GET /me(delegated) or/users/{userID}(client_creds) → cacheownUserID+ownDisplay.forge/channels/msteams-cursor.jsonor initialise from$filter=lastModifiedDateTime gt <now>Poll loop:
@odata.nextLinkto drain the current batch in one tick (no mid-batch sleep)@odata.deltaLink(caught-up cursor) — notnextLink(pagination state)handleMessage()admission order (mirrors Slack):from.user.id == ownUserID) — hard rule, beats allowlistfrom.applicationnon-nil → must be inallow_bot_ids)mention/dm/mention_or_dm)TeamsHTMLToPlain) for the LLMrouter.DispatchSendResponse():PostChatMessagewithcontentType: \"html\"SplitSummaryAndReport+ hosted-content attachment (research-report.md)Markdown converter (new
forge-plugins/channels/markdown/teams.go)Mirrors the Telegram/Slack pattern already in the package:
Conversion table covered: bold, italic, inline code, fenced code, links, ordered/unordered lists, headings,
@mentions, blockquotes.Error handling
401auth.Refresh(), retry once. Still 401 → 60s backoff.403docs/channels/msteams.mdremediation hint. 60s backoff.429Retry-After. Min 10s, max 300s.410 Gone(delta cursor expired)$filter=lastModifiedDateTime gt <now>. WARN log. Must never crash.5xx/ network400on nextLink (known DeltaToken bug)Capability bundle
forge-core/security/capabilities.go:Sovereign clouds (US Gov:
graph.microsoft.us,login.microsoftonline.us/ China:microsoftgraph.chinacloudapi.cn,login.chinacloudapi.cn) stay out of the default bundle — operators add them viaegress.allowed_domainsto avoid widening the surface for the 99% of users on commercial cloud.Attachment uploads (hosted contents)
Teams has no
files.uploadequivalent for chats. Decision: use hosted contents (inline base64 inattachments[].contentUrl), not OneDrive uploads. Rationale: OneDrive would requireFiles.ReadWritescope +*.sharepoint.com/*.onedrive.comegress + extra consent. Hosted contents keep the egress surface to two domains.4 MB cap per message. Beyond that → fall back to chunked text. Documented in user guide.
CLI wizard —
forge channel add msteamsMirrors
forge channel add slackUX (same bubbletea framework, same step component pattern):docs/channels/msteams.mdfor Entra app registration stepsdelegated(default) /client_credentialshttps://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode, poll until user completes consent, capture refresh tokenMSTEAMS_USER_ID(the agent user's objectId)GET /meor/users/{userID}, displaydisplayName+userPrincipalNamefor confirmation.env+msteams-config.yaml+ addmsteamstochannels:inforge.yaml. Print egress domains being added.Anti-patterns (do NOT do)
bf-cligraph.microsoft.comvianet/http+EgressEnforcergraph_base_url_envso sovereign clouds workforge.yaml.env(local) or K8sSecret(container). Config holds env-var names with_envsuffix.model=A/model=Bquery parametersmodelparam is ignored. Omit it.getAllMessages(non-delta) on every tickgetAllMessages/deltawith persisted@odata.deltaLink. Non-delta is first-run only.body.contentwhencontentType: \"html\"— strip and convert to plain/meon every polluserID+displayNamefor adapter lifetimetenant_id_env; keep the multi-tenant seam cleanDone criteria
Phase A — Skeleton compiles:
cd forge-plugins && go build ./channels/msteams/...ChannelPlugin; no-opStart/Stop/SendResponsePhase B — Graph client + auth:
TestGraph_*—httptest.Serverstub covers/me, delta paging, all four error-class branches (401/403/429/410)TestAuth_*— delegated refresh-token flow, client_credentials flow, refresh-token rotation persisted viasecrets.StorePhase C — Markdown converter:
TestTeams_*— round-trip MD → HTML → plain for every row of the conversion tablePhase D — Admission + dedup:
TestAdmission_*— each row of the admission flow tested; self-loop guard fires before allowlistTestDedup_*— ring holds exactly 1000; evicts oldest firstPhase E — End-to-end against a real tenant (manual):
forge channel add msteamswizard completes; tenant resolves; refresh token capturedforge run --with msteams— DM the agent → response arrives within ~5s@AgentName what's up→ response arrivesforge package --prod→ K8s manifest emitsMSTEAMS_*env vars insecretKeyRefform (via existingChannelsStagefrom PR fix: inject channel env vars into K8s manifests (closes #50) #54)Phase F — Egress conformance:
forge run --with msteams 2>&1 | grep egress_allowed | jq -r .fields.domain | sort -u→ onlygraph.microsoft.com+login.microsoftonline.com. No other domains.Open questions (for resolution before implementation)
delegated. Lowest-friction onboarding via device-code flow; most users won't have admin consent rights.@AgentNametext from the prompt before LLM, or pass through? Recommendation: strip (Slack passes through, but Teams renders mentions as<at id=\"0\">…</at>— the literal display name doesn't help the LLM).ChannelTargetis the Graphchat.id(URL-safe string like19:meeting_XXX@thread.v2). Confirm the scheduler prompt handles arbitrary string targets (it should, but verify).msteams-channels(in-team channel messages) as follow-up? Recommendation: yes. The v1.0/teams/{id}/channels/{id}/messages/deltaendpoint has the known DeltaToken bug. Ship chat-only first; team-channel polling is a separate workstream blocked on the Microsoft bug.channel_pollaudit event? Recommendation: no —egress_allowedalready covers per-call observability.Alternatives considered
/subscriptionsresource) — also requires a public endpoint for delivery, plus subscription renewal management. Same architectural objection.Files.ReadWritescope, two extra egress domains, additional consent step. Hosted contents keep the surface minimal.Out of scope (deferred)
/teams/{id}/channels/{id}) polling — blocked on Microsoft bug, separate workstreamreactionAdded, etc.) — delta returns updated reactions but use case isn't establishedMSTEAMS_GRAPH_BASE_URLoverride is the seam; explicit testing is out of scopeReference patterns
Mirror these existing adapters (do not modify them):
forge-plugins/channels/slack/slack.go— admission flow,admitBotEvent, self-loop guard (PR feat(slack): admit bot @mentions via allow_bot_ids, drop self always (closes #55) #56)forge-plugins/channels/telegram/telegram.go— polling-loop shape, env-var resolution, large-response splitforge-cli/build/channels_stage.go— per-channel YAML_env-suffix scanner, no changes needed (PR fix: inject channel env vars into K8s manifests (closes #50) #54)forge-plugins/channels/markdown/{telegram,slack}.go— converter pattern that the newteams.gomirrorsforge-plugins/channels/slack/slack.goSendResponse+extractLargestFile— large-response handling and the newa2a.Message.Summaryfield from PR feat(channels): LLM-generated summary for large responses (closes #64) #65Estimated effort
1.5–2 weeks. The bulk of complexity is in the Graph client + OAuth flows + wizard UX; the adapter shell, admission logic, and markdown converter are mechanical mirrors of existing patterns.
Design doc update (post-implementation)
FORGE_PROJECT_DESIGN.md:FORGE_MSTEAMS_CHANNEL_GRAPH_POLLING.mdas the source spec for this feature