Skip to content

Webhooks and custom channels

A custom channel gives the agent its own HTTP surface. You get routes with validated payloads, sessions keyed to something in your domain (a thread, a ticket, a PR), and replies delivered back to the caller. The Slack and GitHub packs build on this mechanism. This page is the mechanism itself.

What you already have

The built-in HTTP channel is always mounted (under /<slug> in the default multi-agent layout). POST /v1/session starts a conversation, POST /v1/session/:id follows up, and GET /v1/session/:id/stream streams NDJSON events, plus sessions, approvals, and tool routes. The full list is in the HTTP API reference.

Write a custom channel when that shape doesn't fit: a webhook with its own payload contract, a surface that keys sessions by a domain id, or a flow that does host-side work before (or instead of) a model turn.

Define a channel

Author agent/channels/<id>.ts with defineChannel. The filename is the channel id, and routes mount under /v1/channels/<id>:

ts
import { defineChannel, POST } from "@cursor/july/channels";
import { z } from "zod";

export default defineChannel({
  routes: [
    POST("/message", {
      description: "Enqueue a chat turn on this channel",
      bodySchema: z.object({
        message: z.string(),
        thread: z.string().optional(),
      }),
      handler: async (_req, { send, body }) => {
        const session = await send(body.message, {
          // stable key: same thread → same durable session
          continuationToken: body.thread,
        });
        return Response.json({ sessionId: session.id });
      },
    }),
  ],
  events: {
    "message.completed"(event, channel, ctx) {
      // deliver the reply back to the surface that owns this channel:
      // post to a webhook, reply in a thread, update a ticket, …
    },
  },
});
bash
curl -X POST http://127.0.0.1:3000/<slug>/v1/channels/<id>/message \
  -H 'content-type: application/json' \
  -d '{"message":"hello","thread":"ticket-42"}'

Schemas are Zod, and required

GET routes require a Zod querySchema. POST, PUT, and PATCH require a Zod bodySchema. Compile-time checks enforce this, so plain JSON Schema objects won't type-check. Use z.object({}) or z.unknown() when a surface is intentionally open. The host validates before your handler runs, handlers receive typed args.body and args.query, and empty POST bodies are coerced to {} first.

Declared schemas also feed GET /v1/info, which adds two things to the playground: a Try button on every route, and matching slash commands in the composer (a channel drive route becomes /drive …).

What a handler receives

Handlers get the Fetch Request plus an args object:

HelperWhat it does
send(message, options?)Run a model turn on this channel. continuationToken keys the durable session; options can seed workspaceFiles, attach cloud repos, or override auth.
getSession(sessionId)Look up an existing session
receive(channel, input)Hand off to another channel (schedules use this)
callTool(name, input, opts?)Run an authored server tool with no model turn (Tools)
body, query, paramsValidated payloads and path params
authThe AuthContext the route's auth chain resolved
requestIpThe TCP peer address
hostShared host services: host.mcp, host.github, host.slack, host.reminders
waitUntil(promise)Background work that outlives the response

Channel state declares the starting per-session adapter state. It persists across events, and event handlers receive it on channel.state.

Respond fast, work in the background

Webhook senders time out quickly. GitHub gives you about ten seconds. For slow work, ACK immediately and continue in the background:

ts
handler: async (_req, { send, waitUntil, body }) => {
  waitUntil(
    send(`Process incoming report: ${body.url}`, {
      continuationToken: `report:${body.id}`,
    })
  );
  return Response.json({ accepted: true }, { status: 202 });
},

Prepare on the host, then hand off

The strongest channel pattern: do the deterministic setup in the handler, then hand the model prepared evidence. Fetch the PR with callTool, seed the files it needs, and make the prompt about judgment rather than about finding things:

ts
handler: async (_req, { callTool, send, body }) => {
  const prep = await callTool("prepare_pr", { pr: body.pr });
  if (prep.isError) {
    return Response.json({ ok: false, error: prep.result }, { status: 502 });
  }
  const session = await send("Review the prepared PR under pr/.", {
    continuationToken: `pr:${body.pr}`,
  });
  return Response.json({ sessionId: session.id });
},

This host-prep shape is the change with the largest effect on latency and quality. Hillclimbing lists it first.

Deliver replies back out

The events map subscribes the channel to stream events for the sessions it owns. Typical wiring: message.completed posts the assistant text back to the caller's surface, and turn.failed posts an error notice. The full vocabulary is in Sessions and streaming.

Auth: loopback by default, on purpose

