Channel Adapters
Channel adapters connect external messaging platforms to the Open Astra gateway. All 12 supported channels follow the same integration pattern: they receive messages from their platform, convert them into a unified internal format, run the agent loop via a shared router, and deliver the response back in platform-specific formatting. This page covers how that pattern works, how channels are activated, and how messages flow from platform to agent and back.
Uniform channel pattern
Every channel module exports a start and stop function that follow the same signature convention.
// Every channel adapter exports two functions:
startXChannel(...credentials, resolveClient, app?)
stopXChannel()
// resolveClient is getInferenceClientForAgent — a per-agent factory
// that returns the correct inference provider for a given agent.
// app is the Express instance; webhook channels register routes on it.resolveClientisgetInferenceClientForAgent— a per-agent factory that returns the right inference provider (OpenAI, Anthropic, local, etc.) based on the agent's configurationappis the Express app instance. Channels that need inbound HTTP endpoints (webhooks) register their own routes on it. Polling channels don't need it.- The channel's message handler calls
routeMessage(platform, senderId, text, resolveClient)viasrc/channels/router.ts, which picks the default agent and runs the agent loop
Auto-detection at startup
Channels are auto-detected at startup. The gateway checks which environment variables are present and activates those channels automatically. No code changes are needed — just set the env vars and restart.
# No configuration needed — just set env vars and restart
# The gateway checks for these at startup:
TELEGRAM_BOT_TOKEN=... # → startTelegramChannel()
DISCORD_BOT_TOKEN=... # → startDiscordChannel()
SLACK_BOT_TOKEN=... # → startSlackBot()
WHATSAPP_ACCESS_TOKEN=... # → startWhatsAppChannel()
# ... and so on for all 12 channelsastra.yml under the channels: key, which overrides env var detection. Setting enabled: false disables a channel even if its env var is set. Polling vs webhook channels
Channels fall into two categories based on how they receive messages from their platform.
Polling channels
Telegram and Discord are polling channels. They connect outbound to the platform API and long-poll for updates. Because they initiate the connection themselves, they don't need the Express app instance and don't register any HTTP routes on the gateway. This makes them the simplest to set up — no public URL or webhook configuration is required.
Webhook channels
The remaining 10 channels — WhatsApp, iMessage, Signal, Slack, Google Chat, Microsoft Teams, LINE, Viber, X, and Email — are webhook channels. They register HTTP routes on the gateway's Express app and receive inbound POST requests from the platform. These channels require the gateway to be reachable at a public URL so the platform can deliver events to it.
GATEWAY_URL to your gateway's public HTTPS address (e.g. https://astra.example.com). Some platforms (WhatsApp, X/Twitter) also require you to register this URL in their developer portal. Channel reference
All 13 supported channel adapters and their requirements.
| Channel | Function | Needs app? | Auth method | Required env vars |
|---|---|---|---|---|
| Telegram | startTelegramChannel | No (polling) | Bot token | TELEGRAM_BOT_TOKEN |
| Discord | startDiscordChannel | No (polling) | Bot token | DISCORD_BOT_TOKEN |
| iMessage (BlueBubbles) | startIMessageChannel | Yes | Password | BLUEBUBBLES_URL, BLUEBUBBLES_PASSWORD |
| WhatsApp Cloud API | startWhatsAppChannel | Yes | verifyToken challenge (GET) + HMAC (POST) | WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN |
| Signal (signal-cli) | startSignalChannel | Yes | Signal REST API | SIGNAL_API_URL, SIGNAL_PHONE_NUMBER |
| Slack | startSlackBot | Yes | HMAC-SHA256 with x-slack-signature | SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET |
| Google Chat | startGoogleChatChannel | Yes | Service account JWT | GOOGLE_CHAT_SERVICE_ACCOUNT_KEY, GOOGLE_CHAT_ENABLED=true |
| Microsoft Teams | startTeamsChannel | Yes | Bot Framework auth | TEAMS_APP_ID, TEAMS_APP_PASSWORD |
| LINE | startLineChannel | Yes | HMAC-SHA256 | LINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRET |
| Viber | startViberChannel | Yes | HMAC-SHA256 | VIBER_AUTH_TOKEN, VIBER_BOT_NAME |
| X/Twitter DMs | startTwitterChannel | Yes | OAuth 1.0a + CRC challenge | X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_WEBHOOK_ENV |
startEmailChannel | Yes | HMAC secret on inbound webhook | SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_PORT, SMTP_SECURE, EMAIL_WEBHOOK_SECRET |
Message routing
All channels converge on routeMessage() in src/channels/router.ts. Every channel adapter calls this single function after parsing the platform-specific payload.
import { routeMessage } from "./router.js";
// Inside a channel message handler:
await routeMessage(
"telegram", // platform identifier
senderId, // unique sender on that platform
text, // raw message text
resolveClient // getInferenceClientForAgent
);Inside routeMessage(), four steps happen in sequence:
- Resolve the default agent — from
setDefaultAgentId()orenv.defaultAgentId - Find or create a session — keyed by the platform + sender combination, so each user on each platform gets their own conversation thread
- Run the full agent loop — the agent processes the message, calls tools, retrieves memories, and produces a response
- Return the response — the channel adapter receives the raw text and formats it for its platform (Markdown, Block Kit, Cards, etc.)
// src/channels/router.ts — routeMessage() internals
export async function routeMessage(platform, senderId, text, resolveClient) {
// 1. Resolve the default agent
const agentId = getDefaultAgentId() ?? env.defaultAgentId;
// 2. Find or create a session for this platform + sender
const session = await findOrCreateSession(platform, senderId, agentId);
// 3. Run the full agent loop
const response = await runAgentLoop(session, text, resolveClient);
// 4. Return response for platform-specific formatting
return response;
}HMAC verification
The shared hmacVerify() middleware in src/gateway/middleware/hmac-verify.ts is used by all HMAC-based channel webhooks. It validates inbound requests from platforms that sign their payloads (Slack, LINE, Viber, Email).
// src/gateway/middleware/hmac-verify.ts
import { timingSafeEqual } from "node:crypto";
export function hmacVerify(options: {
secret: string;
algorithm: "sha256" | "sha1"; // SHA256 or SHA1
header: string; // e.g. "x-hub-signature-256"
prefix?: string; // e.g. "sha256="
encoding?: "hex" | "base64"; // default: hex
}) {
return (req, res, next) => {
const signature = req.headers[options.header];
const expected = computeHmac(options, req.rawBody);
// timingSafeEqual prevents timing attacks
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: "Invalid signature" });
}
next();
};
}Features of the shared HMAC middleware:
- Supports SHA256 or SHA1 algorithms
- Configurable prefix (e.g.
sha256=,v0=) to match platform-specific header formats - Hex or base64 encoding
- Uses
timingSafeEqualthroughout to prevent timing attacks
express.json() middleware captures req.rawBody automatically (up to the 1 MB limit), so no extra configuration is needed. Example: how Slack registers HMAC verification on its webhook routes.
// Slack uses HMAC-SHA256 with "v0=" prefix
app.post("/channels/slack", hmacVerify({
secret: process.env.SLACK_SIGNING_SECRET,
algorithm: "sha256",
header: "x-slack-signature",
prefix: "v0=",
encoding: "hex",
}), slackHandler);Graceful shutdown
At shutdown, each stopXChannel() is called in sequence, draining connections before the HTTP server closes. This ensures in-flight messages are delivered and platform connections are closed cleanly.
// Graceful shutdown sequence (SIGTERM / SIGINT)
1. Stop accepting new HTTP connections
2. For each active channel:
await stopTelegramChannel() // stops polling
await stopDiscordChannel() // disconnects gateway
await stopSlackBot() // drains pending acks
await stopWhatsAppChannel() // deregisters routes
... (all 12 channels in sequence)
3. Close WebSocket connections (1001 Going Away)
4. Close the HTTP server
5. Close database poolastra.yml channel configuration
Channels can be configured in astra.yml under the channels: key. This overrides env var auto-detection and gives you explicit control over which channels are active.
# astra.yml — channel configuration
channels:
telegram:
enabled: true
token: ${TELEGRAM_BOT_TOKEN}
slack:
botToken: ${SLACK_BOT_TOKEN}
signingSecret: ${SLACK_SIGNING_SECRET}
discord:
token: ${DISCORD_BOT_TOKEN}
enabled: false # disabled even if env var is set${"${ENV_VAR}"} syntax in astra.yml to reference environment variables. This keeps secrets out of the config file while still allowing explicit channel configuration. Individual channel documentation
Each channel has its own dedicated page covering platform-specific setup, required scopes/permissions, webhook configuration, and message formatting.
| Channel | Setup guide |
|---|---|
| Telegram | Setup and configuration |
| Discord | Setup and configuration |
| Slack | Setup and configuration |
| Setup and configuration | |
| Signal | Setup and configuration |
| iMessage | Setup and configuration |
| Google Chat | Setup and configuration |
| Microsoft Teams | Setup and configuration |
| LINE | Setup and configuration |
| Viber | Setup and configuration |
| X/Twitter | Setup and configuration |
| Setup and configuration |