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 are guardrail code that runs at the runtime's lifecycle boundaries. Where a tool is a capability the model chooses to call, a hook sees (and may allow, block, modify, or gate) every input, tool call, and output, whoever produced it: chat, an intent, a skill, or a delegated agent. That makes hooks the right place for hard rules that must hold platform-wide: data-loss prevention, redaction, write policies, permission checks against your own stores.

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 is authoritative for what the hook binds to and what it may do; the module only supplies the decision function. The runtime discovers every subfolder of hooks/ automatically; there is no registration list.

Ship the handler as .mjs. Unlike intents and tools, the hook loader imports the module directly (no esbuild step), so plain ESM JavaScript is the reliable format. (index.js and index.ts are probed too, but a .ts handler only works where the host process can already import TypeScript.)

hook.json

{
  "name": "ready-to-quote-guard",
  "points": ["preToolUse"],
  "capabilities": ["store:read", "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).
capabilitiesyesWhat the hook is permitted to do (array; see the vocabulary below). Mandatory even though enforcement is still rolling out: it is the basis of the trust model for third-party hooks.
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". Reserved for the package trust model; not yet enforced.

The loader validates the manifest on startup. A malformed hook (bad JSON, unknown point or capability, missing handler) is skipped with a hook_skipped warning; it does not disable the other hooks. Two hooks with the same name keep the first one loaded (local hooks win over package hooks) 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 currently 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 ACL (access.json), so a hook can block or rewrite args first; the ACL then has the final, authoritative say (and re-evaluates rewritten args).

A few payload fields are ahead of the loop today: every live turn enters through the chat seam, so preInput currently always fires with source: 'chat' and preOutput with channels: ['chat']; preToolUse's isDelegated and planModeActive are declared in the contract but not yet populated. Branch on them only as forward-compatibility.

When any hook is loaded (whatever points it binds to), assistant text is buffered instead of streamed token by token: a preOutput hook cannot unsend text already on the wire, so the runtime holds each turn's text, runs the chain, and emits the result as one block.

postTurn is defined in the contract and accepted by the loader, but the live loop does not invoke it yet; today you can only exercise it through the test endpoint. Treat it as reserved for observability.

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 ${payload.toolName}: docs incomplete`);
        return { action: 'block', reason: 'A submission with missing documents cannot be ready-to-quote.' };
      }
      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
configResolved config for this hook.
env(name)Read an allowed environment variable / secret by name.
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.
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.

A common pattern is a permission hook that gates a tool on your own membership store: check ctx.caller.userId against a team_members row via ctx.store.get, allow on a matching role, block otherwise, with failPolicy: "closed" so a failed read never fails open.

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

modify details worth knowing: a preInput modify rewrites the text everything downstream sees (context, persistence, intent matching, the LLM), and the client is notified so the optimistic bubble can update; a preOutput modify is persisted to history, so the model's own next-turn view matches what the user saw; a preToolUse modify rewrites args before the ACL evaluates them.

Failure behavior

Each hook invocation is capped by a wall-clock timeout (default 1500 ms). A hook that throws, rejects, or times out 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

Declared in the manifest; must cover what run actually does.

CapabilityGrants
reads_inputReads incoming message text (preInput).
modifies_inputRewrites incoming message text (preInput modify).
reads_outputReads assistant output (preOutput, postTurn).
modifies_outputRewrites assistant output (preOutput modify).
reads_tool_ioReads tool args/results (preToolUse, postToolUse).
modifies_tool_ioRewrites tool args/results (modify at tool points).
gates_toolsCan block or require confirmation on a tool call.
networkMakes outbound network calls (a sidecar or external policy engine).
secretsReads runtime secrets via ctx.env.
store:readReads the agent's own stores via ctx.store (read-only). This one is enforced today: without it, ctx.store is absent.

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 simply 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 use list that names no hooks.<name> entries opts the package out of contributing hooks. 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

The runtime exposes a deterministic test runner so you can exercise a hook against a fixture payload without driving a real turn:

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

The response reports the combined decision (action, reason), the input and output payloads, whether the payload was modified or blocked, which hooks ran, and any ctx.log lines.

Two cautions before you ship one:

  • Fail-closed hooks are load-bearing. A preToolUse guard with failPolicy: "closed" that starts throwing will block every call it binds to. Keep handlers small, dependency-free, and fast (well under the 1500 ms cap).
  • Hooks run in-process. The local hooks/ path is author-owned code, installed by consent; sandboxing and trust-tier gating apply to third-party packages as that ecosystem opens up.