Octov0.11.7
Guides

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: stop

tool_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: 2m

A 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

On this page