| title | Channels |
|---|---|
| description | Bridge messaging platforms like Slack and Telegram to your AI agent. |
| order | 4 |
Channel adapters bridge messaging platforms (Slack, Telegram) to your A2A-compliant agent. Each adapter normalizes platform-specific events into a common ChannelEvent format, forwards them to the agent's A2A server, and delivers responses back to the originating platform.
Slack/Telegram ──→ Channel Plugin ──→ Router ──→ A2A Server
↑ │
└──────────────── SendResponse ←────────────────────────┘
Both channels use outbound-only connections — no public URLs, no ngrok, no inbound webhooks. Telegram webhook mode binds to 127.0.0.1 only with secret token verification.
| Channel | Adapter | Mode | Default Port |
|---|---|---|---|
| Slack | slack.Plugin |
Socket Mode | 3000 |
| Telegram | telegram.Plugin |
Polling or Webhook | 3001 |
Note: Slack uses Socket Mode — an outbound WebSocket connection from the agent to Slack's servers. No public URL or ngrok is needed for local development.
# Add Slack adapter to your project
forge channel add slack
# Add Telegram adapter
forge channel add telegramThis command:
- Generates
{adapter}-config.yamlwith placeholder settings - Updates
.envwith required environment variables - Adds the channel to
forge.yaml'schannelslist - Prints setup instructions
# Start agent with Slack and Telegram adapters
forge run --with slack,telegramThis starts the A2A dev server and all specified channel adapters in the same process.
# Run adapter separately (requires AGENT_URL)
export AGENT_URL=http://localhost:8080
forge channel serve slackStandalone mode is useful for running adapters as separate services in production. Each adapter connects to the agent's A2A server via HTTP.
Before running the Slack adapter, create and configure a Slack App:
- Create a Slack App at https://api.slack.com/apps -> "Create New App" -> "From scratch"
- Enable Socket Mode — Settings -> Socket Mode -> toggle On
- Generate an App-Level Token — Basic Information -> "App-Level Tokens" -> "Generate Token and Scopes" -> add the
connections:writescope -> copy thexapp-...token - Enable Event Subscriptions — Features -> Event Subscriptions -> toggle On -> Subscribe to bot events:
message.channels— messages in public channelsmessage.im— direct messagesapp_mention— @mentions of your bot
- Set Bot Token Scopes — Features -> OAuth & Permissions -> Bot Token Scopes -> add:
app_mentions:readchat:writechannels:historyim:historyfiles:write(for large response file uploads)reactions:write(for processing indicators)
- Install the App — Settings -> Install App -> "Install to Workspace" -> copy the
xoxb-...Bot Token - Add tokens to
.env:SLACK_APP_TOKEN=xapp-1-... SLACK_BOT_TOKEN=xoxb-... - Invite the bot to any channel where you want it active:
/invite @YourBot
The Slack adapter resolves the bot's own user ID and bot_id at startup via auth.test. The user ID drives @mention matching; the bot_id powers the self-loop guard.
- Channel messages — the bot only responds when explicitly @mentioned (e.g.
@ForgeBot what's the status?) - Thread replies — the bot responds to all messages in a thread it's participating in, unless the message @mentions a different user
- Direct messages — all DMs are processed
- Bot mentions are stripped from the message text before passing to the LLM, so it sees clean input
By default the adapter ignores every event whose Slack bot_id is non-empty — this prevents bot-to-bot loops. Operators can admit specific bots (scheduler, monitoring tool, CI bot) that should be allowed to @-mention the agent by listing their bot_ids in slack-config.yaml:
adapter: slack
settings:
app_token_env: SLACK_APP_TOKEN
bot_token_env: SLACK_BOT_TOKEN
allow_bot_ids: B0123ABC,B0456DEFTwo safeguards keep loops bounded:
| Rule | Scope |
|---|---|
| Self-loop guard | The agent's own bot_id is always dropped, even if listed in allow_bot_ids. No opt-out. |
| Mention requirement | Admitted bots still must include <@FORGE_AGENT_USER_ID> in the message text — chatter from an allowed bot without an @-mention is ignored. |
Both drop paths emit an operator-actionable log line naming the bot_id and pointing at the YAML setting, so debugging is self-service. Find a bot's bot_id: Slack admin → Manage apps → app → Bot User OAuth.
When the Slack adapter receives a message:
- An 👀 reaction is added immediately to acknowledge receipt
- If the handler takes longer than 15 seconds, an interim message is posted: "Researching, I'll post the result shortly..."
- The 👀 reaction is removed when the response is ready
This gives users visual feedback that their message is being processed, especially for long-running research queries.
The Telegram adapter mirrors Slack's processing feedback:
- A typing indicator ("typing...") is sent immediately and refreshed every 4 seconds
- If the handler takes longer than 15 seconds, an interim message is posted: "Working on it — I'll send the result when ready."
- The typing indicator stops when the response is ready
Context isolation: Each handler goroutine runs with an independent context (10-minute timeout), detached from the polling loop. This prevents in-flight tasks from being cancelled if the polling context is interrupted during server restarts or errors.
adapter: slack
settings:
app_token_env: SLACK_APP_TOKEN
bot_token_env: SLACK_BOT_TOKEN
# Optional: comma-separated bot_ids whose @mentions are admitted.
# Default (omit / empty) = no other bots admitted; only humans trigger.
# The agent's own bot_id is always dropped, regardless of this list.
# allow_bot_ids: B0123ABC,B0456DEFEnvironment variables:
SLACK_APP_TOKEN— Socket Mode app-level token (xapp-...)SLACK_BOT_TOKEN— Bot user OAuth token (xoxb-...)
See Bot Authorship Admission for allow_bot_ids details.
adapter: telegram
webhook_port: 3001
webhook_path: /telegram/webhook
settings:
bot_token: TELEGRAM_BOT_TOKEN
mode: pollingEnvironment variables:
TELEGRAM_BOT_TOKEN— Bot token from @BotFather
Mode options:
polling(default) — Long-polling viagetUpdateswebhook— Receives updates via HTTP webhook (loopback-only binding with secret token verification)
When running in webhook mode, the Telegram adapter applies multiple security controls:
| Control | Detail |
|---|---|
| Loopback binding | Webhook server binds to 127.0.0.1:<port> instead of 0.0.0.0, preventing direct internet exposure |
| Secret token verification | A 32-byte random secret is generated at startup and registered with Telegram's setWebhook API. Incoming requests must include the matching X-Telegram-Bot-Api-Secret-Token header; mismatches return 401 |
| Content-Type enforcement | Only application/json requests are accepted; others return 415 |
| Request body limit | Bodies are limited to 1 MiB via http.MaxBytesReader; oversized payloads return 413 |
The Slack adapter deduplicates events by envelope ID to prevent processing the same message multiple times (common during reconnections or network retries):
- Each envelope ID is recorded in an in-memory cache on first receipt
- Subsequent envelopes with the same ID are silently skipped after acknowledgment
- Cache entries older than 5 minutes are evicted automatically every 60 seconds
- Empty envelope IDs are never considered duplicates
When an agent response exceeds 4096 characters (common with research reports), channel adapters automatically split it into a summary message and a file attachment:
- A brief summary is sent as a regular inline message
- The full report is uploaded as a downloadable Markdown file (
research-report.md)
This works on both Slack (via files.getUploadURLExternal) and Telegram (via sendDocument). If file upload fails, adapters fall back to chunked messages. Markdown is converted to platform-native formatting (Slack mrkdwn or Telegram HTML).
The runtime decides what the inline summary contains:
| Condition | Inline summary source |
|---|---|
| Final LLM response > 4096 chars | LLM-generated summary — one extra Chat() call asking the model to summarise its own response in 2-4 sentences. Returned to channel adapters as a2a.Message.Summary |
| LLM response ≤ 4096 chars but a tool attached a large file part | The LLM's response text itself — it is already a brief summary of the file content. No extra summariser call |
| LLM response ≤ 4096 chars, no file part | The full response is sent inline as chunked messages — no attachment, no summary |
If the summariser call fails or returns empty, channel adapters fall back to head-truncating the response body at the first paragraph boundary (≤ 600 chars) or truncateAtSentence(text, 500). The fallback ensures the channel always delivers something even when the LLM is unreachable.
Additionally, the runtime tracks large tool outputs (>8000 characters) and attaches them as file parts in the A2A response. This ensures channel adapters receive the complete, untruncated tool output even when the LLM's text summary is truncated by output token limits. JSON tool outputs (e.g. Tavily Research/Search results) are automatically unwrapped into readable markdown before delivery.
When channels are configured in forge.yaml, the build pipeline automatically:
- Includes channel config files —
slack-config.yaml,telegram-config.yaml, etc. are copied into the Docker build context alongsideforge.yaml - Adds
--withto the entrypoint — The container entrypoint becomes["forge", "run", "--host", "0.0.0.0", "--with", "slack,telegram"] - Surfaces channel env vars in the manifests — Every
_env-suffixed setting in each<channel>-config.yaml(e.g.bot_token_env: SLACK_BOT_TOKEN) is unioned into the Kubernetessecrets.yamlanddeployment.yaml(viasecretKeyRef) and into the docker-compose adapter services. Both outputs derive from the same source — see Kubernetes — Env Var Injection - Handles auth loopback — When external auth is configured, channel adapters authenticate to the A2A server using an internal token, bypassing the external auth provider
Pass channel secrets via environment variables:
docker run \
-e SLACK_APP_TOKEN=xapp-... \
-e SLACK_BOT_TOKEN=xoxb-... \
-e FORGE_AUTH_URL=https://auth.example.com/verify \
my-agent# Package agent with channel adapter sidecars
forge package --with-channelsThis generates a docker-compose.yaml with:
- An
agentservice running the A2A server - Adapter services (e.g.,
slack-adapter,telegram-adapter) connecting to the agent
Implement the channels.ChannelPlugin interface:
type ChannelPlugin interface {
Name() string
Init(cfg ChannelConfig) error
Start(ctx context.Context, handler EventHandler) error
Stop() error
NormalizeEvent(raw []byte) (*ChannelEvent, error)
SendResponse(event *ChannelEvent, response *a2a.Message) error
}- Create a new package under
forge-plugins/channels/yourplatform/. - Implement
ChannelPlugin. - Register the plugin in the channel registry.
- Add config generation in
generateChannelConfig()and env vars ingenerateEnvVars(). - Wrap your per-message handler with
channels.StartDeliverSpan(ctx, "<adapter>", event)so the dispatch lands in traces aschannel.<adapter>.deliverand the downstream A2A POST nests under it via the W3Ctraceparentinjected by the router. See Observability — Tracing ›channel.<adapter>.deliver.
When tracing is enabled, each inbound message produces a channel.<adapter>.deliver span that wraps the adapter's per-message handler. The internal A2A POST in forge-cli/channels/router.go injects the W3C traceparent from that span's context, so the agent server's a2a.tasks/send span nests under the deliver span. Operators can finally answer "how long does Slack→agent take?" from the flame graph alone, without correlating two unconnected trace roots.
Attributes (Slack / Telegram / Teams alike): forge.channel.adapter, forge.channel.target (Slack channel ID / Telegram chat ID / Teams chat ID), forge.channel.message_id (pivot back to the upstream system), forge.channel.user_id. Span Status is set to Error on handler / send failure. See Observability — Tracing for the full attribute reference.