Parts

Hooks

Your own checks before and after tool calls and at the start and end of a job.

For: plugin authors.

Add a hook to check a tool call before or after it runs, or a job when it starts or ends. A hook is a plugin function that returns allow, deny, modify or escalate.

Use hooks for checks specific to your organisation, such as a risk model, a rule engine, allowed push destinations or a signature added to each answer.

A hook is an ordinary MCP function of one of your plugin's tools, named with call:

oz0-plugin.yaml
name: acme-policy
tools:
  - id: acme_policy
    mcp: ["python", "-m", "acme_policy.server"]
hooks:
  - on: pre_tool_call
    call: acme_policy.check       # the function that answers
    match: { tools: ["acme_github.merge", "acme_github.push"] }
    timeout: 2s
    on_timeout: escalate
  - on: job_end
    call: acme_policy.check
    match: { agents: [acme-reviewer] }
src/acme_policy/server.py
from mcp.server import MCPServer
from mcp.types import ToolAnnotations

server = MCPServer("acme-policy")


# A hook is a call too: since v1.0 a function that declares nothing waits for a person before it is asked.
@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def check(event: dict) -> dict:
    """Decide on a call or a job."""
    if event["event"] == "pre_tool_call" and event["arguments"].get("branch") == "main":
        return {"decision": "escalate", "reason": "a push to main"}
    if event["event"] == "job_end":
        return {"decision": "modify", "output": event["output"] + "\n\nChecked by policy."}
    return {"decision": "allow"}

A plugin's hooks watch the tenant's agents from every plugin, not only its own.

Events

EventWhenevent holdsmodify replaces
pre_tool_callBefore a tool function runstool, argumentsarguments
post_tool_callAfter it ran, before the model sees the resulttool, arguments, resultresult
job_startWhen a job starts, before the agent does anythinginputinput
job_endWhen the agent has answered, before the job endsoutputoutput

Every event also has event (its name), job and agent. A harness's shell call that the platform's command rules decide after the hooks also has commands: platform, so a hook that checks commands itself can leave it to them.

Decisions

The function returns an object with decision and, if you like, reason:

  • allow: go ahead.
  • deny: before a call, it is not made and the model reads The call to acme_github.merge was not made: hook acme_policy.check denied it: <reason>.; after a call, the model gets that instead of the result; at the start or end of a job, the job fails with HookDenied and the reason.
  • modify: go ahead with the new value under the key in the table above.
  • escalate: a person decides, as an approval of kind hook with your reason; yes counts as allow, no as deny. An escalate may also carry a new value under the same key as modify: the person decides on that value, the approval keeps it, and a yes goes on with it. A redacting hook uses this so that what it redacted stays redacted while the rest waits for a person. A hook that may only escalate cannot change the value this way.

Several hooks on one event run in the order of their plugins, then of the manifest. The first deny ends it, and each modify feeds the next hook.

Hooks that may only escalate

A hook that weighs a call, such as one that asks a model whether a call looks right, can be fooled by what it reads. Declare decisions: [allow, escalate] and it is an escalate-only hook: it can only let a call through or send it to a person.

