The problem
Some actions should not be taken by a model unsupervised — refunding a payment, deleting a record, sending mail to a customer. Two naive designs both fail. Blocking them outright is not the answer: the agent then cannot do the job it was built for, and someone will remove the restriction rather than lose the workflow. Putting the pause at the execution boundary does not work either. A tool call runs inside a Temporal activity, and an activity has a timeout and cannot wait for a human. An interceptor at that boundary can only fail the call — which reads as an error, not a pending decision, and loses the model’s context in the process. The pause has to happen where the durable state lives.How AgentArea approaches it
Writing the rule
Approval is expressed as a policy rule with effectapproval, and the target
decides its shape:
*ortool:*— global. Setsrequires_human_approval: true; every capability tool call pauses. Approvers fromparams.approversapply to all of them.tool:<name>— per tool. Adds the name toescalation_rules, and storesparams.approversunderapprovers_by_tool[<name>]so distinct tools keep distinct signoff lists rather than pooling into one.
user:<id>, group:<id>, or a
userset <type>:<id>#<relation> — and are validated at write time. A bare id is
rejected so the data stays relationship-native.
Most approval rules are not written by hand. Ticking “requires approval” on a
tool in the agent editor reconciles an agent-scoped approval rule targeting
tool:<name>, and unticking it deletes the row rather than disabling it — the
checkbox is the source of truth. The flag is not persisted alongside the agent’s
tool configuration; it is reconstituted from the rules on read, so the two cannot
drift. Every agent-creation path reconciles through the same function, including
bundle install, workspace import and catalog fork.
One naming subtlety is worth knowing when a rule appears not to fire: policy
judges the name the model calls. A code toolset’s namespace is collapsed
(agentarea/shell becomes shell), while an MCP tool keeps the name it
advertises.
The requirement is monotonic across scopes. A workspace that sets
requires_human_approval cannot have it turned off by an agent, user or task
layer; the resolver raises a validation error instead of accepting the weaker
document.
No approval rule ships in the default workspace baseline. Human-in-the-loop is
something you opt into.
Two fields named for approval, one of which does nothing
The API exposes two similarly-named approval fields at different layers. Only one of them gates anything, and confusing them produces a task that runs unapproved.requires_user_confirmation on an agent’s tool configuration is enforced.
This is the checkbox described above: transport only, lifted into an agent-scoped
rule, folded into the snapshot under approval, and read by the workflow gate. A
tool ticked here does pause the run.
requires_human_approval on TaskCreate is not enforced. It is a public
field in the schema, and it is carried faithfully: into the task row’s metadata,
into the execution request, and onto the workflow’s goal object. Nothing reads it
after that. The gate consults the policy snapshot, never the goal. Setting it to
true and setting nothing else creates a task that runs to completion without
ever asking a human.
The two are not related in code despite the near-identical names. Approval is
granted by a policy rule, and TaskCreate has no way to write one.
The pause
decide_tool_policy returns REQUIRE_APPROVAL when requires_human_approval is
true or the tool is in escalation_rules. The workflow’s tool gate then:
- Creates a pending escalation with a fresh id, the tool call id, the tool name,
its arguments, and the resolved approver list for that specific tool
(
approvers_by_toolwins over the global list, falling back to empty). - Sets the workflow status to
WAITING_FOR_APPROVALand updates the task row towaiting_for_approval, so the inbox can find it by querying the database. - Emits an
approval.requestedevent with the escalation id, tool name, sanitized arguments and the approver list, and publishes it immediately to the event stream. Sanitizing drops fields such ascommand, which can carry inline secrets, so the event alone does not show the approver what they approve. - Waits on that specific escalation.
GET /v1/workspaces/{workspace}/agents/{agent_id}/tasks/{task_id}/escalations lists the pending
escalations with their tool_args. It answers only a caller who may act on the
run, and drops any escalation whose approver list does not include them, so the
arguments reach the same people who may resolve the escalation. The inbox shows
the command from this endpoint.
Tools awaiting approval stay disclosed to the model. Disclosure drops denials,
not escalations — the point of an approval is that the answer might be yes.
The resolution
A human answers by signalling the workflow with the escalation id, an approve or deny boolean, an optional comment, and the resolver’s user id. The signal handler is the authorization point:- If the escalation id is not pending, the signal is ignored.
- If the caller is not permitted to approve, the signal is logged as unauthorized and ignored, so the API or activity boundary cannot bypass the policy.
- Otherwise the escalation is marked resolved. When the waiting tool call
resumes it emits exactly one
approval.decidedevent carryingapproved,approved_byand thecomment. Approve and deny share that one wire type; the decision is in the payload.
404 when the escalation is not pending (or the run has no
workflow), 403 when the caller is not one of its approvers, and 200 with
{"status": "resolved"} once the signal is delivered. The MCP
runs.resolve_escalation tool returns the same outcomes as an error field.
The workflow still applies the approver check itself, so a signal sent around
the API is ignored. Confirm the outcome by the approval.decided event.
On approval the tool call proceeds and the normal tool.call event
follows. On denial a tool message saying the call was denied by a human operator,
with the comment, is appended to the conversation so the model can react rather
than retry blindly. Once no escalations remain pending the workflow returns to
running and the task row is set back to running.
Whether the caller may approve is decided by caller_can_approve:
- An empty approver list means any caller may approve. This is a deliberate
soft default, not an oversight, and it is the state you get from a global
approval rule with no
approversparameter. - A non-empty list is matched literally against
user:<caller_id>.
Why not enforce approval in the interceptor pipeline
No gate in the interceptor pipeline turns a matched action into an approval request, and that is intentional. The interceptor pipeline runs at the Temporal activity boundary. An activity cannot pause and resume — anESCALATE there is converted into an
EscalationRequired exception, which fails the activity. No registered gate
escalates; the tool path’s workflow code still turns such a failure into a
pending approval, so a run that was waiting on one when it was written replays
unchanged. On the LLM and discovery paths it stays a failure, and the
conversation state that would let a human judge the request is not available at
that layer.
The workflow is the only place that holds durable state, can wait indefinitely,
can receive a signal, and can put a denial message back into the conversation. So
approval lives there.
The cost of that choice is coverage: any tool path that does not run through the
agent workflow has no approval flow, only a denial. See the limits below.
Limits
TaskCreate.requires_human_approvalis inert. Described above. It is writable through the API, reaches the workflow goal, and is read by nothing. Per-task approval cannot be requested at task creation; write a policy rule instead.- The MCP proxy denies rather than escalates.
_authorize_mcp_tool_callstreats anything that is notALLOWas not allowed, so a tool requiring approval called throughPOST /v1/mcp/{instance_id}/mcpreturns HTTP 403 with no escalation created and nothing for a human to answer. An external MCP client cannot obtain approval. - There is no timeout. The workflow waits on the escalation with no deadline.
A task blocked on an approval nobody answers stays in
waiting_for_approvaluntil it is cancelled. There is no auto-deny, no expiry, and no escalation to a second approver. - Empty approvers means anyone. A global approval rule written without an
approversparameter produces an empty list, and an empty list permits any caller to resolve. If you want the approval to mean something, name the approvers. - Group and userset approvers do not grant approval.
group:<id>and<type>:<id>#<relation>references are accepted, validated and stored, but the resolution check only matches directuser:<id>subjects. An approver list containing nothing but group references matches nobody and blocks the task indefinitely. - No notification is sent. Core emits the
approval.requestedevent to the stream and updates the task row. It does not send mail, post to a channel, or page anyone. Anything that notifies an approver has to consume the event. - Approval does not change who acts. The task continues to execute as the original run. An approval by a second person does not re-scope the execution to that person’s permissions, and the tool activity itself runs under a system context rather than either user’s.
Related
Tool authorization
Where the
REQUIRE_APPROVAL verdict comes from and which paths it reachesThe policy engine
How approval rules merge across scopes
Audit
What an approval leaves behind, and where