This piece is about where that check should sit, and what it should do when things go wrong.
The v1 fact: the harness asks first
In I/O Mesh, the harness calls the decision service before a tool runs. The service answers allow or deny. The answer comes from two inputs: the department's policy and the specialist card currently pinned for that agent. The harness only runs the tool on allow.
That ordering is the whole point. A check that runs after the tool is a report. A check that runs before the tool is a control.
It sounds obvious. In practice, many agent stacks end up with the check in the wrong place for ordinary reasons:
- The model's tool call gets streamed straight into an executor, and the "policy" is a system prompt.
- Logging happens in a callback that fires on tool completion, so the record exists only for calls that succeeded.
- The tool's own API enforces auth, which tells you the service account is allowed. It does not tell you this agent, acting for this person, on this task, should be.
Where the call goes in the loop
A typical harness loop looks like this. Pseudocode, not a real API:
loop:
proposal = model.next(context)
if proposal is final_answer: return
call = parse_tool_call(proposal)
decision = ask_decision_service(call, caller_token)
if decision != allow:
context.append(tool_result("denied"))
continue
result = run_tool(call)
context.append(tool_result(result))Three things about the placement:
- After parsing, before execution. The check needs the concrete tool and arguments, so it goes after you have parsed them. It must go before any side effect, including "dry run" calls that still hit a remote API.
- On every call, not per session. An agent that was fine five calls ago can propose something different now. Per-session approval turns into a blanket grant.
- On the same path as execution. If there is a code path that runs tools without passing through the check (a retry helper, a batch mode, a debug flag), that is the path the incident will use.
What the service decides, and in what order
The decision runs in two layers. First, L0: an exact deny list. If the call matches a deny entry, it stops there. Then L1: department policy evaluated with the pinned specialist card.
The caller gets a plain deny for every cause. It does not learn whether it hit L0, failed L1, or tripped a fail-closed condition. The reason goes on the audit record, not back to the agent.
That choice annoys people the first time they debug with it. It is deliberate. An agent that receives "denied: amount exceeds limit" will try again just under it. A model is very good at turning error messages into a search procedure. A flat deny gives it nothing to optimize against. Your operators still get the reason, because every decision writes one audit record with the cause.
Who is asking
The caller is not a string the agent fills in. It is the mapped subject of a verified OIDC bearer token. If your organization runs Okta or Entra, those are ordinary issuers from I/O Mesh's point of view. I/O Mesh is not an identity provider and does not try to be one.
For the harness, this means you pass the bearer you already have for the user or service the agent acts for. The decision is made against that identity. If the token does not verify, there is nothing to decide about.
Fail closed, specifically
"Fail closed" is easy to put on a slide. Here is what it means in v1. The service denies, and stores no grant, when:
- the audit write fails
- the store returns an error
- the decision deadline passes (default 5 seconds)
- confidence in the decision is uncertain
- the grant map is full
If an allow cannot be delivered to the caller, the grant is dropped. The caller gets no usable answer. The audit record for that decision is an allow; the grant does not stay.
The first one matters most. If the service cannot record the decision, it does not make one. An allow that is not on the record is the kind of allow you find out about during an audit, months later, with no way to explain it.
The last one surprises people. Active grants sit under a configurable cap. If the map is full, new requests are denied rather than evicting something or skipping the grant. Size the cap for your workload, and alert on fill level before it becomes a deny storm.
On the harness side, you need to match this. If the call to the decision service times out, errors, or returns something you cannot parse, treat it as deny. Do not wrap it in a retry loop that eventually falls through to run_tool. That is the most common way a fail-closed service becomes fail-open in practice.
What the harness should do with a deny
A deny is not an exception to crash on. It is a tool result. Feed it back to the model as one, and let the agent decide what to do next: ask the user, try a different approach, or stop.
A few patterns that hold up:
- Return a fixed string. "This action was not allowed." Do not embellish it in the harness with guesses about why.
- Cap consecutive denies. If the agent hits several in a row, end the run and hand it to a human. An agent looping against a deny is spending tokens to probe your policy.
- Surface the audit pointer to operators, not the model. Your run log can carry a reference to the decision record so an engineer can look up the reason. The model's context should not.
What it does not do
I/O Mesh does not run the tool. It does not fetch the tool endpoint, proxy the request, or hold model weights. It decides and records. Your harness still owns execution, retries, timeouts against the tool, and result handling.
That split keeps the decision service small, and it means you can add it to a harness without re-plumbing how tools are called. It also means the harness is part of your trust boundary. If someone can modify the harness to skip the check, the check does not help. Treat harness code like you treat auth middleware: reviewed, versioned, deployed through the same controls as everything else that touches production.
Latency budget
People ask whether a synchronous check in the tool loop is too slow. The decision path is meant to be small next to the model call that proposed the tool. Measure the check in your own environment, with your own policy and store, before you size anything from it.
Self-hosted, local usage
The service runs in your environment. Usage is counted locally and not sent anywhere. The service has no built-in destination; every destination is set by the operator. For a component that sees every tool call your agents attempt, that matters.
A short checklist
Before you call the harness done:
- Every tool execution path goes through the decision call. Grep for direct tool invocations.
- Timeouts, errors, and unparseable responses from the decision service are treated as deny.
- The caller token is the real bearer for the acting identity, not a shared service credential.
- Denies go back to the model as a plain tool result.
- Consecutive deny limit is set.
- Operators can find the audit record for a given run.
- Grant map size and fill are monitored.
None of this is exotic. It is the same discipline you would apply to an authorization check in front of any production API. The difference is that the client is a model, which will try things no human client would.
I/O Mesh runs in your own environment. Learn more at iome.sh, or email hello@iome.sh to talk with us about Enterprise.