Skip to main content

Command Palette

Search for a command to run...

BDK

Webhook agents

A custom channel turns an event from another system into an agent conversation, then sends the result back to the system that started it. Use one when the built-in HTTP API does not match the provider's payload, conversation key, or reply path.

Author bot/channels/<id>.ts. Its routes mount under /<slug>/v1/channels/<id>.

Receive a webhook and send the answer back

When a ticket system sends a new message, this endpoint responds with 202 Accepted and an empty body right away. The agent keeps working after the HTTP request ends, and its completed answer appears as a comment on the ticket.

// bot/channels/tickets.tsimport { defineChannel, POST } from "@cursor/bdk/channels";import { z } from "zod";import { postTicketComment } from "../lib/tickets.js";type TicketState = {  ticketId: string;};export default defineChannel<TicketState>({  state: { ticketId: "" },  routes: [    POST("/ticket", {      description: "Answer a new ticket message",      bodySchema: z.object({        ticketId: z.string(),        message: z.string(),      }),      handler: async (_request, { send, waitUntil, body }) => {        waitUntil(          send(body.message, {            state: { ticketId: body.ticketId },            title: `Ticket ${body.ticketId}`,          })        );        return new Response(null, { status: 202 });      },    }),  ],  events: {    async "message.completed"(event, channel) {      if (event.data.finishReason === "tool_call" || event.data.text === "") {        return;      }      await postTicketComment(channel.state.ticketId, event.data.text);    },  },});

postTicketComment is a small wrapper around the ticket provider's SDK. Keeping that write in the event handler means the webhook request does not stay open while the model works.

The caller only ever receives that empty 202. On Cursor-managed hosting, the deployment sends it before your handler runs and discards whatever the handler returns, so deliver every result, including failures, back through the provider's API. See Route responses.

Keep one conversation per ticket

Without a conversation key, every delivery creates a new session. Add the ticket ID as the continuationToken so the opening message and all later comments resume the same conversation, while a different ticket starts its own.

waitUntil(  send(body.message, {    continuationToken: `ticket:${body.ticketId}`,    state: { ticketId: body.ticketId },    title: `Ticket ${body.ticketId}`,  }));

The channel owns this key. Callers only need to send the domain ID they already know.

Prepare evidence before the turn

Webhook payloads often contain little more than an object ID. This handler fetches the full ticket through a deterministic tool and writes the result to ticket.json, so the model begins with a stable snapshot instead of spending the turn looking for context.

handler: async (_request, { callTool, send, waitUntil, body }) => {  const prepared = await callTool("prepare_ticket", {    ticketId: body.ticketId,  });  if (prepared.isError) {    await postTicketComment(      body.ticketId,      "I couldn't load this ticket, so I didn't draft a reply."    );    return new Response(null, { status: 202 });  }  waitUntil(    send("Read ticket.json, then draft the reply.", {      continuationToken: `ticket:${body.ticketId}`,      state: { ticketId: body.ticketId },      workspaceFiles: {        "ticket.json": JSON.stringify(prepared.result, null, 2) ?? "null",      },    })  );  return new Response(null, { status: 202 });},

The same message.completed handler from the first example delivers the answer back to the ticket. When preparation fails, the handler reports that on the ticket instead.

Routes receive, events deliver

Routes own inbound HTTP. A route validates the request, decides whether to start or resume a turn, and acknowledges the caller with 202. The channel's events handlers receive session events later, which is where you deliver the model's answer or report a failure to the originating system.

Validate payloads with Zod

POST, PUT, and PATCH routes require a Zod bodySchema; GET requires a querySchema. Validation runs before the handler, and the parsed value arrives as typed body or query. Use z.unknown() when the provider controls a variable payload and your own parser will narrow it.

The route description and schema also appear in GET /v1/info. The playground uses them to add a Try action and a matching slash command, so you can exercise the same handler without building a separate test UI.

Use handler context

The handler context keeps deterministic setup and model work in one route:

  • send starts or resumes the agent conversation.

  • callTool runs an authored server tool without starting a model turn.

  • waitUntil keeps background work alive after the handler returns. Await send when later handler code needs the session handle, such as calling session.waitForCompletion() before reporting the turn outcome to the provider.

  • host provides authenticated services for API calls made by channel code.

The full handler API lives in the Channels reference.

Protect public webhooks

Custom routes accept direct loopback requests by default. They reject forwarded requests, so a tunnel or reverse proxy does not silently turn a local development endpoint into a public one.

Before accepting remote traffic, choose an explicit boundary. bearerAuth() works when the sender can attach an authorization header. On managed hosting, webhook providers often cannot attach the agent alias token, so publicEndpoint() lets that custom channel bypass it. That opens the route to every caller; verify the provider signature before starting a turn or calling a tool.

import { defineChannel, POST, publicEndpoint } from "@cursor/bdk/channels";import { z } from "zod";import { verifyProviderSignature } from "../lib/tickets.js";export default defineChannel({  auth: [publicEndpoint()],  routes: [    POST("/", {      description: "Signed ticket webhook",      bodySchema: z.unknown(),      handler: async request => {        const rawBody = await request.text();        const signature = request.headers.get("x-provider-signature");        if (!verifyProviderSignature(rawBody, signature)) {          return new Response(null, { status: 202 });        }        // Parse and dispatch only after the signature passes.        return new Response(null, { status: 202 });      },    }),  ],});

Because a hosted handler runs after the 202, a delivery with a bad signature is dropped silently rather than rejected. When the provider sends a shared secret header or signs the raw body with HMAC-SHA256, use sharedSecretAuth() or hmacSignatureAuth() instead. Hosting checks those policies before the handler runs and answers a bad delivery with 401.

publicEndpoint() only opens this channel's routes. The built-in session and tool APIs stay private. See Auth policies for bearer tokens and the complete policy reference.

Test locally

Run the agent in development mode, then replay the same payload the provider will send. The route should answer 202 with an empty body right away, and the resulting session should appear in the playground while the reply is delivered.

bdk serve --dir . --devcurl -sS -X POST \  http://127.0.0.1:3000/<slug>/v1/channels/tickets/ticket \  -H 'content-type: application/json' \  -d '{"ticketId":"ENG-142","message":"CSV export returns 500"}'

Keep representative payloads under fixtures/ and pass them with --data-binary @fixtures/<event>.json. You can also open the route's Try action in the playground for manual probes.