The guardrails plugin's intent check and its command guard, which reads a harness session's shell commands and sends a catastrophic one such as rm -rf ~ to a person, are hooks of this kind, with no match, so they see every call once that plugin is installed (nothing installs it by default; the command guard acts only on functions named Bash, bash and execute, and leaves them to the platform's command rules, which decide shell commands on every node by default, when the runtime says it decides them itself):

oz0-plugin.yaml
hooks:
  - on: pre_tool_call
    call: acme_policy.judge
    decisions: [allow, escalate]
    timeout: 20s

Any other answer of such a hook, deny or modify, an answer without a valid decision, a failure and a timeout, counts as escalate, with the hook's reason and a note that it was turned: it never refuses a call or changes its arguments. Its on_timeout is escalate (lint refuses any other), and [allow, escalate] is the only list lint accepts.

A pre_tool_call event of such a hook also carries request: the job's input as text, at most 4 KiB, so the hook can compare the call with what the job was asked to do, and, when it is known, agent_model: the model the job's agent runs on, as {"provider": "<provider record>", "model": "<model>"} after the tenant's aliases and mapping rules. A hook that asks a model must not ask the model that made the call, which tends to approve its own work: compare it, and escalate when they are the same, as the guardrails plugin's intent check does.

A harness that chooses its own model gives none. Other hooks get neither.

Hooks come before the rules

pre_tool_call hooks run after a call the tenant's tool policy refuses outright (that call is not made, and no hook is asked), and before everything else the policy decides: conditions on the arguments, floors, where arguments came from, sequence rules and approvals. So a hook's allow skips no rule, and a person's yes to a hook's escalate is not an approval the rules ask for: the call still waits for those. A hook's modify changes the arguments the rules then decide on.

Known secrets in a result: scan

A hook with scan: [secrets] learns where the tenant's own secrets are in the value of its post_tool_call or job_end event, without their values. Before the hook is asked, the node scans the result or the output with the fingerprints of the tenant's secrets, as it scans a model's arguments, and hands the hook what it found in the MCP request's _meta under oz0/secrets: a list of {path, start, end, name, form}, where path is the JSON pointer of the string (/result, /output/items/0/body), start and end are positions in characters, name is the secret's name and form its encoding.

Every byte is scanned, an object's keys too, up to 16 MiB of text; a value with more, one that carries more than 100 of the tenant's secrets, one with a secret as an object's key (which no JSON pointer can name), or one the node cannot scan because its fingerprints have not arrived or cannot be read, is not handed to the hook, which then counts as failed (its on_timeout, deny by default, decides).

The hook already holds the text; what it learns is which part is a secret and that secret's name, so it can redact it by name.

That is more than the text alone tells a hook, so oz0 plugin inspect lists it among the plugin's permissions ("it is told where the tenant's known secrets are"): install a plugin with such a hook only when you trust it with that. A hook answered by a remote (url) tool may not ask for it, since the findings would leave the node.

Calling a model from a hook or a tool: uses_model

A tool whose functions, or hooks, need a model declares why:

oz0-plugin.yaml
tools:
  - id: acme_policy
    mcp: ["python", "-m", "acme_policy.server"]
    uses_model:
      purpose: "the judge asks whether a call fits the job's request"

Each call of its functions then brings a key for the node's own gateway path in the MCP request's _meta under oz0/model: {token, url, anthropic_base_url, openai_base_url, purpose}. Call the model through those addresses with the token as the API key, as any SDK would. The key is made for that call's job and agent: the model call is metered and budgeted to the job, recorded with its other model calls, and under the tenant's data rules and model allowlist. It reaches only the gateway's model routes, it holds no provider key, and it is good for that job only.

The server's environment gets the addresses, never a key. A remote tool (url) and a flow tool cannot declare it: their calls do not run on the node, so the key would leave it.

src/acme_policy/server.py
import anthropic
from mcp.server.mcpserver import Context


@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def judge(event: dict, ctx: Context) -> dict:
    """Ask a model whether the call fits the job."""
    key = (ctx.request_context.meta or {}).get("oz0/model")
    if not key:
        return {"decision": "escalate", "reason": "no model key came with the call"}
    client = anthropic.Anthropic(api_key=key["token"], base_url=key["anthropic_base_url"])
    ...

A plugin with such a tool needs the tenant's grant: an install is refused until a tenant admin accepts it with oz0 plugin install ... --accept-model (audited plugin.reads.granted with kind model), oz0 plugin list shows it, oz0 plugin revoke-reads <plugin> model takes it back from the next model call, and oz0 dev grants it in its dev tenant. Without the grant the gateway refuses the call with 403 and records why. A call made where the node cannot make a key (an older node or edge) gets no oz0/model: say so in your answer, as above.

Timeouts and failures

A hook has timeout to answer, 2 seconds by default and 30 at most. When it does not answer in time, fails, or answers something without a valid decision, its on_timeout applies: allow, deny (the default) or escalate (always escalate for a hook that may only escalate). Hooks fail closed unless you say otherwise.

Hooks in policy-path coverage

oz0 plugin test --coverage counts each of the plugin's hooks as a decision point of kind hook, named <event>:<tool>.<function>: its branches are allowed and escalated for a hook that may only escalate, and allowed and refused for the others. modified, and a hook's escalated when it may answer anything, are shown as observed but not counted, since whether a hook changes or escalates a value depends on what the case's calls carry.

Where they run

Hooks run on the job's home node, the node where its tools run, as tool calls from the job's workflow: each one is in the job's history with its answer, and a replay does not call it again. Which hooks apply is read once, when the job starts, from the node that takes it.

Tool calls inside a harness session reach pre_tool_call hooks too, named <harness>.<tool>, such as claude-agent-sdk.Bash, so match: { tools: ["claude-agent-sdk.Bash"] } sees every command a Claude session runs.

post_tool_call for harness tools, and pre_llm_call, are accepted by the manifest and do not run yet.
Copyright © 2026