Skip to main content

Channels — Connecting Agents to External Systems

Channels normalise incoming events into AgentInput and deliver AgentOutput back to the external system. Each channel implements the ChannelInterface.

Contents​


ChannelInterface​

interface ChannelInterface {
name?: string; // required for sendTo() routing
isTriggerChannel: boolean; // see below

listen(): void; // start accepting messages
send(output: AgentOutput): Promise<void>;
normalize(incoming: unknown): AgentInput;
onMessage(handler: (input: AgentInput) => Promise<void>): void;

// Optional: resolve richer Participant info (display name, etc.)
resolveParticipant?(input: AgentInput): Promise<Participant | undefined> | Participant | undefined;
}

You do not normally call these methods yourself — BaseAgent._bindChannel() and AgentRegistry manage the lifecycle.


Trigger vs. conversation channels​

isTriggerChannelExamplesCan use ask()?Has human recipient?
falseSlack, Discord, Telegram, WebhookYesYes
trueScheduled, Email, SMS (outbound)NoNo

Trigger channels fire the agent on a schedule or external event but have no interactive human on the other end. Calling ask() from a trigger channel throws:

AgentError: this.ask() called from a trigger channel (ScheduledChannel).
Trigger channels have no human recipient — use a conversation channel instead.

SlackChannel​

Connects your agent to Slack workspaces via the Events API.

Install​

npm install @slack/web-api

Configuration​

import { SlackChannel } from '@toolpack-sdk/agents';

const slack = new SlackChannel({
name: 'support-slack', // required for sendTo() routing
token: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,

// Listen on one channel, multiple channels, or omit to listen to all
channel: '#support', // single channel (or pass channel ID 'C12345')
// channel: ['#support', '#escalations'], // multiple channels
// channel: null, // listen to every channel the bot is in

port: 3000, // port for Slack events webhook (default: 3000)

// Optional allow/block lists for bot users (matched against bot_id B... or user id U...)
allowedBotIds: ['U123ABC'],
blockedBotIds: ['U456DEF'],
});

What it does​

  • Starts a plain HTTP server to receive Slack Events API callbacks (built-in, no @slack/bolt dependency).
  • On startup, runs auth.test to determine botUserId. This ID is added as an agent alias so assemblePrompt can recognise messages addressed to the bot even when mentioned by its platform ID.
  • Caches resolveParticipant() results and invalidates on user_change events.
  • Supports thread replies — messages in threads use the thread timestamp as conversationId.

Slack app setup​

  1. Create a Slack app at https://api.slack.com/apps
  2. Enable Event Subscriptions → set Request URL to https://<your-host>/slack/events
  3. Subscribe to bot events: message.channels, message.groups, app_mention
  4. Install the app to your workspace
  5. Copy Bot User OAuth Token → SLACK_BOT_TOKEN
  6. Copy Signing Secret → SLACK_SIGNING_SECRET

DiscordChannel​

Connects your agent to Discord servers via the Gateway (WebSocket) API.

Install​

npm install discord.js

Configuration​

import { DiscordChannel } from '@toolpack-sdk/agents';

const discord = new DiscordChannel({
name: 'discord',
token: process.env.DISCORD_BOT_TOKEN!,
guildId: process.env.DISCORD_GUILD_ID!,
channelId: process.env.DISCORD_CHANNEL_ID!,
});

What it does​

  • Uses discord.js client with GatewayIntentBits.Guilds, GuildMessages, MessageContent, and DirectMessages.
  • Normalises Discord messages → AgentInput with thread support.
  • Sends responses back to the originating channel.

Discord bot setup​

  1. Create an application at https://discord.com/developers/applications
  2. Under Bot, generate a token → DISCORD_BOT_TOKEN
  3. Enable Message Content Intent under Privileged Gateway Intents
  4. Invite the bot to your server with bot + applications.commands scopes and Send Messages permission
  5. Copy the Server ID → DISCORD_GUILD_ID (right-click server → Copy ID with Developer Mode on)
  6. Copy the Channel ID → DISCORD_CHANNEL_ID

TelegramChannel​

Connects your agent to Telegram via bot polling or webhooks.

Install​

npm install node-telegram-bot-api

Configuration​

import { TelegramChannel } from '@toolpack-sdk/agents';

const telegram = new TelegramChannel({
name: 'telegram',
token: process.env.TELEGRAM_BOT_TOKEN!,

// Optional: use webhook instead of polling
// webhookUrl: 'https://your-server.com/telegram/webhook',
});

