Skip to main content

Command Palette

Search for a command to run...

BDK

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.

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

MemberWhat it is
ctx.sessionRead-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.kvDurable JSON, shared by every session of the agent. Prefix keys with ctx.session.id for per-session state
ctx.host.filesDurable files, bound to this session. Pass { scope: "deployment" } for agent-wide files
ctx.host.otelCounters, histograms, and tags, attributed to this session
ctx.host.mcp, ctx.host.github, ctx.host.slackThe same shared clients tools get
ctx.host.remindersPer-session reminders, the same API tools get
ctx.artifactsSession-bound artifacts facade: tag fills in sessionId and turnId
ctx.stateRootAbsolute 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.

RuleWhat happens
Same sessionEvents dispatch one at a time
Eval sessionsThe same stream fires; skip metering or paging when ctx.session.purpose === "eval"
Host restartRecorded events are not replayed into hooks, so a mirror needs no dedupe
Slow handlerHolds 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:

HooksChannel eventsEvals
Scopeevery session of the agentsessions the channel ownsone test turn
Jobobserve: audit, metrics, mirrors, derived statedeliver: replies back to the channel's surfaceassert: gates over the trajectory
Contextctx.host, ctx.artifacts, session infochannel.state, setContinuationToken, ctx.host, session infothe t assertion helpers
Can affect the runnoyes, it owns the surfacen/a
Authored atbot/hooks/*.tschannel configevals/**/*.eval.ts

Hook boundaries

You want toUse instead
Add context before the model runsThe 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 deliveryThe 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 behaviorEvals

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.

CommandWhat it reports
bdk validate --dir .Discovery errors and the empty-handlers warning
bdk info --dir . --jsonLoaded 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.