Octov0.11.7
AI

Tool Authorization

Asking a person before a tool call runs, and what happens when nobody answers.

Every other boundary an agent has is structural: a tool it does not hold, an HTTP client bounded to GET, a branch that is not wired. What those cannot express is a rule about an argument: "this tool is a read" is a boundary, "this call is a read" is not, and once the agent fetches text somebody else wrote (a search result, a web page, a ticket description) that is where the danger is. So a tool can declare authorize, a condition over the call the model asked for. When it holds, the run stops and asks a person.

The shape

      - type: ai-agent
        name: assistant
        connector: claude
        memoryThreadId: vars.sub + ":" + body.threadId
        stream: true

        # Which call an incoming request answers, and what it says.
        authorizeId: '"X-Agent-Auth-Id" in vars ? vars["X-Agent-Auth-Id"] : ""'
        authorizeAllow: '"X-Agent-Auth" in vars && vars["X-Agent-Auth"] == "allow"'
        authorizeTimeout: 2m

        emit: [text, tool_call, tool_authorization, tool_result, signal, done]
        events:
          process:
            - type: sse-event
              settings:
                event: agent
                ifClosed: stop

        tools:
          # A read. No gate: asking about this one is ceremony.
          - name: lookup_order
            description: Look up an order by its id.
            process: [...]

          # The gate is about the arguments, not the tool.
          - name: refund_order
            description: Refund an order, in whole or in part.
            authorize: '!(input.amount >= 0.0 && input.amount <= 10.0)'
            process: [...]

Write the condition as "free only when", not "gated when". The obvious spelling of the refund rule is input.amount > 10.0, and it has a hole: -1 is not greater than ten, so a negative refund (a charge) goes through unasked. Inverting it, as above, closes the whole outside of the safe range. An inputSchema saying the same thing is not a substitute: a provider usually follows it, the runtime does not enforce it.

Free is the default. A panel that asks about every read trains a person to click yes without reading. Gate the call that changes something.

What travels

The agent asks through the events path, as an ordinary frame alongside tool_call frames.

{"type":"tool_authorization","iteration":3,"tool":"refund_order",
 "toolCallId":"toolu_01A…","authorizationId":"auth_9f3c2b…",
 "input":{"orderId":"A-1","amount":240.0},"expiresInSeconds":120}

input is the arguments as the model asked for them: the person is authorizing this call, not this tool. The answer comes back the way a follow-up or a stop does, as a second request on the same conversation quoting the id (see Steering a running agent).

curl -X POST http://localhost:8080/chat \
     -H 'X-Agent-Auth-Id: auth_9f3c2b…' -H 'X-Agent-Auth: allow' \
     -d '{"userId":"u1","threadId":"t1"}'

That request stops with an empty body, exactly as a steer does.

Nobody has to answer

A run parked on a tool call is billed for the wait, so every way of not being allowed ends the same: the call is denied, and the denial is the tool's result.

{"authorized": false, "reason": "nobody authorized this call within 2m0s"}

It reaches the model as an error result, so the run carries on: the model gets a turn to say so, ask, or take another route. Like any tool result it survives compaction and is in the transcript afterwards.

What happenedWhat the model is told
A person denied ita person denied this call
Nobody answered in timenobody authorized this call within <timeout>
The connection is closedthe connection that would have asked for authorization is closed
The run ended firstthe run ended before this call was authorized
The condition would not evaluatethe authorization condition for this tool could not be evaluated: …

The last one is deliberate: a broken gate must not be generous. A closed connection denies immediately rather than waiting out the clock, since with ifClosed: stop in the events path the only person who could have answered is gone.

How quickly a disconnect is noticed depends on stream. Stream any agent whose caller can walk away; see When the caller hangs up.

What is refused at build time

A block that gates anything must have a way to ask and a way to be told. Each missing half fails the build rather than turning up as a denial five minutes into a run.

MissingWhy it is refused
authorizeIdNothing could ever allow a gated call; every one would be denied on the timeout.
An events path emitting tool_authorizationThe event is how a person is asked. A gate nobody is asked about only denies.
memoryThreadId (with authorizeId)An answer is handed to the run working on a conversation, and there would be nothing to name.
authorizeAllow (with authorizeId)A request carrying an id is an answer, and there would be nothing for it to answer.

Whatever the thread id is derived from is exactly who can answer. An authorization is delivered on the conversation, so anyone who can reach the conversation can allow a call. Build the thread id from an identity the caller cannot choose: vars.sub, the verified subject of a token a jwt-validate block checked, rather than a field of the request body.

What this is not

It does not replace the structural boundaries; keep them. An http-client tool bounded to allowMethods: [GET] cannot be talked into a PUT at all. This is the layer for calls where the danger is in the argument rather than the verb.

It is also not inherited. An ai-agent standing in another agent's tool slot declares its own gates on its own tools, where the arguments are known.

authorize is an ai-agent setting. An mcp-router rejects it: a tool call arrives over the protocol, is answered, and is gone, and the client runs its own consent step.

Try it

Human in the Loop walks the whole thing through, and samples/ai-agent-authorization.yaml is the whole pattern, runnable, in two terminals.

Next steps

On this page