Skip to main content
A channel connects an agent to one surface: a JSON API, a webhook, a chat app. It says how an inbound request is authenticated, which conversation it belongs to, and how the agent’s reply (or an approval it pauses on) goes back to the surface. mountChannels() serves your channels behind one (req, res) handler and runs every request the same way:
Channels are new. The trigger adapters still work: WebhookTriggerAdapter now uses webhookChannel() for its auth and parsing. Channels add what triggers lack: each conversation on the surface is a session, and approvals and questions go back to the surface.

Quick start

httpChannel() is the reference channel: { sessionKey, input } in, JSON out.
The handler resolves true when it served the request and false (nothing written) for any other route, like the /chat routes of lousho dev, so you can mount it next to your own routes.

The contract

defineChannel({ ... }) checks the name and returns the definition: ctx (ChannelContext) lets parse ask the host instead of keeping state: ctx.approval(id) (the pending call), ctx.sessionId(key) and ctx.hasSession(key) (a session for that key was saved in the store). A { decision } may carry inbound (the conversation as the click itself names it, so a pause survives a restart) and approver (who decided; passed to mountChannels({ onDecision }) for your audit log). req is a framework-free ChannelRequest: method, url, headers (lower-case names), rawBody (the exact bytes: verify signatures over these, never over re-serialized JSON), text and native (the host’s request). A reply that does not call respond is followed by 200 {"ok":true}; a surface that delivers replies out of band (posting to a chat API) only needs to acknowledge the request. An error thrown by parse answers 400 for a SyntaxError (bad JSON) and 500 otherwise; a body over 1MB gets 413.

Sessions

Messages with the same sessionKey on the same channel share one session, so the agent sees the earlier exchanges; turns of one session run one at a time. Session ids allow only A-Za-z0-9_-, so an id with other characters (the default `${name}:${sessionKey}` always has the :) has them replaced by _ and a hash of the original appended: sms:+1555 becomes sms__1555-<hash>. Return a valid id from sessionId() to use it unchanged. Transcripts are kept in mountChannels(agent, channels, { store }): a SessionStore or { sessions, checkpoints } such as a SqliteStore (pass the one you gave createAgent({ store })). Without store, the handler keeps them in memory.

Approvals and questions

When a turn pauses on a tool that needs approval, or on an ask_question call, the handler calls onApproval (by default, reply with a prompt such as Approve send_email {"to":"sam@example.com"}? (approval id: ...)). Decide it in one of two ways; the continuation goes back through the same channel’s reply (or onApproval again, if it pauses again):
  • handler.resolveApproval({ id, approved, note? }) or handler.resolveApproval({ id, answer }) from your code, e.g. a surface’s button callback.
  • POST <basePath>/<name>/approvals/<id> with { approved, note? } or { answer }. The channel’s verify runs first; an id the channel did not pause on gets 404.

Built-in channels

webhookChannel({ secret }) checks an HMAC-SHA256 signature of the raw body in x-signature-256: sha256=<hex>; auth takes any webhook auth (HMAC options with replay protection, bearer token, custom). The checks and the generic 401 are the ones WebhookTriggerAdapter uses.

In an agent directory