What it does​

  • On startup, calls getMe to populate botUserId and botUsername.
  • Supports both polling (development) and webhook (production) modes.
  • Sends text messages (and optional inline keyboard buttons) via the Telegram Bot API.
  • Handles inline keyboard button taps (callback_query updates) as first-class input.

Inline keyboards (callback queries)​

Sending a message with inline buttons:

Pass metadata.replyMarkup in the AgentOutput to attach an InlineKeyboardMarkup-shaped object to any message. Existing callers that omit replyMarkup are unaffected.

await channel.send({
output: 'Approve this trade?',
metadata: {
chatId: 123456789,
replyMarkup: {
inline_keyboard: [[
{ text: '✅ Approve', callback_data: 'approve:abc-123' },
{ text: '❌ Reject', callback_data: 'reject:abc-123' },
]],
},
},
});

Receiving a button tap:

When a user taps an inline button, Telegram sends a callback_query update. normalize() handles this as a distinct path and sets context.isCallback = true:

// Input from a button tap:
// input.message → 'approve:abc-123' (the callback_data)
// input.conversationId → '555' (the chat.id as string)
// input.context.isCallback → true
// input.context.callbackQueryId → 'cbq-1' (needed for answerCallbackQuery)
// input.context.callbackData → 'approve:abc-123'
// input.context.chatId → 555
// input.context.userId → 987654321

Dismissing the loading spinner:

After handling a button tap, call answerCallbackQuery() to dismiss Telegram's loading spinner. This is not called automatically — the consuming agent is responsible.

async invokeAgent(input: AgentInput): Promise<AgentResult> {
if (input.context?.isCallback) {
const data = input.context.callbackData as string;

// Handle the tap
const result = await this.handleTap(data);

// Dismiss the spinner — pass callbackQueryId from context
await (this.channels[0] as TelegramChannel).answerCallbackQuery(
input.context.callbackQueryId as string,
'Recorded.',
);

return result;
}
return this.run(input.message ?? '');
}

answerCallbackQuery(callbackQueryId, text?)

ParameterTypeDescription
callbackQueryIdstringcallback_query.id from the update — available as input.context.callbackQueryId
textstring (optional)Short toast text shown to the user (max 200 chars)

Throws if the Telegram API returns an error (e.g. query expired).

Callback context fields

FieldTypeDescription
isCallbacktrueAlways set on callback_query inputs
callbackQueryIdstringPass to answerCallbackQuery() to dismiss the spinner
callbackDatastringThe callback_data value from the tapped button
chatIdnumberNumeric chat ID
userIdnumberNumeric user ID of who tapped the button
usernamestring | undefinedTelegram username
firstNamestring | undefinedUser's first name
messageIdnumberThe message that contained the keyboard

Telegram bot setup​

  1. Message @BotFather on Telegram
  2. Run /newbot and follow the prompts
  3. Copy the token → TELEGRAM_BOT_TOKEN

ChatChannel​

Lightweight channel for in-app UI conversations. The request/response cycle is synchronous: send() and listen() are no-ops and the agent reply is returned directly from the HTTP handler.

Configuration​

Declare the channel on the agent's channels property:

import { BaseAgent, ChatChannel } from '@toolpack-sdk/agents';

class MyAgent extends BaseAgent {
name = 'my-agent';
mode = 'agent';
channels = [new ChatChannel({ name: 'chat' })];

async invokeAgent(input: AgentInput): Promise<AgentResult> {
return this.run(input.message ?? '', undefined, { conversationId: input.conversationId }, input.attachments);
}
}

Request format​

In your HTTP handler, call chat.normalize() to parse and validate the request body, then pass the result to agent.invokeAgent():

const chat = agent.channels[0] as ChatChannel;

app.post('/api/chat', async (req, res) => {
const input = chat.normalize(req.body); // validates attachments, parses fields
const output = await agent.invokeAgent(input);
res.json(output);
});

Expected JSON body fields:

FieldTypeDescription
messagestringUser message text
conversationIdstringOptional. Continues an existing conversation thread
attachmentsArray<ImagePart | FilePart>Optional. Images or documents to attach
participantParticipantOptional. Identifies the user

Attachment validation runs at normalize() time. For FilePart, the size limit is enforced only when size is provided in the attachment object — if omitted, no client-side check runs. For inline image_data, the decoded size is always checked.


