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.
defineHook is unrelated to
Cursor 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
@cursor/bdk/hooks. This one meters tokens:
// bot/hooks/usage.tsimport { defineHook } from "@cursor/bdk/hooks";export default defineHook({ events: { async "turn.completed"(event, ctx) { if (ctx.session.purpose === "eval" || event.data.usage === undefined) { return; } const { inputTokens, outputTokens } = event.data.usage; ctx.host.otel.increment("acme.tokens.input", inputTokens); ctx.host.otel.increment("acme.tokens.output", outputTokens); }, async "turn.failed"(_event, ctx) { ctx.host.otel.increment("acme.turn.failed", { 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; bdk validate
reports all three. An extension mounts its hooks as <ns>__<name>, and
disableHook() removes one
(Adjust a mounted extension).
bdk init scaffolds bot/hooks/memory.ts, which exports
memoryHook() from @cursor/bdk/memory and journals every turn for
later sessions to read. Delete the file to opt out. See
Memory.
Events and payloads
Keys are event types from the
event vocabulary, 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 @cursor/bdk.
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.otel | Counters, histograms, and tags, attributed to this session |
ctx.host.mcp, ctx.host.github, ctx.host.slack | The same shared clients tools get |
ctx.host.reminders | Per-session reminders, the same API tools get |
ctx.artifacts | Session-bound artifacts 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.
// bot/hooks/page-on-failure.tsimport { defineHook } from "@cursor/bdk/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 |
| Gate a change on behavior | Evals |
To act on a turn's decision, such as a label or a ticket, have the model
record it with an authored tool. 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 |
|---|---|
bdk validate --dir . | Discovery errors and the empty-handlers warning |
bdk info --dir . --json | Loaded hooks under agents[].hooks |
bdk run --dir . --message "…" | Serve log on stderr, including hook "<name>" handler for <event> threw: … |
On hosting, read the same log with bdk logs.
Related
- Hooks guide: meter usage and page on failure
- Sessions and streaming: the event vocabulary hooks observe
- OpenTelemetry: OTLP traces and metrics from the same event stream
- Deployment: runtime logs and export paths
- Channels: the delivery-side counterpart