Hooks
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:
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] }
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
| 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.
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 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 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.
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.