WebhookChannel​

Exposes an HTTP endpoint. Any HTTP POST to the endpoint triggers the agent.

Configuration​

import { WebhookChannel } from '@toolpack-sdk/agents';

const webhook = new WebhookChannel({
name: 'api-webhook',
path: '/api/agent', // HTTP path
port: 4000, // HTTP port (default: 3000)
});

Request format​

Send a POST request with JSON body:

{
"message": "Summarise the quarterly report",
"conversationId": "session-abc",
"context": { "userId": "user-123" }
}

The channel responds synchronously — the HTTP response body is the agent's output.

Response format​

{
"output": "The quarterly report shows...",
"metadata": { "conversationId": "session-abc" }
}

ScheduledChannel​

Triggers an agent on a cron schedule. Three modes are available:

ModeConfigWhen to use
Staticcron onlyFixed schedule, no persistence needed
Dynamicstore onlyAgent drives its own schedule via scheduler.* tools
Hybridcron + storeFixed seed + agent-driven additions

Install (store and hybrid modes)​

npm install better-sqlite3

Static mode​

import { ScheduledChannel } from '@toolpack-sdk/agents';

const daily = new ScheduledChannel({
name: 'daily-report',
cron: '0 9 * * 1-5', // 9am Monday–Friday
intent: 'daily_summary',
message: 'Generate the daily standup summary',
});

The cron expression is validated on construction — an invalid expression throws immediately. Supported syntax:

  • 5-field min hour dom month dow and 6-field sec min hour dom month dow
  • Step (*/15), range (9-17), list (1,3,5), combined (*/15 9-17 * * 1-5)
  • Named days (MON-FRI, case-insensitive) and months (JAN-DEC)
  • Modifiers: L (last day/weekday of month), # (nth weekday — e.g. 5#2 = second Friday)
  • Macros: @yearly, @annually, @monthly, @weekly, @daily, @hourly, @minutely

Dynamic store mode​

Let the agent schedule its own future invocations. Requires better-sqlite3.

import {
ScheduledChannel,
SchedulerStore,
createSchedulerTools,
} from '@toolpack-sdk/agents';

const store = new SchedulerStore({ dbPath: './scheduler.db' });

const channel = new ScheduledChannel({
name: 'dynamic',
store,
idlePollMs: 10_000, // poll interval when store is empty (default: 30_000, min: 1000)
});

// Expose scheduler tools so the LLM can manage its own schedule
const toolpack = await Toolpack.init({ provider: 'anthropic' });
await toolpack.loadToolProject(createSchedulerTools(store));

The agent can then call four scheduler tools:

ToolDescription
scheduler.createSchedule a recurring (cron) or one-shot (run_at) job
scheduler.listList pending / all jobs
scheduler.cancelCancel a pending job by ID
scheduler.updateChange the cron, run time, message, intent, or payload of a pending job

See scheduler.md for the full SchedulerStore and tool reference.

Hybrid mode​

Seed a static cron job into the store and let the agent add more:

const store = new SchedulerStore({ dbPath: './scheduler.db' });

const channel = new ScheduledChannel({
name: 'hybrid',
cron: '0 9 * * 1-5', // seeded into store on first listen()
store,
intent: 'morning_check',
idlePollMs: 10_000,
});

The static cron is inserted as a recurring job on startup. Deduplication prevents re-insertion on restarts; the agent can schedule additional jobs using scheduler.create.

Routing output​

ScheduledChannel is a pure trigger — it has no send() behaviour. Route output by attaching a named channel and calling sendTo() from invokeAgent():

class DigestAgent extends BaseAgent {
name = 'digest';
mode = 'agent';
channels = [
new ScheduledChannel({ name: 'daily', cron: '0 9 * * 1-5' }),
new SlackChannel({ name: 'team-slack', channel: '#standups', token, signingSecret }),
];

async invokeAgent(input: AgentInput): Promise<AgentResult> {
const report = await this.run(input.message ?? '');
await this.sendTo('team-slack', report.output);
return report;
}
}

Configuration options​

OptionTypeDefaultDescription
namestring—Channel name for sendTo() routing. Required in store/hybrid mode.
cronstring—Cron expression (5- or 6-field). Required in static/hybrid mode.
storeSchedulerStore—SQLite-backed job store. Required in dynamic/hybrid mode.
intentstring—Default intent forwarded to AgentInput.intent.
messagestring—Default message forwarded to AgentInput.message.
idlePollMsnumber30_000How often (ms) to poll the store when no jobs are pending. Min: 1000.

