Appearance
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.
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 @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.
agent-sdk init scaffolds bot/hooks/memory.ts. Delete it to opt out of the journal. See Memory.
Read hosted secrets inside the handler, not at module scope. See 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, 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 |
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.
| 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 |
| Gate a change on behavior | Evals |
| 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. 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.
See also Sessions, OpenTelemetry, and Channels.