Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Hooks

Hooks inspect or modify messages and tool calls in the root chat loop. Use them for input checks, output redaction, and approval rules on model-selected calls.

Hooks are not applied inside delegated subagent loops, nested composite calls, or direct tool evals. Enforce rules that must cover those paths in the tool implementation or downstream API.

Folder shape

Hooks live at the repo root under hooks/, one folder per hook:

hooks/
└── ready-to-quote-guard/
    ├── hook.json     ← declarative manifest (binding, capabilities, config)
    └── index.mjs     ← decision module (exports createHook)

The manifest declares the binding; the module supplies only the decision function. Every subfolder of hooks/ is loaded; there is no registration list.

Ship the handler as index.mjs. The loader imports the module without bundling it, unlike tools. index.js and index.ts are also probed, but a .ts handler works only where the host process can import TypeScript.

hook.json

{
  "name": "ready-to-quote-guard",
  "points": ["preToolUse"],
  "capabilities": ["reads_tool_io", "gates_tools"],
  "failPolicy": "closed",
  "priority": 10,
  "config": {
    "guardedTools": ["store__submissions__set"],
    "blockedRecommendation": "ready-to-quote"
  }
}
FieldRequiredMeaning
namenoStable identifier, unique within the agent's hook chain. Defaults to the folder name.
pointsyesLifecycle points to bind to (non-empty array; see below).
capabilitiesyesArray of capabilities. May be empty.
failPolicyno"closed" (a failing hook blocks) or "open" (a failing hook allows). Defaults per point: closed everywhere except postTurn (open).
prioritynoNumber; lower runs first at a shared point. Default 0.
confignoAuthor-supplied defaults, passed to createHook. Overridable per agent via amodal.json (see Chain configuration).
trustTierno"first_party", "verified", or "community". Metadata only; not enforced.

At runtime, a malformed hook (bad JSON, unknown point or capability, missing handler) is skipped with a hook_skipped warning, and the other hooks still load. A hosted deploy fails instead, naming each local hooks/<name>/ folder whose hook.json is invalid or that has no handler module. The deploy check does not inspect the module's exports or hooks from installed packages. Two hooks with the same name keep the first one loaded, local before package, and log hook_name_conflict.

Hook points