Startup behaviour (store and hybrid modes)​

On first listen() (process startup):

  1. Crash recovery — resets any running jobs left over from a previous crash back to pending
  2. Cron seeding — inserts the static cron job if provided (idempotent via dedup)
  3. Missed-run recovery — immediately executes any overdue pending jobs

On a stop()+listen() cycle within the same process, crash recovery is skipped to avoid interfering with in-flight jobs from the previous cycle.

isTriggerChannel​

ScheduledChannel.isTriggerChannel is true. Calling ask() from within a scheduled run throws — there is no human to answer.


EmailChannel​

Outbound-only email delivery.

Install​

npm install nodemailer

Configuration​

import { EmailChannel } from '@toolpack-sdk/agents';

const email = new EmailChannel({
name: 'email-alerts',
from: 'agent@example.com',
to: 'team@example.com',
smtp: {
host: 'smtp.example.com',
port: 587,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
},
});

isTriggerChannel = true. Use this for sending outbound email notifications from your agent.

For inbound email, set up an email parsing service and deliver the payload to a WebhookChannel.


SMSChannel​

Bidirectional SMS via Twilio.

Install​

npm install twilio

Configuration​

import { SMSChannel } from '@toolpack-sdk/agents';

const sms = new SMSChannel({
name: 'sms',
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
from: process.env.TWILIO_FROM_NUMBER!,

// Optional: recipient number for outbound-only SMS
// to: '+15551234567',

// Optional: HTTP path to receive inbound SMS (makes channel bidirectional)
// webhookPath: '/sms/webhook',
// port: 3000, // default: 3000
});

isTriggerChannel is dynamic: true when webhookPath is not set (outbound-only), false when webhookPath is set (bidirectional). Sends SMS via the Twilio REST API.


McpChannel​

McpChannel exposes a Toolpack agent as a tool in an MCP server. When an MCP client calls agent.<name>, the channel delivers the input to the agent and returns its output as the tool result.

isTriggerChannel = false — the MCP client drives the conversation, so ask() works normally.

Configuration​

import { McpChannel } from '@toolpack-sdk/agents';

const ch = new McpChannel({
// Optional: descriptive name used for sendTo() routing
name: 'mcp',
});

Wiring to an agent and MCP server​

import { McpChannel } from '@toolpack-sdk/agents';
import { Toolpack } from 'toolpack-sdk';

const ch = new McpChannel();
const agent = new PrReviewerAgent({ channels: [ch] });
await agent.start();

const sdk = await Toolpack.init({ provider: 'anthropic', tools: true });

await sdk.startMcpServer({
transport: 'stdio', // or 'http'
agents: [ch.asAgentDefinition(agent)],
});

ch.asAgentDefinition(agent) produces the McpAgentDefinition object that startMcpServer uses to register the agent in tools/list as agent.<agentName>.

McpChannelConfig​

OptionTypeDefaultDescription
namestring'mcp'Channel name for sendTo() routing.

Flow​

  1. MCP client calls tools/call with name: 'agent.<agentName>'
  2. startMcpServer routes the call to ch.asAgentDefinition(agent).invoke(args)
  3. McpChannel wraps args into an AgentInput and calls agent.invokeAgent()
  4. Agent runs, returns AgentResult
  5. Output is returned to the MCP client as a text tool result

See MCP Server guide for full server configuration options.


Custom channels​

Implement ChannelInterface (or extend BaseChannel) to connect any data source:

import { BaseChannel, AgentInput, AgentOutput } from '@toolpack-sdk/agents';

class KafkaChannel extends BaseChannel {
readonly isTriggerChannel = false;

constructor(private config: { topic: string; brokers: string[] }) {
super();
this.name = 'kafka';
}

listen(): void {
// Subscribe to Kafka topic, call this._messageHandler(this.normalize(msg))
}

async send(output: AgentOutput): Promise<void> {
// Produce to Kafka response topic
}

normalize(incoming: unknown): AgentInput {
const msg = incoming as KafkaMessage;
return {
message: msg.value.toString(),
conversationId: msg.key?.toString() ?? `kafka-${Date.now()}`,
participant: { kind: 'user', id: msg.headers?.userId ?? 'unknown' },
};
}
}

BaseChannel provides the onMessage() registration and _messageHandler field — call this._messageHandler(input) when a message arrives.