An agent directory can keep its channels in channels/*.ts, one default-exported channel per file (named by the file unless the channel sets a name). resolveAgentDir() returns them as channels, and the node server mounts them with createDeployedServer(agent, { channels }).

Who may approve (Slack and Discord)

Both channels take approvers: a list of platform user ids, or a function (user: { id, name?, roles? }, { toolName, input, sessionId }) => boolean | Promise<boolean> (Discord fills roles with the member’s role ids). A click from anyone else does not decide the approval: the user gets an ephemeral “not allowed” message and the approval stays pending. Security note. Without approvers, only the user who started the turn (the message author on Slack, the command’s user on Discord) may approve. In earlier versions anyone who could see the message could; approvers: () => true restores that, which you should only do in a private channel. A function approvers fails closed when the process does not know the pending call (after a restart); use the list form or the default for approvals that must survive one. Answers to an ask_question are not restricted: the next message (Slack) or /ask (Discord) in the conversation is the answer. Who decided goes to mountChannels(agent, channels, { onDecision({ approver, decision, sessionId, channel }) {} }). Failures after the acknowledgment go to onError (option of the channel, or of mountChannels); see the contract above.

Slack

slackChannel({ signingSecret, botToken, name?, fetch? }) connects a Slack app. Each Slack thread is one session: a mention of the bot starts (or continues) the session of its thread, keyed by team, channel and the thread’s root ts; later messages in a thread that already has a session continue it without a mention (the session store is asked, so this survives a restart). A direct message to the bot starts a session keyed on the DM channel. Replies are posted in the thread with chat.postMessage (plain fetch, no Slack SDK; pass fetch to inject one in tests).
  • Every request’s signature is checked (v0 HMAC-SHA256 with Web Crypto, five-minute replay window); a failure answers 401.
  • The request is answered right away (200, or the url_verification challenge) and the turn runs after the response, within Slack’s 3-second limit.
  • Retries (X-Slack-Retry-Num), bot messages (including the bot’s own) and the message copy of a mention are acknowledged and skipped, so a turn never runs twice.
  • A tool approval is posted in the thread with Approve and Deny buttons; the click resumes the session and the continuation is posted in the thread. The clicked message is replaced with the outcome (“Approved by @user”), so its buttons disappear. The click carries its channel, thread and the user who may decide, so it works after a restart. An ask_question is posted as text, and the next message in the thread is the answer.
Set up the app at api.slack.com/apps:
  1. OAuth & Permissions: bot token scopes app_mentions:read, chat:write, and channels:history (plus groups:history for private channels) for follow-ups without a mention, and im:history for direct messages. Install the app and copy the bot token (xoxb-...).
  2. Event Subscriptions: Request URL https://<host>/channels/slack; bot events app_mention and message.channels (message.groups for private channels), and message.im for direct messages (also enable Messages Tab in App Home so users can write to the bot).
  3. Interactivity & Shortcuts: Request URL https://<host>/channels/slack (the same route).
  4. Basic Information: copy the signing secret.
Which threads are active comes from the store you pass mountChannels(). Pending approvals are resolved from the click itself. Only a pending ask_question (which thread awaits an answer) lives in the handler’s memory: a restart forgets it, so the next message is a new turn. After a restart, a continuation is posted but, because the new agent instance has no session bound to the approval, it is not appended to the session transcript.

Discord

discordChannel({ publicKey, applicationId, botToken?, name?, fetch? }) connects a Discord application over the HTTP Interactions endpoint (no gateway connection, no Discord library; Web Crypto and fetch only, so it also runs on Workers). The expected command is /ask prompt:<text>: one string option, and the prompt option (or the first string option) is the message.
  • Every request’s X-Signature-Ed25519 / X-Signature-Timestamp is verified over timestamp + body; a missing or bad signature answers 401, as Discord requires. PING is answered with PONG.
  • A command is acknowledged at once with a deferred response (within Discord’s 3-second limit); the reply then edits the original response (PATCH /webhooks/{applicationId}/{token}/messages/@original). Text over 2000 characters continues in follow-up messages.
  • Commands in the same channel share one session, keyed by guild and channel (and the thread, in a thread).
  • A tool approval is posted with Approve and Deny buttons (only approvers, by default the command’s user, may click them); the click resumes the session and the continuation is a follow-up message. The click carries the conversation, so it works after a restart. An ask_question is posted as text, and the next /ask in that channel is the answer.
  • Interaction tokens last 15 minutes, so a reply (or a click) later than that fails. botToken is reserved for bot REST calls; replies need only the interaction token.
Set up the application at discord.com/developers/applications:
  1. General Information: copy the application id and the public key.
  2. Interactions Endpoint URL: https://<host>/channels/discord (Discord sends a signed PING when you save it).
  3. Installation: the applications.commands scope is enough (add bot only if you also want the bot user in the server).
  4. Register the command once (a guild command appears immediately; use /applications/{id}/commands for a global one):
Only a pending ask_question lives in the handler’s memory (a restart forgets it); pending approvals are resolved from the button click and the approval store. As on Slack, a continuation after a restart is not appended to the session transcript. An agent directory’s channels/*.ts files are loaded as channels too, and the node server mounts them. SlackTriggerAdapter and verifySlackSignature() (see Triggers) still work for one-shot replies through an incoming webhook.