Appearance
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 @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).
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.
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 @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, 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.
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 |
| 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 |
|---|---|
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.
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