Channels Reference
Channels are communication integrations such as Telegram, GitHub, Slack, Discord, Pancake, and Zalo. They translate provider webhooks into the shared agent input shape, then send replies through a channel-specific ChannelActions implementation.
Customers interact with the provider bot, app, or webhook. They do not receive account secrets. The webhook URL always includes the account, agent, and channel:
{AGENT_SERVICE_URL}/webhooks/{accountId}/{agentId}/{channel}
Runtime Flow
Webhook handling is split deliberately:
functions/harness-processing/integrations.tsowns routing, account/agent lookup, adapter selection, provider ACKs, and normalized channel events.functions/harness-processing/handler.tsowns session setup, command dispatch, agent execution, and final reply handling.functions/_shared/channels.tsowns the shared channel contracts.functions/_shared/<channel>-channel.tsowns provider-specific authentication, parsing, formatting, and reply API calls.
Supported Channels
| Channel | Adapter | Required config | Documentation |
|---|---|---|---|
telegram | functions/_shared/telegram-channel.ts | botToken, webhookSecret, allowedChatIds | Telegram Details |
github | functions/_shared/github-channel.ts | webhookSecret, appId, privateKey | GitHub Details |
slack | functions/_shared/slack-channel.ts | botToken, signingSecret | Slack Details |
discord | functions/_shared/discord-channel.ts | botToken, publicKey | Discord Details |
pancake | functions/_shared/pancake-channel.ts | pageId, pageAccessToken, webhookSecret | Pancake Details |
zalo | functions/_shared/zalo-channel.ts | botToken, webhookSecret, allowedUserIds | Zalo Details |
Code-First Configuration
The CLI SDK exposes one constructor per provider. Attach the resulting definitions to one agent; an agent may receive from multiple channel types, while one channel definition cannot be shared by multiple agents.
import { defineAgent, defineGitHubChannel, defineSlackChannel, env } from "broods";
export const github = defineGitHubChannel({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_PRIVATE_KEY,
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
allowedRepos: ["owner/repo"],
});
export const slack = defineSlackChannel({
botToken: env.SLACK_BOT_TOKEN,
signingSecret: env.SLACK_SIGNING_SECRET,
streaming: { mode: "edit" },
});
export const support = defineAgent({
name: "support",
config: { channels: [github, slack] },
});
broods dev lowers the list to the runtime's keyed config.channels shape, syncs referenced environment values, generates api.channels, and prints each provider webhook URL. Code-first agent definitions must use channel constructors; keyed channel objects are rejected.
Runnable examples live under packages/demos/channel-*. Provider registration is explicit: Telegram, Zalo, and Discord demos include a register command; other providers use their administration console.
Shared Channel Behavior
Every channel gets these behaviors from the shared pipeline, not from the adapter:
- Bot commands — a message starting with
/commandruns a command fromfunctions/_shared/commands.tsinstead of the agent:/new(alias/start) clears the conversation context,/helplists commands, and Discord additionally exposes/ask. Commands only see the channel-agnosticChannelActions. - Typing + reaction — an accepted message immediately triggers a fire-and-forget typing indicator and a reaction (👀 on Slack/GitHub, configurable on Telegram, no-op on Discord/Pancake/Zalo).
- Tool approval auto-deny — tools configured with
needsApprovalare automatically denied on channel turns with the reasonTool approval is only supported through the direct API. - Error replies — if processing fails, the channel receives
Error: <message>as the reply. - Per-channel config scoping — a webhook run only sees its own channel's config; other channels' credentials are stripped from the runtime agent config.
- Deferred replies — when a turn finishes in the background (detached async tools or sandbox jobs), the final result is pushed back into the originating chat once it settles.
Reply Streaming
By default a channel sends one final message per turn. Set config.channels.<channel>.streaming.mode to stream the assistant reply live as the model produces it:
| Mode | Behavior | Requirement |
|---|---|---|
off (default) | One final sendText | — |
edit | Post a placeholder, then edit it in place on a ~1.2s throttle; final edit holds the complete reply | Channel implements beginMessage/editMessage (else falls back to chunk) |
progress | Show a live preview of tool activity (⏳ Working… • <tool>) while the model runs, then swap the same message for the final answer | Same edit primitives as edit (else falls back to chunk) |
chunk | Send each paragraph (blank-line boundary) as its own message as it completes; a fenced code block is never split mid-fence | Uses sendText — works on every channel |
The handler reads text-delta and tool-call parts from the agent's fullStream: edit/chunk consume the text and ignore tool calls; progress consumes tool calls and ignores the streamed text (the answer arrives whole at the end).
The driver (functions/_shared/channel-streaming.ts) owns accumulation and throttling; channels only provide the beginMessage/editMessage primitives (and an optional editMaxChars cap) for edit/progress modes. Streaming is best-effort — a failed edit/send never aborts the turn, and a structured/object final response always sends as one message. When an edited reply outgrows the channel's editMaxChars budget (default ~3500 raw characters; Discord uses 1900, both safely below the provider caps of 4096/2000), the driver freezes the current message at a clean break and continues streaming in a new one (rotation), so long replies are not truncated. Telegram, Slack (chat.postMessage/chat.update), and Discord (interaction webhook edits) ship edit primitives; other channels stream via chunk until they add the two methods.
Channel Contract
Each channel implements ChannelAdapter from functions/_shared/channels.ts:
| Method | Purpose |
|---|---|
name | Stable URL segment and config key, such as telegram |
canHandle(req) | Quick provider-shape check, usually based on headers |
authenticate(req) | Provider-native signature or secret verification |
parse(req) | Converts the webhook into message, ignore, or direct response |
actions(msg) | Returns reply, typing, and reaction actions scoped to the inbound message |
parse() returns one of three outcomes:
| Result | Meaning |
|---|---|
message | Continue into the agent loop after sending ack or a default 200 |
ignore | Stop without running the agent, usually for unsupported events |
response | Return a provider-specific response immediately, such as a challenge reply |
The normalized InboundMessage contains:
eventId: provider delivery/message ID used for deduplicationconversationKey: provider thread/chat/channel key used for persisted conversation statechannelName: adapter namecontent: Vercel AI SDKUserContentsource: provider metadata needed for commands, replies, or diagnostics
integrations.ts scopes eventId and conversationKey with accountId and agentId before the session sees them.
Add a Channel
- Add config types to
functions/_shared/storage/agent-config.ts. - Validate the new
config.channels.<channel>fields innormalizeChannelsConfig(). - Create
functions/_shared/<channel>-channel.ts. - Implement
ChannelAdapter. - Keep provider-specific reply formatting and send logic inside the channel module.
- Import the channel factory in
functions/harness-processing/integrations.ts. - Add
create<Channel>ChannelFromConfig()and include it increateChannelRegistry(). - Document the webhook URL as
/webhooks/{accountId}/{agentId}/{channel}. - Update the SDK constructor, API Reference, and focused tests/examples when the public config changes.
Do not hardcode channel-specific behavior in commands, shared handlers, or the core agent loop. Commands receive only the channel-agnostic ChannelActions interface.
Adapter Skeleton
/**
* Example channel adapter implemented as a ChannelAdapter.
* Keep Example auth, message normalization, and reply actions here.
*/
import type { ChannelAdapter, ChannelParseResult } from "./channels.ts";
export function createExampleChannel(
token: string,
webhookSecret: string,
): ChannelAdapter {
return {
name: "example",
canHandle(req) {
return "x-example-delivery" in req.headers;
},
authenticate(req) {
return req.headers["x-example-secret"] === webhookSecret;
},
parse(req): ChannelParseResult {
const body = JSON.parse(req.body) as {
id: string;
threadId: string;
text?: string;
};
if (!body.text) {
return { kind: "ignore", response: { statusCode: 200 } };
}
return {
kind: "message",
ack: { statusCode: 200 },
message: {
eventId: body.id,
conversationKey: body.threadId,
channelName: "example",
content: [{ type: "text", text: body.text }],
source: body as Record<string, unknown>,
},
};
},
actions(msg) {
return {
sendText: async (text) => {
await fetch("https://api.example.com/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
threadId: msg.conversationKey,
text,
}),
});
},
sendTyping: async () => {},
reactToMessage: async () => {},
};
},
};
}
Channel Rules
- Verify provider signatures or webhook secrets before parsing user-controlled payloads deeply.
- Return a provider ACK quickly; long-running model work should happen in
afterResponse. - Use stable provider IDs for
eventIdso duplicate deliveries are deduped. - Use thread/chat/channel IDs for
conversationKeyso follow-up messages preserve context. - Put provider-specific Markdown or HTML formatting in the channel module.
- Keep
ChannelActionsmethods resilient; failed typing or reaction calls should not fail the whole turn. - Keep approval-dependent tools off channel-only agents unless a direct API client will resume the approval flow.