PointFiresPayload
preInputbefore a user/trigger message enters context (and before it is persisted){ text, source } where source is 'chat' | 'webhook' | 'automation' | 'channel'
preToolUsebefore a tool call executes, ahead of the permission gate{ toolName, args, connection?, endpointPath?, method?, intent?, isDelegated?, planModeActive? }
postToolUseafter a tool returns, before its result re-enters context{ toolName, args, status, result, durationMs } (result is the tool's string output; structured results such as image blocks bypass this point)
preOutputbefore assistant text is streamed, persisted, or routed to a channel; runs on every turn's text, not just the final answer{ text, channels }
postTurnafter a turn completes; observe-only{ turn, usage, toolCalls, terminal }

preToolUse runs before the connection policy (policy.json). The policy then evaluates the possibly rewritten args and has the final say.

Live root turns pass source: 'chat' to preInput and channels: ['chat'] to preOutput. isDelegated and planModeActive are not populated. Do not base a permission decision on these absent fields.

When any hook is loaded, at any point, assistant text is buffered instead of streamed token by token. The runtime runs the preOutput chain on each turn's text and emits the result as one block.

postTurn is accepted by the loader and test endpoint, but the live loop does not invoke it.

Handler contract

The module exports createHook(config) (or a default export) returning the decision function:

// hooks/ready-to-quote-guard/index.mjs
 
/** @param {Record<string, unknown>} config  merged hook.json config + amodal.json overrides */
export function createHook(config) {
  const guarded = new Set(config.guardedTools ?? []);
 
  return {
    /**
     * @param {string} point    the lifecycle point that fired
     * @param {object} payload  that point's payload (see table above)
     * @param {object} ctx      HookContext
     * @returns {Promise<{action: 'allow'} |
     *                   {action: 'block', reason: string} |
     *                   {action: 'modify', payload: object, note?: string} |
     *                   {action: 'ask', reason: string}>}
     */
    async run(point, payload, ctx) {
      if (point !== 'preToolUse' || !guarded.has(payload.toolName)) {
        return { action: 'allow' };
      }
      const row = payload.args?.value;
      if (row?.recommendation === config.blockedRecommendation) {
        ctx.log(`blocked recommendation in ${payload.toolName}`);
        return { action: 'block', reason: 'This workflow cannot set the ready-to-quote recommendation.' };
      }
      return { action: 'allow' };
    },
  };
}

Accepted export shapes: a factory (config) => {run} or (config) => run, a plain {run} object, or a bare run function. run may be sync or async. A hook bound to several points receives each one through the same run and switches on point.

ctx (HookContext)

PropertyMeaning
configReserved; always {}. The resolved config (hook.json config plus amodal.json overrides) is passed to createHook(config); capture it in the factory closure rather than reading ctx.config.
env(name)Read a runtime process environment variable. The root chat hook runner does not apply an allowlist or require the secrets capability for this method.
log(message)Structured log line, scoped to the session (guardrail_hook in runtime logs).
signalAbortSignal; aborts when the session or request is cancelled.
agentId, scopeId, sessionIdIdentifiers for the current turn, when available.
callerThe verified caller of the turn: { userId?, source, orgId? }, sourced from the authenticated request (JWT claims), never from the client-supplied scopeContext. Absent for unauthenticated triggers. Use this for permission decisions.
contextThe turn's request context: string values the client sends with each turn, with token claims laid over them. Only keys pinned by a runtime token are verified, so use caller for permission decisions. Present when the turn carries context.
storeRead-only access to the agent's own stores: get(store, key) and query(store, filter?). Present only when the hook declares the store:read capability. Reads are scoped to the agent and the caller's scope.

For a permission hook, check ctx.caller.userId against your own membership store with ctx.store.get, block when no row matches, and set failPolicy: "closed" so a failed read blocks.

Decisions

run returns one of four decisions:

ActionEffect
{ action: 'allow' }No opinion; the chain continues.
{ action: 'block', reason }Refuse. Terminal: short-circuits the rest of the chain.
{ action: 'modify', payload }Replace the payload; the modified payload feeds the next hook, so transformers (successive redactors) compose. Valid at every point except postTurn.
{ action: 'ask', reason }Require human confirmation. Non-terminal: a later hook may still block. Meaningful at preToolUse, where it routes the call through the standard confirmation flow.

When several hooks fire at one point, they run in ascending priority order and their decisions combine with precedence block > ask > modify > allow.

What block concretely does at each point:

PointEffect of block
preInputThe turn is rejected before the message enters context or is persisted; the client receives an error event with your reason.
preToolUseThe tool never executes; the model sees an error tool result: Blocked by guardrail: <reason>.
postToolUseThe result is masked: the model sees [output withheld by guardrail: <reason>].
preOutputThe assistant text is replaced with [response withheld by guardrail: <reason>].
postTurnIgnored (observe-only).

A preInput modify rewrites the text that context, persistence, and the model see, and the client receives an input_redacted event. A preOutput modify is persisted to history, so the next turn sees what the user saw. A preToolUse modify rewrites args before the policy check.

Failure behavior

Each hook invocation is capped by a wall-clock timeout (default 1500 ms). A hook that throws, rejects, times out, or returns no valid decision (undefined, or an action other than allow, block, ask, or modify) is converted to a decision by its failure policy:

  • failPolicy: "closed": the failure becomes a block (reason: Hook "<name>" failed (fail-closed)). This is the default at preInput, preToolUse, postToolUse, and preOutput.
  • failPolicy: "open": the failure becomes an allow. Default at postTurn; opt into it elsewhere only for pure observability hooks.

Every non-allow decision and every failure is logged (guardrail_decision in runtime logs) with the hook name, point, action, and reason.

Capabilities

Valid values are reads_input, modifies_input, reads_output, modifies_output, reads_tool_io, modifies_tool_io, gates_tools, network, secrets, and store:read. An unknown value skips the hook.

Only store:read is enforced: without it, ctx.store is absent. The others describe intent and do not restrict the module. secrets does not gate ctx.env.

Chain configuration (amodal.json)

The agent controls the composed chain through the optional hooks block:

{
  "hooks": {
    "order": ["pii-redactor", "ready-to-quote-guard"],
    "disabled": ["verbose-audit"],
    "config": {
      "ready-to-quote-guard": { "blockedRecommendation": "ready-to-quote" }
    }
  }
}
KeyMeaning
orderExplicit chain order by hook name. Listed hooks run first in this order; unlisted discovered hooks append after, sorted by their own priority.
disabledHook names to skip entirely.
configPer-hook config overrides, keyed by hook name; merged over the hook's own hook.json config.

Hooks from packages

An installed package (declared in amodal.json packages) can contribute hooks: a hook package is a package with a hooks/ directory inside it, scanned by the same loader. Select a subset with the use selector:

{
  "packages": [{ "package": "@acme/compliance-pack", "use": ["hooks.pii-redactor"] }]
}

A bare string entry loads everything the package ships (hooks included). A non-empty use list that names no hooks.<name> entries opts the package out of contributing hooks. An empty use list loads every hook. On a name conflict, the local hooks/ folder wins. A declared but uninstalled package logs a warning rather than failing the agent.

Testing a hook

Run a hook against a fixture payload without driving a turn:

# inventory the hook manifests discovered under hooks/ and installed packages
curl $RUNTIME/inspect/hooks \
  -H "Authorization: Bearer $RUNTIME_TOKEN"
 
# run one hook (or the whole chain, omit hookName) at a point
curl -X POST $RUNTIME/inspect/hooks/test \
  -H "Authorization: Bearer $RUNTIME_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "point": "preToolUse",
    "hookName": "ready-to-quote-guard",
    "scopeId": "taxpayer:100-00-0001",
    "context": { "taxpayer_id": "TP-1001" },
    "payload": {
      "toolName": "store__submissions__set",
      "args": { "value": { "recommendation": "ready-to-quote" } }
    }
  }'

The test context has no scope unless the request passes scopeId, so a hook that compares tool arguments against ctx.scopeId needs one to be tested. Likewise ctx.context is absent unless the request passes context, an object of string values that stands in for the turn's request context. A guard fixture sets it with a context field next to scopeId.

Both routes require an API key or an operator token; an end-user token receives 403. The response reports the combined decision (action, reason), the input and output payloads, which hooks ran, and any ctx.log lines.

Local and packaged hooks run in the runtime process without a sandbox. Install only hook code you trust.