Skip to content

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.

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 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.

ts
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.

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"}'

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.

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 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.

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 });
      },
    }),
  ],
});

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.

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 sample payloads under fixtures/ and pass them with --data-binary @fixtures/<event>.json.