Every route runs an auth-policy chain (the channel's auth array). The default is [localDevStrict()]: direct loopback callers only. Requests carrying proxy-forwarding headers (X-Forwarded-For, X-Real-IP, Forwarded, X-Forwarded-Host) are rejected, and a loopback Host header is required. So a tunnel, a same-host reverse proxy, or a DNS-rebinding page can't silently re-expose the route.

Before real traffic, author auth explicitly:

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

export default defineChannel({
  auth: [localDevStrict(), bearerAuth(process.env.WEBHOOK_TOKEN ?? "")],
  routes: [/* … */],
});

The built-in policies are localDevStrict() (the default), localDev(), loopbackOnly(), bearerAuth(tokenOrVerify), and allowAll(), and any (request, info) => AuthContext | null function composes with them. Signature-verified surfaces usually use allowAll() at the route and verify the HMAC in the channel; the GitHub channel with a webhook secret works this way. The details live in Channels.

Server-level flags interact with channel auth. --bearer-token <secret> swaps the default for bearerAuth on every channel that doesn't author its own chain, and --allow-anonymous swaps it for allowAll() (trusted networks only). Authored auth arrays always win over both.

Hosted aliases need an alias-token relay

Cursor-managed deployments expose a stable alias URL. External callers must send X-Agent-Alias-Token on every request to that URL. Channel auth still runs after the alias gate.

Webhook providers that cannot attach custom headers cannot POST straight at a managed alias. Put a relay (or other authenticating intermediary) in front, use a Cursor-managed ingress path that does not use the public alias (for example Cursor Slack / GitHub event relay), or self-host. See Deployment.

Example: Linear as the control plane

Everything above composes into a working ticket-driven agent. This example wires Linear to the agent: new issues and comments start or resume sessions, and replies land back on the issue as comments. The same shape works for any tracker with signed webhooks.

Create the webhook in Linear under Settings → API → "New webhook", pointed at https://<your-host>/<slug>/v1/channels/linear, and copy the signing secret. Linear requires a public HTTPS URL, so use a tunnel during local development or test with signed fixtures (below). Set three environment variables:

bash
LINEAR_WEBHOOK_SECRET=lin_wh_...   # the webhook's signing secret
LINEAR_API_KEY=lin_api_...         # posts replies as comments
LINEAR_AGENT_USER_ID=...           # the API key's user: query { viewer { id } }

LINEAR_AGENT_USER_ID matters: replies posted with the API key trigger the Comment webhook again, so the channel must recognize and skip its own comments. Without the guard, every reply starts another turn.

Author agent/channels/linear.ts:

ts
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";
import { allowAll, defineChannel, POST } from "@cursor/july/channels";
import { z } from "zod";

const secret = process.env.LINEAR_WEBHOOK_SECRET ?? "";
const apiKey = process.env.LINEAR_API_KEY ?? "";
const agentUserId = process.env.LINEAR_AGENT_USER_ID ?? "";

interface LinearWebhook {
  action: string;
  type: string;
  url?: string;
  webhookTimestamp: number;
  data: {
    id: string;
    title?: string;
    description?: string;
    body?: string;
    issueId?: string;
    userId?: string;
  };
}

// Linear signs the raw body: hex HMAC-SHA256 in `Linear-Signature`.
function verified(rawBody: string, header: string | null): boolean {
  if (secret === "" || header === null) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const received = Buffer.from(header, "hex");
  return (
    received.length === expected.length && timingSafeEqual(received, expected)
  );
}

async function postComment(issueId: string, body: string): Promise<void> {
  const response = await fetch("https://api.linear.app/graphql", {
    method: "POST",
    headers: { "content-type": "application/json", authorization: apiKey },
    body: JSON.stringify({
      query:
        "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }",
      variables: { input: { issueId, body } },
    }),
  });
  if (!response.ok) {
    throw new Error(`commentCreate failed: ${response.status}`);
  }
}

function issueIdFromToken(token: string | null): string | null {
  if (token === null || !token.startsWith("linear:")) {
    return null;
  }
  return token.slice("linear:".length);
}

export default defineChannel({
  // The HMAC check is the request auth, so admit everything at the route.
  auth: [allowAll()],
  routes: [
    POST("/", {
      description: "Linear webhook ingress",
      // The payload shape varies by `Linear-Event`; parse after verifying.
      bodySchema: z.unknown(),
      handler: async (request, { send, waitUntil }) => {
        const rawBody = await request.text();
        if (!verified(rawBody, request.headers.get("linear-signature"))) {
          return Response.json({ ok: false }, { status: 401 });
        }
        const event = JSON.parse(rawBody) as LinearWebhook;
        // Reject stale deliveries to guard against replay.
        if (Math.abs(Date.now() - event.webhookTimestamp) > 60_000) {
          return Response.json({ ok: false }, { status: 401 });
        }
        // Skip the agent's own comments so replies don't re-trigger it.
        if (event.type === "Comment" && event.data.userId === agentUserId) {
          return Response.json({ ok: true });
        }

        // New issue → new session. New comment → follow-up on the same
        // session, keyed by issue id through the continuation token.
        let issueId: string | undefined;
        let message: string | undefined;
        if (event.type === "Issue" && event.action === "create") {
          issueId = event.data.id;
          message = `New Linear issue: ${event.data.title}\n\n${
            event.data.description ?? ""
          }\n${event.url ?? ""}`;
        } else if (event.type === "Comment" && event.action === "create") {
          issueId = event.data.issueId;
          message = event.data.body;
        }
        if (issueId === undefined || message === undefined) {
          return Response.json({ ok: true });
        }

        // Linear retries on any non-200 and times out after five
        // seconds: ACK now, run the turn in the background.
        waitUntil(send(message, { continuationToken: `linear:${issueId}` }));
        return Response.json({ ok: true });
      },
    }),
  ],
  events: {
    async "message.completed"(event, channel) {
      if (event.data.finishReason === "tool_call" || event.data.text === "") {
        return;
      }
      const issueId = issueIdFromToken(channel.continuationToken);
      if (issueId !== null) {
        await postComment(issueId, event.data.text);
      }
    },
    async "turn.failed"(event, channel) {
      const issueId = issueIdFromToken(channel.continuationToken);
      if (issueId !== null) {
        await postComment(issueId, `Turn failed: ${event.data.message}`);
      }
    },
  },
});

The channel handles ingress and reply delivery deterministically. To let the model read and update Linear during the turn (search related issues, change state, assign), add the Linear MCP connection alongside it:

ts
// agent/mcp-connections/linear.ts
import { defineConnection } from "@cursor/july/connections";

// Uses the signed-in Cursor account's Linear connector.
export default defineConnection({ cursorAccount: true, servers: ["Linear"] });

See MCP connections for the direct https://mcp.linear.app/mcp form when the host isn't signed in to Cursor.

Production Linear agents: expiring OAuth tokens

A plain API key fits a personal integration. A production Linear agent is an OAuth application: installing it in a workspace mints an access token with an expiry plus a refresh token. The serve host can be replaced at any time, so the pair can't live in process memory. Persist it in host.kv instead. Route handlers receive host in their args, and event handlers get the same services on ctx.

Two prerequisites:

  • Keep the OAuth client id and client secret in deployment secrets. They're static, so deploy-time env vars fit them.
  • Back kv with durable storage. On Cursor-managed hosting, use cursorHostedStorage in agent/storage.ts (Storage). Without it, kv falls back to local disk and a host replacement drops the tokens.

Save the pair when the install flow completes, then refresh on demand:

ts
import type { HostKvApi } from "@cursor/july/kv";

interface LinearTokens {
  accessToken: string;
  refreshToken: string;
  expiresAtMs: number;
}

const TOKENS_KEY = "linear/oauth-tokens";

export async function linearAccessToken(kv: HostKvApi): Promise<string> {
  const tokens = (await kv.get(TOKENS_KEY)) as LinearTokens | undefined;
  if (tokens === undefined) {
    throw new Error("No Linear tokens stored. Complete the install flow first.");
  }
  if (Date.now() < tokens.expiresAtMs - 60_000) {
    return tokens.accessToken;
  }
  const response = await fetch("https://api.linear.app/oauth/token", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: tokens.refreshToken,
      client_id: process.env.LINEAR_CLIENT_ID ?? "",
      client_secret: process.env.LINEAR_CLIENT_SECRET ?? "",
    }),
  });
  if (!response.ok) {
    throw new Error(`Linear token refresh failed: ${response.status}`);
  }
  const next = (await response.json()) as {
    access_token: string;
    refresh_token?: string;
    expires_in: number;
  };
  const rotated: LinearTokens = {
    accessToken: next.access_token,
    refreshToken: next.refresh_token ?? tokens.refreshToken,
    expiresAtMs: Date.now() + next.expires_in * 1000,
  };
  await kv.put(TOKENS_KEY, rotated);
  return rotated.accessToken;
}

