mountChannels() serves your channels behind one (req, res)
handler and runs every request the same way:
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.
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 samesessionKey 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 anask_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? })orhandler.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’sverifyruns 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 inchannels/*.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 takeapprovers: 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 (
v0HMAC-SHA256 with Web Crypto, five-minute replay window); a failure answers 401. - The request is answered right away (
200, or theurl_verificationchallenge) 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 themessagecopy 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_questionis posted as text, and the next message in the thread is the answer.
- OAuth & Permissions: bot token scopes
app_mentions:read,chat:write, andchannels:history(plusgroups:historyfor private channels) for follow-ups without a mention, andim:historyfor direct messages. Install the app and copy the bot token (xoxb-...). - Event Subscriptions: Request URL
https://<host>/channels/slack; bot eventsapp_mentionandmessage.channels(message.groupsfor private channels), andmessage.imfor direct messages (also enable Messages Tab in App Home so users can write to the bot). - Interactivity & Shortcuts: Request URL
https://<host>/channels/slack(the same route). - Basic Information: copy the signing secret.
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-Timestampis verified overtimestamp + body; a missing or bad signature answers 401, as Discord requires.PINGis answered withPONG. - 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. Anask_questionis posted as text, and the next/askin that channel is the answer. - Interaction tokens last 15 minutes, so a reply (or a click) later than that
fails.
botTokenis reserved for bot REST calls; replies need only the interaction token.
- General Information: copy the application id and the public key.
- Interactions Endpoint URL:
https://<host>/channels/discord(Discord sends a signedPINGwhen you save it). - Installation: the
applications.commandsscope is enough (addbotonly if you also want the bot user in the server). - Register the command once (a guild command appears immediately; use
/applications/{id}/commandsfor a global one):
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.