Skip to main content

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:


Supported Channels

ChannelAdapterRequired configDocumentation
telegramfunctions/_shared/telegram-channel.tsbotToken, webhookSecret, allowedChatIdsTelegram Details
githubfunctions/_shared/github-channel.tswebhookSecret, appId, privateKeyGitHub Details
slackfunctions/_shared/slack-channel.tsbotToken, signingSecretSlack Details
discordfunctions/_shared/discord-channel.tsbotToken, publicKeyDiscord Details
pancakefunctions/_shared/pancake-channel.tspageId, pageAccessToken, webhookSecretPancake Details
zalofunctions/_shared/zalo-channel.tsbotToken, webhookSecret, allowedUserIdsZalo 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 /command runs a command from functions/_shared/commands.ts instead of the agent: /new (alias /start) clears the conversation context, /help lists commands, and Discord additionally exposes /ask. Commands only see the channel-agnostic ChannelActions.
  • 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 needsApproval are automatically denied on channel turns with the reason Tool 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:

ModeBehaviorRequirement
off (default)One final sendText
editPost a placeholder, then edit it in place on a ~1.2s throttle; final edit holds the complete replyChannel implements beginMessage/editMessage (else falls back to chunk)
progressShow a live preview of tool activity (⏳ Working… • <tool>) while the model runs, then swap the same message for the final answerSame edit primitives as edit (else falls back to chunk)
chunkSend each paragraph (blank-line boundary) as its own message as it completes; a fenced code block is never split mid-fenceUses 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:

MethodPurpose
nameStable 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:

ResultMeaning
messageContinue into the agent loop after sending ack or a default 200
ignoreStop without running the agent, usually for unsupported events
responseReturn a provider-specific response immediately, such as a challenge reply

The normalized InboundMessage contains:

  • eventId: provider delivery/message ID used for deduplication
  • conversationKey: provider thread/chat/channel key used for persisted conversation state
  • channelName: adapter name
  • content: Vercel AI SDK UserContent
  • source: 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

  1. Add config types to functions/_shared/storage/agent-config.ts.
  2. Validate the new config.channels.<channel> fields in normalizeChannelsConfig().
  3. Create functions/_shared/<channel>-channel.ts.
  4. Implement ChannelAdapter.
  5. Keep provider-specific reply formatting and send logic inside the channel module.
  6. Import the channel factory in functions/harness-processing/integrations.ts.
  7. Add create<Channel>ChannelFromConfig() and include it in createChannelRegistry().
  8. Document the webhook URL as /webhooks/{accountId}/{agentId}/{channel}.
  9. 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 eventId so duplicate deliveries are deduped.
  • Use thread/chat/channel IDs for conversationKey so follow-up messages preserve context.
  • Put provider-specific Markdown or HTML formatting in the channel module.
  • Keep ChannelActions methods 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.