Hooks
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:
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] }
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
| Event | When | event holds | modify replaces |
|---|---|---|---|
pre_tool_call | Before a tool function runs | tool, arguments | arguments |
post_tool_call | After it ran, before the model sees the result | tool, arguments, result | result |
job_start | When a job starts, before the agent does anything | input | input |
job_end | When the agent has answered, before the job ends | output | output |
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 readsThe 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 withHookDeniedand the reason.modify: go ahead with the new value under the key in the table above.escalate: a person decides, as an approval of kindhookwith your reason; yes counts as allow, no as deny. An escalate may also carry a new value under the same key asmodify: 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):
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:
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.
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.