Appearance
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.
ts
// bot/channels/tickets.ts
import { 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 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.
ts
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.
Keep one hosted session per thread
On Cursor-managed hosting, the deployment picks the session for a custom-route request before your handler runs. By default, each request starts a new session, even when the handler passes the same continuationToken. To join requests, the sender adds the X-BDK-Session-Key header. Requests with the same value on one channel go to one session, and their turns run in the order the requests arrive.
bash
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"}'Most webhook providers cannot set this header. Put a small relay in front of the deployment that reads the thread ID from the payload and forwards the request with the header.
The header has these rules:
- The route must admit the sender with the alias token or
sharedSecretAuth(). If the route admits the sender withhmacSignatureAuth()orpublicEndpoint(), hosting answers400. An HMAC signature covers the body and not the header, so a copy of one signed request could be sent again under a different key. - The value must be 1 to 256 printable ASCII characters, with no space and no comma. Hosting answers
400for any other value, and no turn starts. - A key belongs to one channel on one deployment. The same value on a different channel or deployment starts a different session.
- Only
defineChannelroutes read the header. Prebuilt channels, such asgithubChannel(), keep their own conversation keys. - The handler still receives the header in
request.headers. - Hosting stores the value with the request. Do not put secrets or personal data in it.
- Local
agent-sdk serveignores the header. Keep thecontinuationTokenin the handler, so local runs join requests the same way.
Two limits apply:
- Hosting does not deduplicate requests. If the sender retries a request, the session runs a second turn.
- A reminder that a keyed session sets fires in that session, but it can run at the same time as a turn from a request.
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.
ts
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:
sendstarts or resumes the agent conversation.callToolruns an authored server tool without starting a model turn.waitUntilkeeps background work alive after the handler returns. Awaitsendwhen later handler code needs the session handle, such as callingsession.waitForCompletion()before reporting the turn outcome to the provider.hostprovides 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.
Under --dev, a provider channel with no webhook secret (GitHub, GitLab, Bitbucket, PagerDuty, Origin) accepts a request only when the peer is loopback and the Host header is localhost, 127.x, or ::1. Local replays and gh webhook forward still work. A tunnel sends its public hostname, so the server refuses it. To receive deliveries through a tunnel, set the channel's webhook secret (for example GITLAB_WEBHOOK_SECRET) in the provider and in your shell before you start agent-sdk dev.
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.
ts
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 });
},
}),
],
});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.
text
agent-sdk serve --dir . --dev
curl -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.
Related
- Channels reference: route methods, handler arguments, events, state, and auth policies
- Triage Linear or Jira issues: a complete signed tracker webhook with reply delivery
- HTTP API: built-in session, follow-up, stream, and tool routes
- Deployment: stable hosted URLs and alias-token behavior
- Evals: turn saved payloads into regression fixtures