Webhook agents
A custom channel turns an event from another system into an agent conversation and sends the result back to the system that started it. Use one when the built-in HTTP API doesn't match the provider's payload, conversation key, or reply path.
A file at bot/channels/<id>.ts mounts its routes under
/<slug>/v1/channels/<id>.
Receive a webhook and send the answer back
When a ticket system sends a new message, this route starts a turn and answers right away. The agent keeps working after the HTTP request ends, and its answer appears as a comment on the ticket.
// bot/channels/tickets.tsimport { defineChannel, POST } from "@grok/agent-sdk/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 wraps the ticket provider's SDK. The write lives in
the event handler, so the webhook request doesn't stay open while the
model works.
On Cursor hosting, the caller always gets an empty 202 response, and
the platform ignores whatever your handler returns. Send results and
failures back through the provider's API instead. See
Route responses.
Keep one conversation per ticket
Without a conversation key, every delivery creates a new session. Pass
the ticket ID as the continuationToken, so every message on one
ticket resumes the same conversation.
waitUntil( send(body.message, { continuationToken: `ticket:${body.ticketId}`, state: { ticketId: body.ticketId }, title: `Ticket ${body.ticketId}`, }));Keep one hosted session per thread
On Cursor hosting, each request to a custom route starts a new session,
even when the handler passes the same continuationToken. To join
requests, the sender or a small relay in front of the deployment adds
the same X-BDK-Session-Key value to each one, and those requests run
in one session in arrival order.
curl -X POST "$AGENT_ALIAS_URL/v1/channels/tickets/ticket" \ -H "X-Agent-Alias-Token: $AGENT_ALIAS_TOKEN" \ -H "X-BDK-Session-Key: ticket:ENG-142" \ -H 'content-type: application/json' \ -d '{"ticketId":"ENG-142","message":"CSV export returns 500"}'Hosting stores the value with the request, so don't put secrets or personal data in it. Hosted session keys lists the auth, format, and retry rules.
Prepare evidence before the turn
Webhook payloads often carry little more than an object ID. This
handler fetches the full ticket with a server tool and writes it to
ticket.json, so the model starts from a fixed snapshot.
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 message.completed handler from the first example still delivers
the answer.
Validate payloads with Zod
The route validates the request and passes the handler a typed body
or query. POST, PUT, and PATCH routes need a Zod bodySchema,
and GET routes need a querySchema. Use z.unknown() for a variable
payload that your own parser narrows.
Use handler context
Await send instead of passing it to waitUntil when later handler
code needs the session, for example to call
session.waitForCompletion().
Handler arguments lists
every helper.
Protect public webhooks
Custom routes accept direct loopback requests by default and reject
forwarded ones, so a tunnel or reverse proxy can't quietly make a local
endpoint public. Under --dev, a provider channel with no webhook
secret (GitHub, GitLab, Bitbucket, or PagerDuty) accepts only local
requests, so set that secret in the provider and in your shell before
you test through a tunnel.
Use bearerAuth() when the sender can attach an authorization header.
Webhook providers often can't attach the agent alias token, so
publicEndpoint() lets a custom channel skip it on Cursor hosting.
That opens the route to every caller, so verify the provider's
signature before you start a turn or call a tool.
import { defineChannel, POST, publicEndpoint } from "@grok/agent-sdk/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 }); }, }), ],});With publicEndpoint(), the handler drops a badly signed delivery
silently. If 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() opens only this channel's routes, and the built-in
session and tool APIs stay private. See
Auth policies.
Test locally
Run the agent with --dev and post the payload the provider will send.
The session appears in the playground while the reply goes out.
agent-sdk 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 sample payloads under fixtures/ and pass them with
--data-binary @fixtures/<event>.json.
Related
- Channels reference
- Review and triage templates, including a signed tracker webhook