How a tool call is decided
For: cluster operators and plugin authors.
A model's call to a tenant MCP function passes checks in three places: the job workflow on its home node, the edge that sees the scheduling command, and the home node's activity that makes the call. The reference below follows the code's execution order, including the checks after a call.
Each check says what it decides and what it can answer:
| Answer | What happens |
|---|---|
| Refuse | The call is not made, and the agent reads why. |
| Ask | A person decides before the call goes on. |
| Change | The call goes on with other arguments: a hook's modify, or a person who edited the call before approving it. |
| Stub | A shadow run answers from a stub instead of making the call. |
| Withhold | The call was made, and its result does not reach the model. |
The tool policy is one of these checks, not all of them. Hooks, grants, approvals, the secret scan and the kill switches are others. A call one check lets through still meets every check after it, so a person can approve a call that a later check refuses: the secret scan and the kill switch run on the node when the call is made, after the approval and after the edge.
The Code column names the file and the function that does each check, as they are in this repository: oz0_runtime/ is the node runtime's package, under node/runtime/src/, and the Go paths (edge/, runtimeapi/) are under server/internal/. The docs' own check fails when one of them no longer exists, so the list cannot drift from the code without someone noticing.
Before the gate
A model's call first meets what the job knows of the agent's run so far. A flow's tool: step and a tool called by hand have no model and no turn: they start at the gate. A flow step that names a platform built-in (oz.report_outcome, oz.evidence or oz.propose_update, in a step) meets the same checks as an agent's call of it: the routine's tool list, the tenant's refuse and approval rules for its name, a shadow run's stub, and the edge's own checks of the built-in (oz0_runtime/agents/flowbuiltins.py call).
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 1 | Tools withdrawn | Once the job tree reached its budget's soft wall, a person denied a forecast's early request, or results in a row added nothing, the agent has only its answer left: every tool is withdrawn for the rest of the run, and a call the model still makes is not made. | Refuse | oz0_runtime/agents/catalog.py _tool_caller; oz0_runtime/spend.py withdrawn |
| 2 | The budget argument | A function that takes a budget gets one when the model left it out, before anything decides the call. | Change | oz0_runtime/agents/catalog.py call_tool; oz0_runtime/fitbudget.py fill |
| 3 | The turn | Calls join their turn's cohort before awaiting anything if they may need approval: requires_approval, policy Ask, a matching pre_tool_call hook, a conditional rule, or a completed loop whose on_loop asks or stops the job. Every other non-read-only call joins too, allowing the turn's calls to wait for these decisions. | Nothing yet | oz0_runtime/agents/catalog.py call_tool, loop_decides |
| 4 | Loop detection | A call that completes a loop the agent's loop detectors find is handled as the agent's on_loop says: reported, sent to a person, or the job stops. It is handled once the call joined its turn, so the turn's approvals hold it and its turn's other calls wait for its decision. | Ask (kind loop), or the job stops | oz0_runtime/agents/catalog.py looping |
| 5 | Shadow rules | What the tenant's shadow rules would have decided is recorded, from the call as the model proposed it. | Nothing: it records | oz0_runtime/wouldhave.py reports |
The gate
The gate is everything that decides a call in the job's workflow before it is made. It runs in this order:
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 6 | Flow tools | A flow tool's call that would start more child jobs than the agent's max_children, deeper than its max_depth, or inside its own flow, is refused before anyone is asked. | Refuse | oz0_runtime/agents/catalog.py flow_tool_refusal |
| 7 | Quarantine | A quarantine child calls only functions that read and cannot send data out. | Refuse | oz0_runtime/agents/catalog.py quarantine_refusal |
| 8 | Grants | Default-deny: the agent declares the function in its tools:; no two plugins provide its tool ID; another plugin's function needs the uses: grant, read again as it is now; the tool is installed on the job's home node. | Refuse | oz0_runtime/reach.py refresh; oz0_runtime/allow.py refusal |
| 9 | A routine's tools | A routine's fire calls only the tools the routine lists. | Refuse | oz0_runtime/routines.py allows |
| 10 | The policy's plain refusal | A function the tenant's policy refuses for every call (a rule without a condition, or a type's default), when no rule with a condition names it, is not called, and no hook is asked. | Refuse | oz0_runtime/agents/catalog.py _gate |
| 11 | Shadow stubs | In a shadow run, a call that does not only read, or one a hook the run cannot ask would see, answers from a stub. | Stub | oz0_runtime/agents/catalog.py shadow_stub, unasked_guard |
| 12 | pre_tool_call hooks | The hooks that match the call, in order. A hook may allow, deny, modify the arguments or escalate to a person (an approval of kind hook); an escalate-only hook, such as the guardrails plugin's command guard and intent check, may only allow or escalate. A hook's own function is under the tenant's policy too: refused, the hook counts as failed; on Ask, a person decides before the hook is asked. | Refuse, Ask, Change | oz0_runtime/hooks.py run, _tenant_allows |
| 13 | The policy's decision | For a function a rule with a condition names, those rules decide the call on the arguments the hooks left; for any other function, what the policy decided when the job started (Allow or Ask) stands. The function's requires_approval asks unless the policy refuses. | Refuse, Ask | oz0_runtime/agents/catalog.py _gate, decide_call |
| 14 | Taint and labels | The floors: the taint-default rule and label presets raise the decision by what the job read. They never lower it. | Refuse, Ask | oz0_runtime/taintflow.py floor |
| 15 | Where arguments came from | The origin check of role-marked arguments, and the job's sequence rules. They only raise the decision. | Refuse, Ask | oz0_runtime/origingate.py decide |
| 16 | A changes approval | A call a changes approval decided before it came here goes on with that approval's binding, unless the agent's changes policy, not a person, made that decision and the tenant's policy sends the call to a person. | Nothing new | oz0_runtime/agents/catalog.py _gate, tenant_approves |
| 17 | The person | When anything above asks, a person decides, as an approval. A per-job allowance (Allow the same call in this job) answers yes without asking. When the person decides, the edge checks that they may (the deciders, four eyes) and re-checks a long wait. | Ask, Change (an edit) | oz0_runtime/agents/catalog.py approve; oz0_runtime/approvals.py Approvals.request, Approvals._allowed; runtimeapi/identity.go Service.checkDecision, Service.recheck |
| 18 | The approval's binding | The approval is bound to the function's snapshot and the policy's version as the home node has them when the person is asked. When either changed while the person decided, the person is asked again. | Ask again | oz0_runtime/agents/catalog.py current_binding; oz0_runtime/worker.py ToolActivities.binding |
| 19 | The long-wait re-check | A yes that came after a wait longer than the tenant's threshold is checked again on the home node: the grant read fresh, the function still installed, and the node's current policy, conditions included. It only refuses. | Refuse | oz0_runtime/agents/catalog.py _recheck; oz0_runtime/recheck.py check; oz0_runtime/worker.py ToolActivities.recheck |
| 20 | After approval and waits | Conditional rules, taint floors, origin requirements and sequence rules are checked again using the approved arguments and the job's current state, immediately before dispatch. A new refusal or a different required approval stops the call. | Refuse | oz0_runtime/agents/catalog.py _gate, final_policy_check |
Origin receipts are bounded. If one call has too many protected input fields to attest, split it into smaller calls. The runtime refuses an oversized attestation, and the edge refuses missing evidence for a protected argument.
The turn
A model often asks for several calls in one turn. A call of the turn that does not only read waits until every call of the turn that waits for a person is decided, and is not made when one of them was denied (one turn waits as a whole).
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 21 | The turn's barrier | The call waits for its turn's decisions, and is refused when one was denied. | Refuse | oz0_runtime/barrier.py TurnBarrier.hold |
Making the call
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 22 | The approved arguments | A call is made only with the arguments a person approved: their digest must equal the approval's. | Refuse | oz0_runtime/agents/catalog.py _dispatch |
| 23 | A flow tool's child | A flow tool's call starts its flow as a child job instead of a call on the node. The home node checks the binding, the snapshot, the kill switches and the secret scan first; the starter's quota admits the child; then the edge checks the child's start against the tree's limits and budget. | Refuse | oz0_runtime/agents/catalog.py _flow_tool; oz0_runtime/flowtools.py check_activity; oz0_runtime/childquota.py check; edge/uses_admission.go UsesCheck.childAdmission |
| 24 | The origin receipt | The call carries a receipt of its origin check and sequence verdicts, decided again on the arguments it is made with, which the edge reads. | Nothing: it records | oz0_runtime/origingate.py receipt |
At the edge
The job's workflow sends the command that schedules the call through the edge, which checks it before Temporal takes it. A refusal here fails the job, as a refusal of the policy does (ToolPolicyRefused for the policy's own). The edge checks only refusals: whether a person approved a call is the job's own history.
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 25 | The call's shape | The call names its plugin, and that plugin provides the tool; a call that runs in the job's own sandbox has that sandbox's shape. | Refuse | edge/uses.go UsesCheck.toolCall; edge/uses_ownsandbox.go ownSandboxShape |
| 26 | A hook's call | The edge checks the hook, event, caller and execution location. Policy conditions and auditing apply; grants, duties, sequences and origins do not. See hook-call details. | Refuse | edge/uses_hooks.go UsesCheck.hookCall |
| 27 | Own sandbox and quarantine | A call in the job's own sandbox is one the job may make there; a quarantine child calls only closed-world reads. | Refuse | edge/uses_ownsandbox.go UsesCheck.ownSandbox; edge/quarantine.go quarantineTool |
| 28 | The policy's conditions and refusing floors | For functions named by conditional rules or refusing floors, the edge rechecks arguments against its current tenant policy. It uses the node's exact input keys and refuses keys differing only in case. Function matching follows the node's Python fnmatchcase behavior. This check can only refuse. | Refuse | edge/uses_conditions.go UsesCheck.conditions, mayCondition; edge/uses_input.go toolCallFields |
| 29 | Duty rules | A call that would complete a pair of duties the tenant refuses for this agent. | Refuse | edge/duties_p378.go UsesCheck.duties |
| 30 | The grant and the envelope | Another plugin's function needs the job's plugin's grant, and must be within the job's authority envelope. | Refuse | edge/uses.go UsesCheck.toolCall; edge/uses_scopes.go UsesCheck.envelopeScope |
| 31 | Tree sequences and origins | The tree's within: tree sequence rules fired in another job, then the origin rules that refuse, from the call's receipt. | Refuse | edge/uses_sequence.go UsesCheck.treeSequences; edge/uses_origins.go UsesCheck.originRefusals |
| 32 | No audit entry, no call | The edge writes run.tool_intent to the audit trail; the call is not scheduled until it can. | Holds the call | edge/uses_intent.go UsesCheck.intent |
| 33 | Revoked authority | A job a live revocation reaches (a kill switch, a narrowed grant, a removed plugin) is refused, or held while a park revocation lasts. | Refuse, or hold | edge/uses_fence.go UsesCheck.fenceCommand |
Hook-call checks at the edge
The hook must belong to the named plugin and be called by the job's own agent or another agent of its plugin. It must run where the job's hooks run.
Its input must describe an event the platform asks hooks about, belonging to that job and matching the hook's kind, agent and tool filters. The header must name the same event.
No grant is needed. Policy conditions and the audit entry still apply; grant, duty, sequence and origin checks do not.
A look at a long call that runs as an MCP task is checked as the call it follows (edge/uses_tasks_p380.go UsesCheck.toolTask).
On the node
The home node's activity makes the call. Before it does:
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 34 | The binding | An edited call must fit the function's input schema; an approved call is not made when the function's snapshot or the node's current policy version differs from the one the person approved under. The agent reads that it may call again, which asks again. | Refuse | oz0_runtime/worker.py ToolActivities._binding_refusal, ToolActivities._edit_refusal |
| 35 | A drain | A node that is stopping holds the call until its workers stop; the call is made on the next runtime. | Hold | oz0_runtime/worker.py _held |
| 36 | The tool | The tool is not withheld (two plugins provide its ID), not a flow tool, its server is up, and the function is one its server listed. | Refuse | oz0_runtime/plugins.py PluginHost.call |
| 37 | The approved snapshot | The function's listing is the one the tenant approved. | Refuse | oz0_runtime/plugins.py PluginHost.check_approved |
| 38 | Kill switches | A kill switch that stops the function, its tool, the agent or the tenant, or a tool retired in the inventory. | Refuse | oz0_runtime/plugins.py PluginHost.check_killed |
| 39 | The secret scan | Arguments that carry the value of one of the tenant's secrets (calls that carry a secret). | Refuse | oz0_runtime/secretscan.py scan_arguments_async |
| 40 | Quarantine, secrets and grants | A quarantine child's call runs in a sandbox of its own with no egress, or is not made; a secret placeholder goes only where an argument binding puts it; the connection's token is added; a plugin that reads the inventory or the setup needs its grant. | Refuse | oz0_runtime/plugins.py quarantine_refusal, remote_placeholder; oz0_runtime/inventory.py fetch; oz0_runtime/setup.py snapshot |
| 41 | Kill switches again | Checked once more under the tool server's lock, right before the call goes to the server. | Refuse | oz0_runtime/plugins.py PluginHost.call |
| 42 | While it runs | A kill switch that now stops the call, and the tool's time limit, cancel it at the tool. | Stops the call | oz0_runtime/worker.py _beating |
After the call
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| 43 | An unknown outcome | A write whose outcome is unknown (its node was lost, or a task ran past its limit) waits for a person or fails the job, as the agent's on_unknown says. | Ask | oz0_runtime/unknown.py made |
| 44 | Labels and clearance | The result's labels join the job's (and may taint it); a result above the agent's clearance, or across a wall, is withheld from the model. | Withhold | oz0_runtime/labelpolicy.py withheld_why; oz0_runtime/taintflow.py join |
| 45 | The origin index | The result's values join the job's origin index, and a tree sequence rule it fired reaches the edge at once. | Nothing: it records | oz0_runtime/origingate.py joined, announce |
| 46 | post_tool_call hooks | The hooks that match may redact the result, or withhold it. | Change, Withhold | oz0_runtime/hooks.py run |
| 47 | Offload | A result over the job's offload threshold reaches the model as a stub, after the hooks saw the whole. | Change | oz0_runtime/offload.py stub_text |
Harness tools
A harness session's own tools (Claude Code's Bash or WebFetch, an ACP agent's execute) are not MCP functions of the tenant, and take a shorter path. The session asks the node's runtime whether it may run a call; only names on the job's ask list go on to the job's workflow, and every other call runs at once.
| # | Check | What it decides | Can answer | Code |
|---|---|---|---|---|
| H1 | The runtime's own requests | A session may not call the runtime's own tools this way. | Refuse | oz0_runtime/harness.py _Answers.decide |
| H1a | MCP resources | Claude's tools that list or read MCP servers' resources (ListMcpResourcesTool, ReadMcpResourceTool and their other names), whatever the agent declares: a session is offered a server's functions only. | Refuse | oz0_runtime/sessiontools.py is_resource_tool |
| H2 | Default-deny | A tool the agent does not declare in harness.tools (or its harness's default tools). | Refuse | oz0_runtime/allow.py harness_refusal |
| H2a | The MCP name | A Claude session's mcp__<tool>__<function> must name exactly one function the session was offered of its own servers, matched by the harness's spelling of the function's name. Any other is refused. | Refuse | oz0_runtime/sessiontools.py function_of |
| H2b | The node's check of an MCP function | The node's plugin host must list the function, its listing must be the approved snapshot's, and no kill switch may stop it. | Refuse | oz0_runtime/harness.py _node_refusal |
| H3 | The secret scan | The call's input carries the value of one of the tenant's secrets. It runs here, before any hook. | Refuse | oz0_runtime/secretscan.py scan_arguments_async |
| H4 | The ask list | Only names a pre_tool_call hook matches (every name, for a hook that names no tools), the agent's harness.requires_approval, the harness's shell tools (for the command rules) and, in a routine's fire or a shadow run, every name, go to the job's workflow. A shell command the harness runs without asking reaches no rule. | Nothing yet | oz0_runtime/agents/workflow.py AgentWorkflow._run_harness; oz0_runtime/agents/catalog.py harness_approvals; oz0_runtime/commandcheck.py patterns |
| H5 | Tools withdrawn and loops | As for a model's call, in the job's workflow. | Refuse, Ask | oz0_runtime/agents/workflow.py AgentWorkflow._session_loop |
| H6 | Routine and shadow | A routine's fire calls only the tools it lists; a shadow run stubs what does not only read. | Refuse, Stub | oz0_runtime/agents/workflow.py AgentWorkflow._permission |
| H7 | pre_tool_call hooks | As for a model's call. The guardrails plugin's command guard, where a tenant installed that plugin, leaves a shell command the runtime decides itself to the command rules below. | Refuse, Ask, Change | oz0_runtime/hooks.py run |
| H7a | Command rules | The home node parses the post-hook shell command (Bash, bash, ACP execute) against command rules and platform defaults, enabled without installation. A Refuse matching any part wins; otherwise the first matching rule decides. Unreadable commands ask. Approval after a long wait is rechecked against the node's current rules. | Refuse, Ask | oz0_runtime/commandcheck.py check; oz0_runtime/commandcheck_node.py decide_activity; oz0_runtime/commandrules/rules.py Decider.decide |
| H8 | The person | A name in the agent's harness.requires_approval waits for a person. | Ask | oz0_runtime/agents/catalog.py approve |
A harness's own tools meet no function rule, no floor, no edge check and no per-call kill switch check, and an approval of one has no long-wait re-check (it needs a home node); its shell commands meet the command rules (H7a), whose own re-check runs on the job's home node. The sandbox bounds what such a session reaches. A session's calls of the tenant's MCP tools, through the platform's own MCP server, take the whole path above (oz0_runtime/agents/workflow.py AgentWorkflow._session_tool).
A Claude session of an imported agent whose install opted in to harness extras runs the plugin's MCP servers itself. Its call mcp__<tool>__<function> is the plugin's function <tool>.<function>. The harness writes every character of the function's name outside letters, digits, _ and - as _, so issues.delete is mcp__jira__issues_delete, and the name is matched by that spelling.
After H2 and before H3, a name that is no function the session was offered is refused (H2a), and so is a function the node may not run (H2b).
Every other call goes to the job's workflow, whatever the ask list says. There the name is resolved again against the job's own functions, and the call meets the function's path above without being made: the routine, default-deny, the tenant's refusals, conditions, floors and origin checks, its hooks (once, with those that name only the session's spelling first) and a person, then the agent's harness.requires_approval (AgentWorkflow._session_extra, catalog.decide_session_call).
The server's result goes to the session, never to the workflow, so a clearance or a wall that withholds unlabelled results refuses the call instead.
The session is offered, of each server, the functions the node's plugin host lists with their approved snapshot, among those the job's policy knows. Two functions the session would spell alike are both left out.
Each server the session runs itself sits behind a pin that lists exactly those functions and refuses a call of any other (oz0_runtime/mcppin.py). The pin also refuses the server's resources, prompts and completions, and of the server's own requests passes only pings and the session's roots, so no resource text, prompt or sampling request reaches the session.
Of the server's answer to initialize the session hears only the protocol version, the tools and logging capabilities and the tool's name: no instructions or other text of the server's.
The pin gives every request it passes on an id of its own, and passes an answer back only by exactly that id, once, rewritten as the answer to the request it recorded; any other answer is dropped and logged.
So what the session's model reads of the server is the approved listing and the results of its calls, whatever the server writes. A relayed server offers the same functions and nothing else (oz0_runtime/guarded.py).
Which policy applies
Each check reads the tenant's tool policy at its own time:
- The job's runtime decides with the rules the job started with. A job reads the policy once, when it starts on its home node, and keeps what it decided in its history, so a job that runs for days keeps that policy, and a replay decides the same (
oz0_runtime/worker.pyjob_start_activity;oz0_runtime/agents/catalog.pyjob_policy). Grants are the exception: they are read again before each call of another plugin's function. - An approval binds to the policy the home node has when the person is asked, and to the function's snapshot. A job without a home node, such as a harness's own tools, binds to the policy it started with. When the version changes while the person decides, the person is asked again; when it changed after the yes, the node does not make the call, and the agent may call again. Neither applies the new rules: the call asked again is decided, as before, with the rules the job started with (
oz0_runtime/agents/catalog.pyapprove;oz0_runtime/worker.pyToolActivities._binding_refusal). - The long-wait re-check uses the node's current policy, and only refuses.
- Command rules are decided with the rules the job read at its start, like the rest of the job's policy: a change reaches only jobs that start after it, and a rule's end still stops it inside a running job; a yes to a command after a long wait is decided again with the node's current command rules (
oz0_runtime/commandrules/rules.pyDecider.decide;oz0_runtime/commandcheck.pycheck). - The edge uses its current copy of the tenant's policy, but only for functions that a rule with a condition or a refusing floor names, and for origin and tree sequence rules (
edge/registry_toolpolicy.goToolPolicies.For).
So a change of the policy reaches a running job like this:
| Change | Reaches a running job |
|---|---|
| A new Ask | Neither at the edge nor in the runtime: only jobs that start after it ask. |
| A new plain Refuse (no condition) | Only at a long-wait recheck using current node policy, or at the edge when the function also has a conditional rule or refusing floor. A changed approval binding triggers another approval or call, but still uses the job's starting rules, so approval lets it through. Otherwise the new refusal affects only new jobs. |
| A new conditional Refuse, a refusing floor, an origin or tree sequence rule | At the edge, at the job's next call of the function. |
| A command rule, of any action | Only in jobs that start after it, and through the long-wait re-check of a person's yes to a command. |
| A narrowed grant, a kill switch, a removed plugin | At the next call: in the runtime (the grant), on the node (the kill switch), and at the edge. |
To stop a running job's calls of a function now, use a kill switch, not a rule.
Budgets and tool calls
No budget refuses a single tool call. A job tree's budget and the tenant's quotas meter model calls:
- At the tree's soft wall, or after a person denied a forecast's early request, every tool is withdrawn and the agent can only answer (row 1).
- A flow tool's child start meets the starter's quota and the tree's budget at the edge (row 23).
- A tool's own model calls (MCP sampling, or a call through the node's gateway path) are budgeted at the gateway as the job's, like the agent's own.
See Budgets and quotas.