Appearance
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>, and the built-in HTTP channel serves the HTTP API routes and is always available.
Define a custom channel
Use defineChannel with one or more typed routes:
ts
import { defineChannel, POST } from "@grok/agent-sdk/channels";
import { z } from "zod";
export default defineChannel({
routes: [
POST("/review", {
description: "Review a pull request on this channel",
bodySchema: z.object({
prUrl: z.string().url(),
}),
handler: async (_request, { send, waitUntil, body }) => {
waitUntil(
send(`Review ${body.prUrl}`, {
continuationToken: `pr:${body.prUrl}`,
})
);
return new Response(null, { status: 202 });
},
}),
],
});This route is POST /v1/channels/<id>/review. Calls for the same pull request pass the same continuation token, so they share one conversation.
Route responses
On Cursor hosting, the deployment answers every custom route with an empty 202 Accepted as soon as the request arrives, then runs the handler. The caller never sees the handler's Response, so a route can't return a session ID, a result, or an error status. A request that fails the alias token, sharedSecretAuth, or hmacSignatureAuth check still gets 401.
Deliver results through events handlers, a callback to the sender, or a tool the turn calls. Local serve returns the handler's response, so return new Response(null, { status: 202 }) to match hosting.
Route verbs and schemas
Route paths must begin with /.
GET requires a querySchema.
POST, PUT, and PATCH require a bodySchema. They also accept a querySchema.
DELETE takes a bodySchema, a querySchema, either, or neither.
Plain JSON Schema objects don't type-check, so use z.object({}) or z.unknown() for an open surface. The host validates input before the handler runs, and declared schemas appear in GET /v1/info for the playground and slash commands.
Handler arguments
A handler receives the Fetch Request and these arguments:
| Member | Contract |
|---|---|
send(message, options?) | Start or resume a session on this channel |
getSession(sessionId) | 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 from the route's auth chain |
requestIp | The TCP peer address, or null |
host | Host services such as MCP, storage, telemetry, and reminders |
waitUntil(promise) | Keep work running after the handler returns |
sessionUrls(request, sessionId) | Playground and trace URLs for a session |
artifacts | List or tag artifacts |
send options
| Option | Contract |
|---|---|
continuationToken | Channel-local key that resumes its session, or creates one when new |
admission | "preempt" (the default) interrupts a busy turn. "coalesce" queues behind it |
workspaceFiles | Relative files to add before the next turn |
workspaceDir | Absolute local working directory |
auth | Session principal. Defaults to the request principal |
state | Starting channel state for a new session |
title, purpose, dryRun, asOf | Set up a new session, as in the POST /v1/session body |
deliveryId | Names this send on the session's events, up to 256 characters. Defaults to a fresh dlv_… ID |
send returns a ChannelSession with id, the current continuationToken, isNew, and coalesced: true for a coalesced call. On Cursor hosting, it returns the ID before the session exists.
Events
The events map subscribes the channel to stream events for its sessions. Keys are types from the event vocabulary, or "*". Each handler receives (event, channel, ctx) with channel.state, session info, and host services, so it can post progress and replies. state on the channel definition is each new session's starting JSON, unless a route passes state to send, and handlers update it through channel.state.
Start and stop a channel
onStart(args) runs when the channel mounts, and onStop() runs when the host stops. Besides the route helpers, onStart receives emitAssistantMessage(sessionId, text), which appends assistant text without a model turn, and hasContinuationSession, isContinuationBusy, and interruptContinuation, which check or stop the session behind a token.
Auth policies
Every route runs the channel's auth array, which defaults to [localDevStrict()]. The first policy to return an AuthContext admits the request, and if every policy returns null, the route answers 401.
| Policy | Admits |
|---|---|
localDevStrict() | Direct loopback requests with a loopback hostname and no forwarding headers |
localDev() | The same, with any hostname |
loopbackOnly() | Any loopback peer, including relays that add forwarding headers |
bearerAuth(tokenOrVerify) | A matching bearer token, or one the verifier accepts |
sharedSecretAuth({ header, secret }) | A header that matches the named secret |
hmacSignatureAuth({ header, secret, prefix?, separator? }) | A hex HMAC-SHA256 signature of the raw body under the named secret, or any of several signatures split by separator. The secret can hold up to 32 values, one per line, and any of them verifies |
allowAll() | Every caller, as an anonymous principal |
publicEndpoint() | Every caller, with no alias token on hosting. The handler must verify the provider's signature |
Use allowAll() only for a public surface or one protected upstream. publicEndpoint() applies only to custom channel routes and doesn't open the built-in session or tool API.
The admitted AuthContext becomes the request principal, which owns the sessions it creates.
On a channel without its own auth, --bearer-token swaps the default for bearerAuth(...) and --allow-anonymous swaps it for allowAll().
Prebuilt channels
Import each one from @grok/agent-sdk/channels/<name>.
| Function | Details |
|---|---|
slackChannel() | Slack |
githubChannel() | GitHub |
gitlabChannel() | GitLab |
bitbucketChannel() | Bitbucket |
pagerdutyChannel() | PagerDuty |
deploymentsChannel() | Deployment events filtered by source, environment, or event name |
Use defineChannel for other platforms, such as Discord.
Handler results
Hooks on the GitHub, GitLab, Bitbucket, and PagerDuty channels, such as onPullRequest or onIncident, return one of three results:
| Return value | What happens |
|---|---|
{ auth } | Runs the model turn with that auth. It can also set the turn's message, title, or context |
{ task } | Runs your function on the host. That function can call send to start a model turn |
null | Ignores the event |
Hosted session keys
On Cursor hosting, each request to a defineChannel route starts a new session unless it carries X-BDK-Session-Key. Requests with the same value on one channel share one session, in arrival order. The handler's continuationToken doesn't change that choice, and local serve ignores the header.
| Rule | Contract |
|---|---|
| Auth | The alias token or sharedSecretAuth(). Routes admitted by hmacSignatureAuth() or publicEndpoint() answer 400 |
| Value | 1 to 256 printable ASCII characters with no space or comma, or the request gets 400. Hosting stores the value, so keep secrets and personal data out of it |
| Scope | One channel on one deployment. Prebuilt channels ignore the header |
| Retries | Hosting doesn't deduplicate. A retry runs a second turn |