# 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](/docs/reference/http-api.md) for request and response
contracts.

## 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({
        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`. A request with a bad `X-BDK-Session-Key` header
gets `400`. See [Hosted session keys](#hosted-session-keys).

Deliver results through the channel's [`events`](#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](/docs/reference/tools.md#call-a-tool-without-a-model-turn) 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](/docs/reference/artifacts.md); 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 |
| `deliveryId` | Name this send on the session's events, at most 256 characters. Defaults to a fresh `dlv_…` ID |

`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`. `deliveryId` names this send on the event where the
message reaches the agent; see
[Track which follow-up reached the agent](/docs/reference/sessions.md#track-which-follow-up-reached-the-agent).
A hosted channel's `send` returns the same ID before the session exists.

## Events

The `events` map subscribes the channel to stream events for the
sessions it owns. Keys are event types from the
[event vocabulary](/docs/reference/sessions.md#stream-events), 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?, separator? })` | A hex HMAC-SHA256 signature over the raw body using the named secret. With `separator`, any of several signatures in the header. A secret holding one value per line verifies under any of them (at most 32, `@grok/agent-sdk` 0.2.25 or later) |
| `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 |
| --- | --- | --- |
| `@grok/agent-sdk/channels/slack` | `slackChannel()` | Slack messages, threads, and streaming replies. See [Slack](/docs/guides/slack.md) |
| `@grok/agent-sdk/channels/github` | `githubChannel()` | GitHub and GitHub Enterprise webhooks. See [GitHub](/docs/guides/github.md) |
| `@grok/agent-sdk/channels/gitlab` | `gitlabChannel()` | GitLab.com and self-managed GitLab hooks. See [GitLab](/docs/guides/gitlab.md) |
| `@grok/agent-sdk/channels/bitbucket` | `bitbucketChannel()` | Bitbucket Cloud and Data Center hooks. See [Bitbucket](/docs/guides/bitbucket.md) |
| `@grok/agent-sdk/channels/pagerduty` | `pagerdutyChannel()` | PagerDuty v3 incident webhooks. Replies post as incident notes only with `deliverReplies: true`. See [PagerDuty](/docs/guides/pagerduty.md) |
| `@grok/agent-sdk/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](/docs/reference/sessions.md#session-identifiers)
for the full contract.

### Hosted session keys

On Cursor-managed hosting, each request to a `defineChannel` route
starts a new session unless the request carries `X-BDK-Session-Key`.
Requests with the same header value on one channel go to one session,
in arrival order. The handler's `continuationToken` does not change
this choice, and local `serve` ignores the header.

| Rule | Contract |
| --- | --- |
| Auth | The alias token or `sharedSecretAuth()`. Routes that admit the request with `hmacSignatureAuth()` or `publicEndpoint()` answer `400` |
| Value | 1 to 256 printable ASCII characters, with no space and no comma. Any other value gets `400` |
| Scope | One channel on one deployment |
| Channels | `defineChannel` routes only. Prebuilt channels ignore the header |
| Handler | `request.headers` still contains the header |
| Retries | Hosting does not deduplicate. A retried request runs a second turn |

See [Keep one hosted session per thread](/docs/guides/webhooks.md#keep-one-hosted-session-per-thread).

## Related

- [Webhooks](/docs/guides/webhooks.md)
- [HTTP API](/docs/reference/http-api.md)
- [Sessions](/docs/reference/sessions.md)
- [Hooks](/docs/reference/hooks.md)