Then authorize GraphQL calls with Bearer ${await linearAccessToken(host.kv)} in place of the static apiKey above. Writes to host.kv await the durable sink and propagate errors, so a failed save surfaces instead of silently losing the rotated refresh token.

To test without a public URL, save a payload from the webhook's delivery log (or the sample in Linear's webhook docs) under fixtures/, refresh its webhookTimestamp, and sign it yourself:

bash
SIG=$(node -e 'const {createHmac}=require("node:crypto");const fs=require("node:fs");
process.stdout.write(createHmac("sha256",process.env.LINEAR_WEBHOOK_SECRET)
  .update(fs.readFileSync("fixtures/issue-create.json")).digest("hex"))')
curl -X POST http://127.0.0.1:3000/<slug>/v1/channels/linear/ \
  -H 'content-type: application/json' -H "linear-signature: $SIG" \
  --data-binary @fixtures/issue-create.json

Test a channel

Start with curl and saved payloads under fixtures/. The playground's Try modal covers manual probes. It remembers your last body per endpoint, has Copy curl, and opens the created session on a successful Try. For regression coverage, drive the same behavior through an eval, or keep channel logic deterministic in agent/lib/ and unit-test it there. When something looks wrong, read the session's events.ndjson. The stream is the record of what happened.

For GitHub specifically, don't hand-roll fixtures. agent-sdk github replay synthesizes real-shaped, signed payloads from any PR you can read. See the GitHub guide.

What's next

Continue with these pages: