Human in the Loop
Make an agent ask a person before it runs a tool, and decide what happens when nobody answers.
This guide builds an agent that stops before a tool call, shows a person the
arguments it chose, waits, and behaves sensibly when nobody is there. It uses
samples/ai-agent-authorization.yaml,
which needs an Anthropic key and nothing else.
What a gate is for
Structural boundaries (a tool the agent does not hold, an HTTP client bounded to
GET, a branch that is not wired) come first. What they cannot express is a rule
about an argument: refund 8.00 and refund 40000.00 are the same tool.
And anything the agent reads (a search result, a web page, a ticket description)
can carry an instruction somebody else wrote. A gate puts a person in front of
that call.
Step 1: gate the call, not the tool
Add authorize to the tool. It is CEL over the call the model asked for, where
input is the decoded arguments:
tools:
# A read. No gate.
- name: lookup_order
description: Look up an order by its id.
process: [...]
- name: refund_order
description: Refund an order, in whole or in part.
authorize: '!(input.amount >= 0.0 && input.amount <= 10.0)'
process: [...]
- name: cancel_order
description: Cancel an order that has not shipped.
authorize: "true"
process: [...]A tool with no authorize runs freely. A panel that asks about every read
teaches people to click yes without reading, so gate the call that changes
something.
Write the condition as "free only when", not "gated when". input.amount > 10.0
has a hole: -1 is not greater than ten, so a negative refund (a charge) goes
through unasked. Inverting it closes the whole outside of the safe range. The
inputSchema says the same to the model, but a schema is advice a provider
usually follows, not something the runtime enforces.
Step 2: give it a way to ask
The question travels the events path as an ordinary frame, so a caller already reading the run receives it:
emit: [text, tool_call, tool_authorization, tool_result, signal, done]
events:
process:
- type: sse-event
settings:
event: agent
ifClosed: stoptool_authorization has to be in emit; a gate on a path that drops the event
could only ever deny, so the block refuses to build that way.
Step 3: give it a way to be told
The answer comes back the way a follow-up or a stop does: a second request on
the same conversation, which is what memoryThreadId names. Two expressions
read it:
memoryThreadId: vars.sub + ":" + body.threadId
authorizeId: '"X-Agent-Auth-Id" in vars ? vars["X-Agent-Auth-Id"] : ""'
authorizeAllow: '"X-Agent-Auth" in vars && vars["X-Agent-Auth"] == "allow"'
authorizeTimeout: 2mA non-empty authorizeId makes a request an answer rather than a message: it is
handed to the run holding the call, and the request stops with an empty body.
Anything but a plain yes in authorizeAllow denies, a missing header included.
Declare the headers on the source so they reach the flow as variables:
settings:
path: /chat
methods: [POST]
headers: [X-Agent-Auth-Id, X-Agent-Auth]Whatever the thread id is derived from is exactly who can answer, because an
authorization is delivered on the conversation. Build the id from an identity the
caller cannot choose, such as vars.sub, the verified subject of a token a
jwt-validate block checked, rather than a field
of the request body. The sample uses a body field and binds to loopback because
it has no authentication in front of it; yours should not.
Step 4: run it
Ask a question that makes the agent reach for a gated tool, with curl -N so it
does not buffer:
curl -N -X POST http://127.0.0.1:8080/chat \
-d '{"userId":"u1","threadId":"t1","message":"cancel order A-1"}'It stops and asks, in the same stream:
{"type":"tool_authorization","iteration":2,"tool":"cancel_order",
"toolCallId":"toolu_01A…","authorizationId":"auth_9f3c2b…",
"input":{"orderId":"A-1"},"expiresInSeconds":120}A second terminal answers, quoting the id:
curl -X POST http://127.0.0.1:8080/chat \
-H 'X-Agent-Auth-Id: auth_9f3c2b…' -H 'X-Agent-Auth: allow' \
-d '{"userId":"u1","threadId":"t1"}'That request returns {} immediately and the first terminal carries on. Send
deny instead and the agent is told the call was refused and answers for
itself.
What happens when nobody answers
Nothing waits forever. The call is denied when authorizeTimeout runs out, and
the denial is the tool's own result:
{"authorized": false, "reason": "nobody authorized this call within 2m0s"}It reaches the model as an error result, so the run continues. Like any other tool result it survives compaction and is in the transcript afterwards.
A closed connection denies immediately: with ifClosed: stop, the only
person who could have answered has gone. Set stream: true on any agent whose
caller can walk away, or a disconnect is only noticed at the next turn boundary;
see
When the caller hangs up.
Deciding what to gate
Three questions, in order. Can a structural boundary answer it? If the tool
should only ever read, use allowMethods: [GET] on its
rest-dynamic block instead. Is the danger in the
argument? That is what a gate is for. Would you ask about this ten times a day?
If so, do not gate it. A gate is not inherited: an ai-agent in another
agent's tool slot declares its own gates on its own tools.
A worked example
The platform's own agent gates one of his tools, web_search,
because of what it brings back rather than what it changes:
it is the door untrusted text comes through, while everything else he holds is a
GET or acts on the installation's own data. One gated tool out of a couple of
dozen is a realistic ratio.
Next steps
- Agentic self-healing puts the gate on a Slack thread, answered by a reply, for an agent that triages platform alerts.
- Tool authorization: the reference, including every denial reason and what fails at build time.
- Steering a running agent: the path an answer travels, and how it works across replicas.
- Dr. Octo: the gate as a person meets it.