Skip to content

Channels

A channel is the surface an agent lives on. The built-in HTTP session channel is always mounted. Custom channels declare their own routes under /v1/channels/<id>. The Slack and GitHub packs are prebuilt channels with platform transports. This page is the authoring reference; for the walkthrough, see the Webhooks guide.

The built-in HTTP channel

It's always mounted, under /<slug> in the default multi-agent layout: session create, follow-up, stream, stop, the sessions list, approvals, deterministic tool calls, health, and info. For the route-by-route contract, see the HTTP API reference.

Author agent/channels/http.ts only to override its defaults:

ts
import {
  bearerAuth,
  httpChannel,
  localDevStrict,
} from "@cursor/july/channels";

export default httpChannel({
  auth: [localDevStrict(), bearerAuth(process.env.AGENT_TOKEN ?? "")],
  onMessage: (message, { auth }) =>
    `[caller ${auth?.principalId ?? "anonymous"}] ${message}`,
});

Define a custom channel

Author agent/channels/<id>.ts. The filename is the channel id and the route prefix:

ts
import { defineChannel, POST } from "@cursor/july/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(),
        thread: z.string().optional(),
      }),
      handler: async (_req, { callTool, send, body }) => {
        const prepared = await callTool("inspect_pr", {
          prUrl: body.prUrl,
        });
        if (prepared.isError) {
          return Response.json(
            { error: prepared.errorMessage ?? "Could not inspect pull request" },
            { status: 502 }
          );
        }

        const session = await send(
          `${body.message}\n\nRead pr.json before answering.`,
          {
            continuationToken: body.thread ?? `pr:${body.prUrl}`,
            workspaceFiles: {
              "pr.json": JSON.stringify(prepared.result, null, 2) ?? "null",
            },
          }
        );
        return Response.json({ sessionId: session.id });
      },
    }),
  ],
  events: {
    "message.completed"(event, channel, ctx) {
      // deliver the reply to the surface that owns this channel
    },
  },
  // auth: [...], state: {...}, onStart(...), onStop(...)
});

This example assumes agent/tools/inspect_pr.ts exists. The handler calls it before the model turn, so every review starts with validated PR data. It also derives a stable conversation key from the PR URL and writes the tool result to pr.json. Instructions can ask the model to inspect a PR, but host code guarantees it.

Route verbs and schemas

GET, POST, PUT, PATCH, and DELETE helpers build routes. Their schemas are Zod, enforced at compile time:

VerbRequired schema
GETquerySchema
POST / PUT / PATCHbodySchema (optional querySchema)
DELETEboth optional

Plain JSON Schema objects won't type-check; use z.object({}) or z.unknown() for intentionally open surfaces. The host validates before the handler runs. Handlers receive typed args.body and args.query, and empty POST bodies are coerced to {} first. Declared schemas are projected on GET /v1/info, which powers the playground's Try buttons and composer slash commands.

Handler arguments

Handlers receive the Fetch Request and an args object:

MemberWhat it is
send(message, options?)Run a model turn on this channel; returns the session handle (options below)
getSession(sessionId)Look up an existing session on this channel
receive(channelDefinition, input)Hand off to another channel (schedules use this)
callTool(name, input, options?)Deterministic server-tool call (Tools)
body, query, paramsValidated payloads and :param path segments
authThe AuthContext resolved by this route's auth chain
requestIpThe TCP peer address
hostShared services: host.mcp, host.github, host.slack, host.kv, host.files, host.reminders
waitUntil(promise)Background work that outlives the response
sessionUrls(request, sessionId)Absolute playground + trace URLs for a session on this mount
artifactsUnbound artifacts facade; pass sessionId in tag input to attribute one

send options: continuationToken (the conversation key), admission ("preempt" interrupts a busy session, the default; "coalesce" enqueues behind the running turn, the Slack policy), workspaceFiles, workspaceDir, cloud (attach cloud repos for this session), auth (defaults to the request principal), sdkAgentId (resume a specific SDK agent), state (starting channel state for new sessions), title (session display title), purpose ("eval" skips sticky A/B enrollment), and coalesceSourceTs (dedupe key for coalesce queue items already delivered mid-turn).

