Channels
A channel connects an external surface to agent sessions. A file at
bot/channels/<id>.ts defines channel <id> and mounts its routes
under /v1/channels/<id>. The built-in HTTP session channel is always
available alongside any custom or prebuilt channels.
Built-in HTTP channel
The built-in channel serves the session, tool, and discovery routes. In a multi-agent host, each agent's routes sit under its slug. See the HTTP API for request and response contracts.
Define a custom channel
Use defineChannel with one or more typed routes:
import { defineChannel, POST } from "@cursor/bdk/channels";import { z } from "zod";export default defineChannel({ routes: [ POST("/review", { description: "Review a pull request on this channel", bodySchema: z.object({ message: z.string(), prUrl: z.string().url(), }), handler: async (_request, { send, waitUntil, body }) => { waitUntil( send(`${body.message}\n\nPull request: ${body.prUrl}`, { continuationToken: `pr:${body.prUrl}`, }) ); return new Response(null, { status: 202 }); }, }), ],});The route above is
POST /v1/channels/<id>/review. Calls for the same pull request reuse
one conversation because they pass the same continuation token.
Route responses
A custom route acknowledges the caller and nothing more. On
Cursor-managed hosting, the deployment answers every custom route with
202 Accepted and an empty body as soon as the request arrives, then
runs the handler. The caller never receives the handler's Response,
so a route can't return a session ID, a computed result, or an error
status. Requests rejected before the handler runs, such as a missing
alias token or a failed sharedSecretAuth or hmacSignatureAuth
check, still get 401.
Deliver results through the channel's events handlers, a
callback to the system that sent the request, or a tool the turn calls.
Local serve passes the handler's response through, so return
new Response(null, { status: 202 }) to keep local calls matching
hosted ones.
Route verbs and schemas
Route paths must begin with /. The method helper determines which Zod
schemas the route accepts:
| Helper | Schema contract |
|---|---|
GET | querySchema is required |
POST, PUT, PATCH | bodySchema is required; querySchema is optional |
DELETE | Both schemas are optional |
Plain JSON Schema objects don't type-check. Use z.object({}) or
z.unknown() for an open surface. The host validates the body and query
before calling the handler, and an empty body is read as {}. A request
that fails validation never reaches the handler; local serve answers it
with 400, while a hosted caller has already received its 202. Declared schemas also appear in GET /v1/info for
playground requests and slash commands.
Handler arguments
Each handler receives the Fetch Request and a typed arguments object.
| Member | Contract |
|---|---|
send(message, options?) | Start or resume a session on this channel |
getSession(sessionId) | Return this channel's session, or null |
receive(channel, input) | Hand work to another channel |
callTool(name, input, options?) | Call a server tool without a model turn |
body, query, params | Validated inputs and :param path segments |
auth | The AuthContext returned by the route's auth chain |
requestIp | The TCP peer address, or null |
host | Shared MCP, provider, storage, telemetry, and reminder services |
waitUntil(promise) | Keep work running after the handler returns |
sessionUrls(request, sessionId) | Build absolute playground and trace URLs for this mount |
artifacts | List or tag artifacts; pass sessionId when attributing one |
send options
| Option | Contract |
|---|---|
continuationToken | Resume the session with this channel-local key, or create one when the key is new |
admission | "preempt" interrupts a busy turn; "coalesce" queues behind it. The default is "preempt" |
workspaceFiles | Add relative files for the next turn |
workspaceDir | Use an absolute local working directory |
auth | Set the session principal; defaults to the request principal |
state | Set starting channel state for a new session |
title | Set the display title for a new session |
purpose | Use "eval" to mark a new session as regression traffic |
dryRun | Run read tools and stub write tools for a new session |
asOf | Freeze a new session at an ISO-8601 instant with a timezone |
send returns a ChannelSession. Its id identifies the session,
continuationToken contains its current channel key, and isNew says
whether this call created it. A coalesced call also returns
coalesced: true.
Events
The events map subscribes the channel to stream events for the
sessions it owns. Keys are event types from the
event vocabulary, or "*". Each handler
receives (event, channel, ctx), including the session's
channel.state, session info, and shared host services. Use these
handlers to deliver progress and replies to the surface that owns the
channel.
Session state
state on the channel definition supplies starting JSON for each new
session. A route can instead pass state to send; event handlers read
and update the active value through channel.state.
Start and stop a channel
onStart(args) runs when the channel mounts, and onStop() runs when
the host stops. onStart receives the route helpers plus these
transport controls:
| Helper | Contract |
|---|---|
emitAssistantMessage(sessionId, text) | Append final assistant text without starting a model turn |
hasContinuationSession(token) | Check whether a token maps to a session |
isContinuationBusy(token) | Check whether a turn is active for a token |
interruptContinuation(token) | Stop the active turn and clear queued coalesced follow-ups |
Auth policies
Every route runs the channel's auth array. The default is
[localDevStrict()]. Policies may be asynchronous; the first one to
return an AuthContext admits the request, and an all-null result
returns 401.
| Policy | Admits |
|---|---|
localDevStrict() | Direct loopback requests with a loopback hostname and no proxy-forwarding headers |
localDev() | Direct loopback requests with no proxy-forwarding headers, without checking the hostname |
loopbackOnly() | Any loopback TCP peer, including local relays that carry forwarding headers |
bearerAuth(tokenOrVerify) | A matching bearer token, or a token accepted by the verifier |
sharedSecretAuth({ header, secret }) | A header matching the named environment or deployment secret |
hmacSignatureAuth({ header, secret, prefix? }) | A hex HMAC-SHA256 signature over the raw body using the named secret |
allowAll() | Every caller as an anonymous principal |
publicEndpoint() | Every caller on this custom channel; managed hosting also exposes the route without an alias token |
Use allowAll() only for an intentionally public surface or one
protected upstream. With publicEndpoint(), the handler must verify the
provider's signature. The policy applies only to custom channel routes;
it doesn't open the built-in session or tool API.
The resolved AuthContext ({ authenticator, principalId, principalType, attributes? }) becomes the request principal. Sessions
belong to the principal that created them, and other principals receive
403 on owned routes.
Server flags interact with authored auth: --bearer-token swaps the
default localDevStrict() for bearerAuth(...) on channels that don't
author their own chain, and --allow-anonymous swaps it for
allowAll(). An authored auth array always takes precedence.
Prebuilt channels
| Import | Function | Surface |
|---|---|---|
@cursor/bdk/channels/slack | slackChannel() | Slack messages, threads, and streaming replies. See Slack |
@cursor/bdk/channels/github | githubChannel() | GitHub and GitHub Enterprise webhooks. See GitHub |
@cursor/bdk/channels/gitlab | gitlabChannel() | GitLab.com and self-managed GitLab hooks. See GitLab |
@cursor/bdk/channels/bitbucket | bitbucketChannel() | Bitbucket Cloud and Data Center hooks. See Bitbucket |
@cursor/bdk/channels/deployments | deploymentsChannel() | Deployment events filtered by source, environment, or event name |
For other platforms like Discord or Teams, use the authored
defineChannel route form.
Continuation tokens
Each channel defines its continuation-token format. The same token
resumes the same conversation; the built-in HTTP channel rotates its
opaque token after every accepted follow-up and returns 409 for a
stale token. See Session identifiers
for the full contract.