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 TokenSLACK_BOT_TOKEN
  6. Copy Signing SecretSLACK_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 IDDISCORD_GUILD_ID (right-click server → Copy ID with Developer Mode on)
  6. Copy the Channel IDDISCORD_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

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
namestringChannel name for sendTo() routing. Required in store/hybrid mode.
cronstringCron expression (5- or 6-field). Required in static/hybrid mode.
storeSchedulerStoreSQLite-backed job store. Required in dynamic/hybrid mode.
intentstringDefault intent forwarded to AgentInput.intent.
messagestringDefault 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.