Events

The events map subscribes the channel to stream events for the sessions it owns. Keys are event types from the event vocabulary, or "*". Handlers receive (event, channel, ctx), where channel.state is the per-session adapter state and ctx exposes session info and host services. This is where a channel delivers replies back to its surface.

State and lifecycle

state declares the starting per-session adapter state (JSON), persisted on the session record. Routes and event handlers read and mutate it through channel.state. onStart(args) runs when the channel mounts; the Slack pack opens its Socket Mode connection here. onStop() runs when the server drains.

onStart receives the route helpers (send, getSession, receive, callTool, host, waitUntil, artifacts, and a logger that respects the server's log sink) plus a set that exists for long-lived transports:

  • emitAssistantMessage(sessionId, text) appends an assistant message without a model turn, for host tasks that already produced the final text.
  • hasContinuationSession(token) and isContinuationBusy(token) report whether a continuation token has a live session and whether a turn is in flight on it.
  • getContinuationLastBotMessageTs(token) reads the Slack warm-delta watermark from channel state.
  • interruptContinuation(token) stops the in-flight turn and clears coalesced follow-ups queued behind it.
  • resolveApproval(sessionId, callId, decision, auth, options?) approves or denies a parked tool call, how Slack Block Kit buttons unblock a turn without the HTTP approvals route.

Auth policies

Every route runs an auth-policy chain: the channel's auth array, or [localDevStrict()] when unset. A policy is a function (request, info) => AuthContext | null (async allowed); the first non-null wins, and a request no policy admits gets 401.

PolicyAdmits
localDevStrict()Direct loopback callers with no proxy-forwarding headers (X-Forwarded-For, X-Real-IP, Forwarded, and X-Forwarded-Host are all rejected, so tunnels and same-host reverse proxies don't silently re-expose the route), plus a loopback Host header, which rejects DNS-rebinding callers that reach 127.0.0.1 with a remote hostname.
localDev()Like localDevStrict() but without the Host check. An explicit, weaker opt-in.
loopbackOnly()A loopback TCP peer, ignoring forwarding headers; for dev relays that legitimately carry them, like gh webhook forward.
bearerAuth(token)Authorization: Bearer <token>, compared in constant time. Also accepts a verifier function mapping a presented token to an AuthContext.
allowAll()Everyone, as an anonymous principal. Only for surfaces protected upstream (an HMAC-verified webhook) or intentionally public.

The resolved AuthContext ({ authenticator, principalId, principalType, attributes? }) becomes the request principal. Sessions bind to the principal that created them, and follow-up, stream, and list routes enforce ownership (403 otherwise).

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(). Authored auth arrays always win over both. A channel that declares [localDevStrict()] stays loopback-only even on an --allow-anonymous host.

First class channels

Slack (@cursor/july/channels/slack): Socket Mode transport, streaming replies, engagement rules, approval cards, and a default block on Slack Connect / guest / other-workspace senders. Author agent/channels/slack.ts with slackChannel(). Guide: Slack.

GitHub (@cursor/july/channels/github): webhook dispatch with signature verification, per-event hooks returning { auth } (a model turn), { task } (host work), or null, and CLI tooling for replay and live forwarding. Author agent/channels/github.ts with githubChannel(). Opt-in progress.commitStatus and progress.banner converge a merge-box check and sticky PR comment from default stream events. Guide: GitHub.

For other platforms like Discord or Teams, use the authored defineChannel webhook form.

Continuation semantics

Channels own their continuation-token format. The built-in HTTP channel mints opaque rotating tokens, Slack uses channelId:threadTs, and PR automations use keys like pr:owner/repo#N. Same token, same durable session; one active continuation per session; the HTTP channel returns 409 for stale tokens. For the full session model, see Sessions.

What's next

Continue with these pages: