Goal
Add a WhatsApp channel adapter to forge-plugins/channels/, outbound-only, following the same pattern as Telegram and Slack. It delivers agent responses out to WhatsApp (primarily scheduled-task results); it does not ingest inbound messages in v1. The closest existing precedent is the MS Teams adapter (#78 / PR #76), which is documented "outbound-only" — WhatsApp is even more one-directional (no polling either).
Provider: Meta WhatsApp Business Cloud API (Graph API) — POST https://graph.facebook.com/<version>/<PHONE_NUMBER_ID>/messages, Authorization: Bearer <ACCESS_TOKEN>.
Why outbound-only is a first-class fit
The channel contract already separates the two directions, and forge already drives a purely-outbound path that never touches inbound:
- Interface
ChannelPlugin — forge-core/channels/plugin.go:13-28: inbound = Start/NormalizeEvent, outbound = SendResponse. "Outbound-only" is an implementation property, not a separate interface.
- The scheduled-task delivery path calls
SendResponse with a synthetic ChannelEvent where only Channel + WorkspaceID (= the target) are set — no MessageID, no inbound origin:
- Runner fires the notifier:
forge-cli/runtime/runner.go:5009-5013 (if sched.Channel != "" && sched.ChannelTarget != "").
- Wiring:
forge-cli/cmd/run.go:326-336 builds &ChannelEvent{Channel, WorkspaceID: target} and calls plugin.SendResponse(...).
- Schedule carries the destination:
ScheduleConfig{Channel, ChannelTarget} — forge-core/types/config.go:890-898.
So WhatsApp's SendResponse must work purely from event.WorkspaceID = the recipient phone number (E.164 / wa_id). Start can be a no-op blocker and NormalizeEvent can return an error — nothing calls them for an outbound-only adapter.
⚠️ Key design decision: the 24-hour window + message templates
This is the one structural difference from Telegram/Slack and must be settled before implementation.
WhatsApp Cloud API rejects free-form text outside a 24-hour customer-service window opened by a user-initiated inbound message. It requires a pre-approved message template (type: "template" with name, language, components) for proactive/business-initiated sends. Since this integration is outbound-only with no inbound in v1, there is effectively no open window for scheduled/proactive delivery → SendResponse must send a template message, not type: text.
Telegram/Slack send free text unconditionally; there is no analog. Decide and record in the issue before coding:
- Template-first (recommended for v1): config a default template
name + language + a parameter-mapping strategy (agent summary text → a body parameter). Proactive sends always use type: template.
allow_freeform flag: send type: text only when an open window is known (not knowable without inbound), else fall back to template. Lower value for v1.
- Long content: template bodies have length limits and can't carry arbitrary markdown, so the Telegram/Slack
markdown.SplitMessage + file-upload approach does not map cleanly. v1 likely truncates the agent summary into a body parameter (or sends a media/document message). This needs an explicit answer in the issue.
Provider specifics vs Telegram/Slack
| Concern |
Telegram/Slack |
WhatsApp |
| Auth |
single bot_token / app_token+bot_token |
phone_number_id (path segment, not secret) + access_token (bearer, secret) |
| Target |
chat ID / channel ID |
recipient phone number (wa_id, E.164) = event.WorkspaceID |
| Send shape |
free text, unconditional |
type: template (proactive) — {messaging_product:"whatsapp", to, type, ...} |
| Formatting |
HTML (TG) / mrkdwn (Slack) |
lightweight only: *bold*, _italic_, ```mono``` — new markdown.ToWhatsApp |
| Inbound |
webhook/poll (TG), Socket Mode (Slack) |
none in v1 — Start is a no-op blocker |
| Egress host |
api.telegram.org / slack capability |
graph.facebook.com |
| Approvals/Consent |
Slack implements ApprovalDeliverer/ConsentDeliverer |
not implemented — base ChannelPlugin only |
File-by-file task breakdown (grounded in the current code)
| File |
Change |
forge-plugins/channels/whatsapp/whatsapp.go (new) |
Plugin type + New(); Name()="whatsapp"; Init reads phone_number_id + access_token (+ optional api_version, base URL), errors on missing (mirror telegram.go:60-63); Start(ctx,_) logs "outbound-only started" and blocks on <-ctx.Done(); NormalizeEvent returns an error; SendResponse POSTs to graph.facebook.com/<ver>/<phoneID>/messages (template-first per decision above). Add an unexported apiBase field so tests can point at httptest. |
forge-plugins/channels/whatsapp/whatsapp_test.go (new) |
httptest.NewServer stub + p.apiBase = srv.URL (same pattern as telegram_test.go/slack_test.go): Init required-field errors; SendResponse posts correct Cloud API JSON (recipient + type: template payload shape); long-content truncation. |
forge-plugins/channels/markdown/whatsapp.go (new) |
ToWhatsApp(text) — lightweight formatter alongside ToTelegramHTML / ToSlackMrkdwn / MarkdownToTeamsHTML. |
forge-cli/cmd/channel.go |
Register in 4–5 sites: createPlugin switch (~:236-247), defaultRegistry (~:250-256), ValidArgs + guard strings (~:32-44, ~:110-111, ~:163-164), addChannelEgressToForgeYAML case (~:343-419, treat capability-based like slack/msteams), and printSetupInstructions. |
forge-cli/templates/init/whatsapp-config.yaml.tmpl + env template (new) |
adapter: whatsapp, settings: { phone_number_id: "...", access_token_env: WHATSAPP_ACCESS_TOKEN, api_version: "..." }. Auto-discovered by EnvVarsFromConfig (forge-cli/channels/env.go:25-55) — no edit needed there. |
forge-core/security/capabilities.go |
Add "whatsapp": {"graph.facebook.com"} to DefaultCapabilityBundles (:4-12). |
docs/core-concepts/channels.md (+ new docs/channels/whatsapp.md) |
Supported-Channels table row, setup steps (get phone number ID + access token, register a template), config sample, outbound-only + 24h-window/template caveat. |
No changes needed in forge-core/channels/* (interface already fits), forge-cli/channels/router.go, forge-cli/channels/env.go, the scheduler-notifier wiring (run.go:326-336), or forge-core/validate/* (no channel validation lives there). The ApprovalDeliverer/ConsentDeliverer type-asserts in run.go simply skip adapters that don't implement them — WhatsApp needs nothing there.
Acceptance criteria
Open questions to resolve in-issue before implementation
- Template strategy: fixed default template + parameter mapping, or configurable per-schedule? What's the parameter shape for the agent's summary text?
- Long-response handling: truncate into a template body parameter, or send a media/document message? (The Telegram/Slack chunk/upload path does not apply.)
- Is there any appetite for a v2 inbound webhook (opening the 24h window + enabling free-form replies), or is outbound-only the permanent scope? Affects whether
Start/NormalizeEvent are stubs or reserved.
- Meta Graph API version to pin, and access-token type (temporary vs system-user long-lived).
References
Goal
Add a WhatsApp channel adapter to
forge-plugins/channels/, outbound-only, following the same pattern as Telegram and Slack. It delivers agent responses out to WhatsApp (primarily scheduled-task results); it does not ingest inbound messages in v1. The closest existing precedent is the MS Teams adapter (#78 / PR #76), which is documented "outbound-only" — WhatsApp is even more one-directional (no polling either).Provider: Meta WhatsApp Business Cloud API (Graph API) —
POST https://graph.facebook.com/<version>/<PHONE_NUMBER_ID>/messages,Authorization: Bearer <ACCESS_TOKEN>.Why outbound-only is a first-class fit
The channel contract already separates the two directions, and forge already drives a purely-outbound path that never touches inbound:
ChannelPlugin—forge-core/channels/plugin.go:13-28: inbound =Start/NormalizeEvent, outbound =SendResponse. "Outbound-only" is an implementation property, not a separate interface.SendResponsewith a syntheticChannelEventwhere onlyChannel+WorkspaceID(= the target) are set — noMessageID, no inbound origin:forge-cli/runtime/runner.go:5009-5013(if sched.Channel != "" && sched.ChannelTarget != "").forge-cli/cmd/run.go:326-336builds&ChannelEvent{Channel, WorkspaceID: target}and callsplugin.SendResponse(...).ScheduleConfig{Channel, ChannelTarget}—forge-core/types/config.go:890-898.So WhatsApp's
SendResponsemust work purely fromevent.WorkspaceID= the recipient phone number (E.164 /wa_id).Startcan be a no-op blocker andNormalizeEventcan return an error — nothing calls them for an outbound-only adapter.This is the one structural difference from Telegram/Slack and must be settled before implementation.
WhatsApp Cloud API rejects free-form text outside a 24-hour customer-service window opened by a user-initiated inbound message. It requires a pre-approved message template (
type: "template"withname,language,components) for proactive/business-initiated sends. Since this integration is outbound-only with no inbound in v1, there is effectively no open window for scheduled/proactive delivery →SendResponsemust send a template message, nottype: text.Telegram/Slack send free text unconditionally; there is no analog. Decide and record in the issue before coding:
name+language+ a parameter-mapping strategy (agent summary text → a body parameter). Proactive sends always usetype: template.allow_freeformflag: sendtype: textonly when an open window is known (not knowable without inbound), else fall back to template. Lower value for v1.markdown.SplitMessage+ file-upload approach does not map cleanly. v1 likely truncates the agent summary into a body parameter (or sends a media/document message). This needs an explicit answer in the issue.Provider specifics vs Telegram/Slack
bot_token/app_token+bot_tokenphone_number_id(path segment, not secret) +access_token(bearer, secret)wa_id, E.164) =event.WorkspaceIDtype: template(proactive) —{messaging_product:"whatsapp", to, type, ...}*bold*,_italic_,```mono```— newmarkdown.ToWhatsAppStartis a no-op blockerapi.telegram.org/ slack capabilitygraph.facebook.comApprovalDeliverer/ConsentDelivererChannelPluginonlyFile-by-file task breakdown (grounded in the current code)
forge-plugins/channels/whatsapp/whatsapp.go(new)Plugintype +New();Name()="whatsapp";Initreadsphone_number_id+access_token(+ optionalapi_version, base URL), errors on missing (mirrortelegram.go:60-63);Start(ctx,_)logs "outbound-only started" and blocks on<-ctx.Done();NormalizeEventreturns an error;SendResponsePOSTs tograph.facebook.com/<ver>/<phoneID>/messages(template-first per decision above). Add an unexportedapiBasefield so tests can point athttptest.forge-plugins/channels/whatsapp/whatsapp_test.go(new)httptest.NewServerstub +p.apiBase = srv.URL(same pattern astelegram_test.go/slack_test.go):Initrequired-field errors;SendResponseposts correct Cloud API JSON (recipient +type: templatepayload shape); long-content truncation.forge-plugins/channels/markdown/whatsapp.go(new)ToWhatsApp(text)— lightweight formatter alongsideToTelegramHTML/ToSlackMrkdwn/MarkdownToTeamsHTML.forge-cli/cmd/channel.gocreatePluginswitch (~:236-247),defaultRegistry(~:250-256),ValidArgs+ guard strings (~:32-44,~:110-111,~:163-164),addChannelEgressToForgeYAMLcase (~:343-419, treat capability-based like slack/msteams), andprintSetupInstructions.forge-cli/templates/init/whatsapp-config.yaml.tmpl+ env template (new)adapter: whatsapp,settings: { phone_number_id: "...", access_token_env: WHATSAPP_ACCESS_TOKEN, api_version: "..." }. Auto-discovered byEnvVarsFromConfig(forge-cli/channels/env.go:25-55) — no edit needed there.forge-core/security/capabilities.go"whatsapp": {"graph.facebook.com"}toDefaultCapabilityBundles(:4-12).docs/core-concepts/channels.md(+ newdocs/channels/whatsapp.md)No changes needed in
forge-core/channels/*(interface already fits),forge-cli/channels/router.go,forge-cli/channels/env.go, the scheduler-notifier wiring (run.go:326-336), orforge-core/validate/*(no channel validation lives there). TheApprovalDeliverer/ConsentDeliverertype-asserts inrun.gosimply skip adapters that don't implement them — WhatsApp needs nothing there.Acceptance criteria
forge channel add whatsappscaffoldswhatsapp-config.yaml, appendsWHATSAPP_ACCESS_TOKENto.env, addswhatsappto forge.yamlchannels:, and adds thegraph.facebook.comegress capability.forge run --with whatsappstarts the adapter (outbound-only; no port bound, no public inbound surface).channel: whatsapp, channel_target: <phone>delivers the agent's response to WhatsApp via the Cloud API.type: template); the 24h-window constraint is respected (no free-form text on a closed window).Initerrors clearly whenphone_number_idoraccess_tokenis missing.graph.facebook.comis allowlisted for the adapter.Initvalidation +SendResponsepayload shape against a stubbedapiBase;gofmt+golangci-lintclean; module tests pass.docs/core-concepts/channels.mddocuments the adapter, setup, and the outbound-only/template caveat.Open questions to resolve in-issue before implementation
Start/NormalizeEventare stubs or reserved.References
forge-plugins/channels/msteams/msteams.go.forge-core/channels/plugin.go:13-28. Outbound wiring:forge-cli/cmd/run.go:326-336. Registry:forge-cli/cmd/channel.go:236-256.