Appearance
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:
| Verb | Required schema |
|---|---|
GET | querySchema |
POST / PUT / PATCH | bodySchema (optional querySchema) |
DELETE | both 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:
| Member | What 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, params | Validated payloads and :param path segments |
auth | The AuthContext resolved by this route's auth chain |
requestIp | The TCP peer address |
host | Shared 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 |
artifacts | Unbound 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)andisContinuationBusy(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.
| Policy | Admits |
|---|---|
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:
- Webhooks guide: the same API, walked through
- HTTP API: the built-in routes precisely
- Sessions and streaming: the events channels subscribe to