# 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](/docs/reference/http-api.md) 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](/docs/reference/channels.md#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 with
  `hmacSignatureAuth()` or `publicEndpoint()`, hosting answers `400`. 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 `400` for 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 `defineChannel` routes read the header. Prebuilt channels, such
  as `githubChannel()`, 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 serve` ignores the header. Keep the `continuationToken` in
  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](/docs/reference/schedules.md#reminders) 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:

- `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](/docs/reference/channels.md#handler-arguments).

## 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](/docs/reference/channels.md#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](/docs/reference/channels.md): route methods,
  handler arguments, events, state, and auth policies
- [Triage Linear or Jira issues](/docs/templates/triage.md): a complete
  signed tracker webhook with reply delivery
- [HTTP API](/docs/reference/http-api.md): built-in session, follow-up,
  stream, and tool routes
- [Deployment](/docs/deployment.md#use-the-hosted-agent): stable hosted
  URLs and alias-token behavior
- [Evals](/docs/evals.md): turn saved payloads into regression fixtures
