# Hooks

A hook subscribes to selected session events and can run a side effect after each matching event is recorded. Hooks run in the serving process for every session of the agent. They cannot change the turn, the prompt, or the reply. A handler that throws is logged and skipped.

Treat the event as read-only. Later subscribers see the same object. A hook is the wrong surface for anything that must run before the model or must fail a turn. See [Hook boundaries](https://cursor.com/docs/agent-sdk/reference/hooks.md#hook-boundaries).

`defineHook` is unrelated to [Cursor Agent hooks](https://cursor.com/docs/hooks.md), the `.cursor/hooks.json` scripts that can observe, block, or modify the agent loop. Those still run inside a local session workspace.

## Author a hook

Author `bot/hooks/<name>.ts` with `defineHook` from `@grok/agent-sdk/hooks`. This hook meters tokens and pages on failure:

```ts
// bot/hooks/usage.ts
import { metrics } from "@opentelemetry/api";
import { defineHook } from "@grok/agent-sdk/hooks";

const meter = metrics.getMeter("acme");
const inputTokens = meter.createCounter("acme.tokens.input");
const turnFailed = meter.createCounter("acme.turn.failed");

export default defineHook({
  events: {
    async "turn.completed"(event, ctx) {
      if (ctx.session.purpose === "eval" || event.data.usage === undefined) {
        return;
      }
      inputTokens.add(event.data.usage.inputTokens);
    },
    async "turn.failed"(event, ctx) {
      const pagerUrl = process.env.PAGER_WEBHOOK_URL;
      if (
        pagerUrl === undefined ||
        ctx.session.purpose === "eval" ||
        event.data.status === "cancelled"
      ) {
        return;
      }
      turnFailed.add(1, { channel: ctx.channel.id });
      await fetch(pagerUrl, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          agent: ctx.agent.name,
          session: ctx.session.id,
          message: event.data.message,
        }),
        signal: AbortSignal.timeout(5_000),
      });
    },
  },
});
```

Any module under `bot/hooks/` is a hook named by its path without the extension. `*.test.ts` and `*.spec.ts` are skipped. The default export must be `defineHook(...)`, names can't contain `__`, and an empty `events` map is skipped with a warning. `agent-sdk validate` reports all three. An extension mounts its hooks as `<ns>__<name>`, and `disableHook()` removes one. See [Adjust a mounted extension](https://cursor.com/docs/agent-sdk/reference/extensions.md#adjust-a-mounted-extension).

`agent-sdk init` scaffolds `bot/hooks/memory.ts`. Delete it to opt out of the journal. See [Memory](https://cursor.com/docs/agent-sdk/reference/memory.md).

Read hosted secrets inside the handler, not at module scope. See [Add egress and secrets](https://cursor.com/docs/agent-sdk/deployment.md#add-egress-and-secrets). Give outbound calls a timeout, because a stalled request blocks later handlers on that session.

## Events and payloads

Keys are event types from the [event vocabulary](https://cursor.com/docs/agent-sdk/reference/sessions.md#stream-events), or `"*"` for every event. A typed key narrows `event.data`, a `"*"` handler receives the union so you switch on `event.type`, and every event carries `{ type, index, sessionId, turnId?, at, data }`. Skip `*.appended` deltas when you want the final text.

| Event               | `event.data`                                                                                                                                                                                                |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message.received`  | `{ text }`                                                                                                                                                                                                  |
| `turn.completed`    | `{ result?, usage?, cost? }`. `usage` has `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, and optional `reasoningTokens`. `cost` has `totalUsd` and the `model` it was priced against |
| `turn.failed`       | `{ message, status? }`. `status` is `"error"` or `"cancelled"` when present                                                                                                                                 |
| `actions.requested` | `{ calls: [{ callId, toolName, args?, parentCallId? }], parentCallId? }`. One envelope per model step. `parentCallId` marks subagent work                                                                   |
| `actions.completed` | `{ results: [{ callId, toolName, output?, isError, stubbed?, parentCallId? }], parentCallId? }`. Emitted when every call in that batch has a result                                                         |
| `action.result`     | `{ callId, toolName, output?, isError, stubbed?, parentCallId? }`. Live per-call envelope. `stubbed` means a dry-run session answered a write without running it                                            |
| `action.tool_retry` | `{ callId, toolName, attempt, reason, connection? }`. A read is running again after a transient failure. `attempt` is the try about to run                                                                  |

The types are `SessionEvent`, `SessionEventType`, and `HookContext`, exported from `@grok/agent-sdk`.

## Handler context

| Member                                              | What it is                                                                                                                                         |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.session`                                       | Read-only session info: `id`, `channelId`, `mode` (`chat` or `task`), `purpose` (`live` or `eval`), `auth`, plus `title` and `sdkAgentId` when set |
| `ctx.agent`                                         | `{ name }` of the agent the event belongs to                                                                                                       |
| `ctx.channel`                                       | `{ id, continuationToken }`. The token is `null` when the session can't take follow-ups                                                            |
| `ctx.host.kv`                                       | Durable JSON, shared by every session of the agent. Prefix keys with `ctx.session.id` for per-session state                                        |
| `ctx.host.files`                                    | Durable files, bound to this session. Pass `{ scope: "deployment" }` for agent-wide files                                                          |
| `ctx.host.mcp`, `ctx.host.github`, `ctx.host.slack` | The same shared clients tools get                                                                                                                  |
| `ctx.host.reminders`                                | Per-session [reminders](https://cursor.com/docs/agent-sdk/reference/schedules.md#reminders)                                                        |
| `ctx.artifacts`                                     | Session-bound [artifacts](https://cursor.com/docs/agent-sdk/reference/artifacts.md) facade. `tag` fills in `sessionId` and `turnId`                |
| `ctx.stateRoot`                                     | Absolute path of the local state root. It resets when a hosted deployment is replaced. Keep derived state in `kv` or `files`                       |

## Hook dispatch

A hook runs after the event is recorded. It never delays the model.

| Rule          | What happens                                                                                                |
| ------------- | ----------------------------------------------------------------------------------------------------------- |
| Same session  | Events dispatch one at a time                                                                               |
| Eval sessions | The same stream fires. Skip metering or paging when `ctx.session.purpose === "eval"`                        |
| Host restart  | Recorded events are not replayed into hooks, so a mirror needs no dedupe                                    |
| Slow handler  | Holds the next event's handlers on that session, not the model. Keep handlers short and queue anything slow |

## Hook boundaries

| You want to                                                      | Use instead                                                                                                                      |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Add context before the model runs                                | The channel's `send` message and `workspaceFiles`, `instructions.md`, skills, or `sandbox/workspace/` seed files                 |
| Reply on Slack, comment on a PR, or post any other delivery      | The channel's `events` map, or the Slack and GitHub packs                                                                        |
| Show PR progress (merge-box check, sticky banner)                | `githubChannel({ progress: { commitStatus, banner } })`. See [GitHub agents](https://cursor.com/docs/agent-sdk/guides/github.md) |
| Gate a change on behavior                                        | [Evals](https://cursor.com/docs/agent-sdk/evals.md)                                                                              |
| Observe every session for audit, metrics, or a transcript mirror | A hook                                                                                                                           |

To act on a turn's decision, such as a label or a ticket, have the model record it with an authored [tool](https://cursor.com/docs/agent-sdk/reference/tools.md). A `turn.completed` hook then reads the recorded decision and acts on it through `ctx.host`.

## Test a hook

A hook definition is a plain object, so a unit test calls `hook.events["turn.completed"]` directly with an event and a stub `HookContext`. Discovery skips `*.test.ts`, so the test can live next to the hook.

| Command                               | What it reports                                                             |
| ------------------------------------- | --------------------------------------------------------------------------- |
| `agent-sdk validate --dir .`          | Discovery errors and the empty-handlers warning                             |
| `agent-sdk info --dir . --json`       | Loaded hooks under `agents[].hooks`                                         |
| `agent-sdk run --dir . --message "…"` | Serve log on stderr, including `hook "<name>" handler for <event> threw: …` |

On hosting, read the same log with [`agent-sdk logs`](https://cursor.com/docs/agent-sdk/reference/cli.md#follow-logs).

See also [Sessions](https://cursor.com/docs/agent-sdk/reference/sessions.md#stream-events), [OpenTelemetry](https://cursor.com/docs/agent-sdk/guides/opentelemetry.md), and [Channels](https://cursor.com/docs/agent-sdk/reference/channels.md#events).


---

## Sitemap

[Overview of all docs pages](/llms.txt)
