# Hooks

A hook subscribes to selected session events and can run a side effect
after each matching event is recorded: an audit line, a metric, a
transcript copy, or derived state. 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.
That makes hooks safe to add to a production agent, and the wrong
surface for anything that must run before the model or must fail a
turn; see [Hook boundaries](#hook-boundaries).

`defineHook` is unrelated to
[Cursor Agent hooks](https://cursor.com/docs/agent/hooks), 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 one meters tokens:

```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 outputTokens = meter.createCounter("acme.tokens.output");
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;
      }
      const usage = event.data.usage;
      inputTokens.add(usage.inputTokens);
      outputTokens.add(usage.outputTokens);
    },
    async "turn.failed"(_event, ctx) {
      turnFailed.add(1, { channel: ctx.channel.id });
    },
  },
});
```

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

`agent-sdk init` scaffolds `bot/hooks/memory.ts`, which exports
`memoryHook()` from `@grok/agent-sdk/memory` and journals every turn for
later sessions to read. Delete the file to opt out. See
[Memory](/docs/reference/memory.md).

## Events and payloads

Keys are event types from the
[event vocabulary](/docs/reference/sessions.md#stream-events), or `"*"`
for every event. A typed key narrows `event.data`; a `"*"` handler
receives the union, so switch on `event.type`. Every event carries the
stream envelope `{ type, index, sessionId, turnId?, at, data }`, with
`turnId` set on turn-scoped events. Skip `*.appended` deltas when you
want the final text; `message.completed` already has it.

| 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 once that step has listed its tools. A single tool is a batch of one. `parentCallId` marks subagent work |
| `actions.completed`    | `{ results: [{ callId, toolName, output?, isError, stubbed?, parentCallId? }], parentCallId? }`. Pair with `actions.requested`; emitted when every call in that batch has a result                          |
| `action.result`     | `{ callId, toolName, output?, isError, stubbed?, parentCallId? }`. Live per-call envelope for chips and spans. `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. `reason` is a class such as `transport` or `http_503` |

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](/docs/reference/schedules.md#reminders), the same API tools get                                                                                  |
| `ctx.artifacts`                                     | Session-bound [artifacts](/docs/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 and
never sees an event that wasn't recorded.

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

Read hosted secrets inside the handler, not at module scope. Give
outbound calls a timeout; a stalled request holds later handlers on that
session. Skip cancelled turns when paging; they record interrupted work.

```ts
// bot/hooks/page-on-failure.ts
import { defineHook } from "@grok/agent-sdk/hooks";

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

## Hooks vs channels vs evals

All three consume the same stream, for different jobs:

|                    | Hooks                                           | Channel `events`                                                  | Evals                             |
| ------------------ | ----------------------------------------------- | ----------------------------------------------------------------- | --------------------------------- |
| Scope              | every session of the agent                      | sessions the channel owns                                         | one test turn                     |
| Job                | observe: audit, metrics, mirrors, derived state | deliver: replies back to the channel's surface                    | assert: gates over the trajectory |
| Context            | `ctx.host`, `ctx.artifacts`, session info       | `channel.state`, `setContinuationToken`, `ctx.host`, session info | the `t` assertion helpers         |
| Can affect the run | no                                              | yes, it owns the surface                                          | n/a                               |
| Authored at        | `bot/hooks/*.ts`                              | channel config                                                    | `evals/**/*.eval.ts`              |

## 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](/docs/guides/github.md)                 |
| Gate a change on behavior                                                             | [Evals](/docs/evals.md)                                                                                             |

To act on a turn's decision, such as a label or a ticket, have the model
record it with an authored [tool](/docs/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`](/docs/reference/cli.md#logs).

## Related

- [Hooks guide](/docs/guides/hooks.md): meter usage and page on failure
- [Sessions and streaming](/docs/reference/sessions.md): the event vocabulary hooks observe
- [OpenTelemetry](/docs/guides/opentelemetry.md): OTLP traces and metrics
  from the same event stream
- [Deployment](/docs/deployment.md#observability): runtime logs and export
  paths
- [Channels](/docs/reference/channels.md#events): the delivery-side counterpart
