Implementation guide

How to add human approval to AI agents

Every agent carries at least a few tools you do not want the model to call alone. Refunds above a threshold, deletes on production tables, outbound emails to addresses outside the org, code merges into main. The right pattern for those is human review: the agent proposes the action, a human approves or rejects through the configured review path, and only then does the call land. Wire that flow into a tool-calling agent with a YAML rule, a review channel, and a blocking call. The approval record and decision record stay on the governed path.

  • A policy rule that marks specific tool calls as require_approval, with notify channel and timeout.
  • A configured review channel or workspace queue for pending approvals.
  • A workspace queue at /dashboard/approvals for on-call review.
  • Agent code that blocks cleanly until the human acts, with sane behavior on timeout.

Step 1: Write an approval policy

Name what needs a human first. Two categories cover most agents: financial actions above a dollar threshold and writes against production data. Add a rule per category inpolicies/approvals.yaml with arequire_approval outcome and an approval block that names the notify channel, the timeout, and the fallback if no one acts.

yaml
# policies/approvals.yaml
- name: refunds_require_approval_above_threshold
  match:
    tool: refund_order
  rules:
    - if: args.amount_cents > 50000
      then: require_approval
      approval:
        notify: slack
        timeout_seconds: 300
        fallback: deny

- name: prod_writes_always_get_a_second_pair_of_eyes
  match:
    tool: execute_sql
  rules:
    - if: args.database == "prod" and args.statement matches "^(UPDATE|DELETE|DROP|ALTER).*"
      then: require_approval
      approval:
        notify: workspace
        timeout_seconds: 1800
        fallback: deny

The approval block is what distinguishes a human-review rule from a plain deny. The notify channel decides where the approver gets pinged. The timeout decides how long the agent waits. The fallback decides what happens if nobody responds in time.

Step 2: Connect a review channel

For a configured-channel queue, connect the approval path in the workspace integrations area. Add the webhook or queue destination your team uses. Veto can post a message for eachrequire_approval decision with the agent identity, the tool, an argument summary, and review controls. Each message should link to a workspace permalink for context.

sh
# In the Veto workspace: Settings -> Integrations
# Paste your incoming webhook URL and pick the channel.
#
# Configured require_approval decisions can post a message with:
#  - The agent identity and role
#  - The tool name and full arguments
#  - Approve and Reject buttons
#  - The user who triggered the original request

# Each approval also gets a permalink to /dashboard/approvals/<id>
# so on-call can pull it up on mobile.

If you do not want approvals in a chat channel, the workspace at /dashboard/approvals is the canonical surface and works on mobile. Many teams use both: configured chat for visibility, the workspace for batch triage.

Step 3: Block the agent until a human decides

In the agent, add veto.decide to the governed tool path. If the outcome is require_approval, callveto.approvals.wait with the approval id and a timeout. The function blocks until the approver acts or the timeout fires. On approval, run the tool. On reject, return the note to the agent as a tool result so the LLM can adapt.

py
import os
from veto_sdk import Veto

veto = Veto(api_key=os.environ["VETO_API_KEY"])

def refund(order_id: str, amount_cents: int, agent_id: str):
    decision = veto.decide(
        tool="refund_order",
        args={"order_id": order_id, "amount_cents": amount_cents},
        agent={"id": agent_id, "role": "support"},
    )

    if decision.outcome == "deny":
        raise PermissionError(decision.reason)

    if decision.outcome == "require_approval":
        approval = veto.approvals.wait(
            decision.approval_id,
            timeout=300,
        )
        if approval.status != "approved":
            return {"status": "rejected", "reason": approval.note}

    return stripe.refunds.create(charge=order_id, amount=amount_cents)

Two notes. The blocking call holds a thread. If you run agents in async workers, prefer the async stream variant below to keep the worker pool available. And every approval, approve or reject, is recorded with the approver identity and the time-to-decision metric. That log feeds the maker-checker evidence record.

Step 4: Handle reject and timeout

Long approval flows deserve async. Use approvals.stream instead of wait so the worker can yield while it waits. Surface the approver note back to the agent so the next LLM turn knows why the action was blocked. That turns a stack trace into structured context for the model.

py
import asyncio
import os
from veto_sdk import Veto

veto = Veto(api_key=os.environ["VETO_API_KEY"])

async def run_with_approval(tool, args, agent):
    decision = await veto.adecide(tool=tool, args=args, agent=agent)

    if decision.outcome == "allow":
        return await TOOLS[tool](**args)

    if decision.outcome == "deny":
        raise PermissionError(decision.reason)

    async for event in veto.approvals.stream(decision.approval_id):
        if event.status == "approved":
            return await TOOLS[tool](**args)
        if event.status == "rejected":
            raise PermissionError(event.note)
        if event.status == "timeout":
            raise TimeoutError("Approver did not respond in time")

The same pattern works for LangChain, AutoGen, CrewAI, and the OpenAI Assistants API. Plug the wrapper into the framework's tool-call handler and you are done. See /integrations/langchain and /integrations/openai for framework-specific glue.

Failure modes to catch

Setting the timeout to infinity

An approval that never times out is a zombie process. Pick a number tied to your on-call response window. Five minutes is reasonable for daytime support. Thirty minutes is the upper bound for anything else. Beyond that, the agent should give up and let the human pick it back up manually.

Default fallback to allow

Allow-on-timeout is convenient and wrong for high-impact actions. If nobody answers, the action runs. The right default for production-impacting tools is deny. Use allow only for low-stakes actions where doing nothing is worse than acting.

No review context

Approvers need the argument summary, agent identity, originating user, and policy reason to make a real decision. Veto includes the required context by default. If you customize the template, do not strip them.

Production checklist

  • require_approval rules cover the tools that write money, delete data, or send external messages.
  • Each rule has an explicit notify channel, timeout, and fallback.
  • Configured review channel or workspace queue has an on-call rotation attached to it.
  • Async agents use approvals.stream so a single pending approval cannot pin a worker.
  • Reject notes flow back to the agent as tool results, not as exceptions.

FAQ

Does the agent thread stay alive while it waits for approval?

Yes. The Python preview call blocks until the approval lands or the timeout fires. The async variant streams events so you can hold the conversation open without pinning a thread. For long-running approvals (over five minutes), use the async stream so the worker stays free.

What happens if the approver does not respond in time?

The fallback in the policy decides. Teams set fallback: deny, which is the default-deny setting for production writes. For lower-stakes approvals you can set fallback: allow with a documented rationale. Every timeout is recorded with the same evidence as an explicit reject.

Can we batch approvals when many agents fire at once?

The workspace groups approvals by tool and by agent so an on-call engineer can pattern-match and bulk-act when the queue is noisy. Configured notifications can group by agent id, which keeps review queues readable when a single agent generates several pending decisions.

Related guides

Put a human in the loop for the actions that matter.