Build plugins

Hooks

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

A hook is a function of your plugin that the platform asks at a set moment: before a tool call, after it, when a job starts, when it ends. It answers allow, deny, modify or escalate. Use hooks for checks of your own: a risk score from your own model, a rule engine, a policy on which repositories an agent may push to, a signature on every answer.

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

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

server = MCPServer("policy")


@server.tool()
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.

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 github.merge was not made: hook 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.

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.

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. Hooks fail closed unless you say otherwise.

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