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 happened | What the model is told |
|---|---|
| A person denied it | a person denied this call |
| Nobody answered in time | nobody authorized this call within <timeout> |
| The connection is closed | the connection that would have asked for authorization is closed |
| The run ended first | the run ended before this call was authorized |
| The condition would not evaluate | the 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.
| Missing | Why it is refused |
|---|---|
authorizeId | Nothing could ever allow a gated call; every one would be denied on the timeout. |
An events path emitting tool_authorization | The 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
- Steering a running agent: the path an answer travels.
- AI Blocks reference: full field tables.