← all articles

How to add human approval checkpoints to an AI agent

In February 2024 a Canadian small claims tribunal ruled that Air Canada was bound by a bereavement fare discount its own chatbot invented and told a grieving customer he qualified for. No human at Air Canada ever saw that promise before it went out, let alone approved it. The tribunal’s decision in Moffatt v. Air Canada, 2024 BCCRT 149 was blunt: the airline didn’t take reasonable care to make sure its chatbot was accurate, and it’s responsible for what the bot said regardless.

No human ever saw it.

That’s the whole argument for approval checkpoints. An agent said or did something with real consequences, and there was no point where a person could have caught it first.

This is for anyone running an agent that does something outside a sandbox: a browser agent that fills out and submits forms, a phone-use agent tapping through a banking or ad-platform app, a coding agent that opens PRs or pushes to a branch, anything that can spend money, delete something, or represent you in front of a third party. If your agent only reads and summarizes, you probably don’t need this article yet. By the end you’ll have a working checkpoint: an agent that pauses before a risky action, asks a real person, waits, and only proceeds (or doesn’t) on their decision, with a log of who said what and when.

What you need

  • an agent or automation that already executes actions, not just generates text (LangGraph, a custom Python loop, a Playwright-driven browser agent, whatever you’re running)
  • somewhere to pause execution: a database row, a Redis key, or a workflow engine that supports durable pauses
  • a channel a human will actually check. Slack is the common choice, email works if your approvers are slow anyway
  • a Slack app with chat:write scope and an interactivity endpoint enabled, free, takes about 10 minutes in the Slack API console
  • a small always-on service to receive the approval webhook: a Flask or FastAPI app behind ngrok for testing, a real endpoint for production
  • roughly half a day for the first working version, longer if your action executor wasn’t built to support pausing mid-action

Step by step

Step 1: decide which actions actually need a human

List every distinct action type your agent can take and tag each with a risk tier: low, medium, or high. Ask two questions per action: is it reversible, and does it cost money or touch a third party’s system.

Expected output: a short table, usually 5 to 15 action types, with most of them landing at low risk.

If it breaks: teams tag too much as high risk on the first pass. If more than a third of your actions land in medium or high, go back and ask whether the action is actually irreversible or just unfamiliar.

Step 2: add a checkpoint gate to the action executor

Wherever your agent currently calls execute on an action, wrap it with a gate that checks the risk tier and, for medium or high, creates a pending checkpoint record instead of running immediately.

RISK_TIERS = {
    "send_email": "low",
    "submit_form": "medium",
    "checkout_payment": "high",
    "delete_resource": "high",
}

def run_action(agent_action, context):
    tier = RISK_TIERS.get(agent_action.name, "high")
    if tier in ("medium", "high"):
        approval = request_approval(agent_action, context)
        if approval.status != "approved":
            return ActionResult(skipped=True, reason=approval.status)
    return agent_action.execute(context)

Expected output: running the agent against a high-risk action now produces a “waiting for approval” state instead of executing, and nothing happens until that record is manually flipped.

If it breaks: some agent loops execute and observe in the same function, with no clean pause point. You’ll need to separate “decide” from “do” before this works at all. That split is usually the actual blocker, not the approval logic.

Step 3: build the approval interface

Stand up a Slack app and post a message with Approve and Reject buttons via Block Kit whenever a checkpoint is created.

{
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Agent wants to run:* `checkout_payment`\nAmount: $412.00 SGD\nMerchant: acme-supplies.com"
      }
    },
    {
      "type": "actions",
      "elements": [
        { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "value": "chk_8842", "action_id": "approve_action" },
        { "type": "button", "text": { "type": "plain_text", "text": "Reject" }, "style": "danger", "value": "chk_8842", "action_id": "reject_action" }
      ]
    }
  ]
}

If your agent operates a real phone rather than a browser, this matters even more. We’ve written before about running phone-use agents on real Android instead of emulators, and the same logic applies to approval: the person clicking approve needs to see the actual screen state, not a text description of it. This is where cloudf.one’s model is worth knowing about: real Android phones in Singapore, on dedicated hardware, each with a persistent Singapore mobile IP, controlled straight from the browser, so a human can watch that live screen in the same browser tab before approving what they’re actually seeing. It’s Singapore-only hardware, so it fits if that’s where your traffic already lives, not a general substitute for an emulator fleet elsewhere.

Expected output: a message like the one above shows up in the channel with working buttons.

If it breaks: buttons with no context get rubber-stamped or ignored. Put the data the approver actually needs right in the message, screenshot, form fields, dollar amount, not just the action name.

Step 4: wire pause and resume

Set up an endpoint that receives Slack’s interaction payload, writes the decision to the checkpoint record, and pushes the checkpoint id back onto a queue your agent is polling or listening on.

@app.post("/slack/interactions")
def handle_interaction(payload):
    action_id = payload["actions"][0]["action_id"]
    checkpoint_id = payload["actions"][0]["value"]
    status = "approved" if action_id == "approve_action" else "rejected"
    db.checkpoints.update(checkpoint_id, status=status, decided_by=payload["user"]["id"])
    resume_queue.push(checkpoint_id)

If you’re already on LangGraph, its interrupt() function does a version of this natively and is worth using instead of rolling your own queue. Anthropic’s note on human-in-the-loop as an agent pattern is a good reference for why this pause point belongs in the architecture, not bolted on as an afterthought.

Expected output: clicking Approve in Slack unblocks the exact agent run that was waiting, and it continues from where it paused, not from the start.

If it breaks: the most common failure is losing execution context across the pause. Check that everything the action needs, auth tokens, form data, session state, survived the round trip, not just the yes or no decision.

Step 5: set a timeout and pick a default

Decide what happens if nobody responds in, say, 30 minutes: does the action get cancelled, or does it go through anyway. Wire a timeout job against the checkpoint record.

Expected output: an unattended checkpoint doesn’t hang forever, it resolves one way or the other and logs which.

If it breaks: default-deny is almost always the right call, and I’d push back hard on anyone defaulting to allow just because the queue backed up. An agent that occasionally misses a window because nobody was watching is annoying. An agent that silently executes because nobody was watching is the incident you can’t undo.

Step 6: log every checkpoint decision

Write every checkpoint, created, who saw it, decision, timestamp, what data was shown, to a table you can query later, separate from your regular agent trace logs.

Expected output: you can answer “who approved the refund on the 12th” in one query, not by scrolling Slack history.

If it breaks: if you’re already capturing traces and replays for your agents, see our piece on observing AI agents, extend that same pipeline instead of bolting on a second logging system nobody maintains.

Step 7: test the gate itself

Don’t just test the happy path. Force a reject, force a timeout, kill the webhook service mid-flow and confirm the checkpoint doesn’t silently vanish.

curl -X POST http://localhost:8000/slack/interactions \
  -H "Content-Type: application/json" \
  -d '{"actions":[{"action_id":"reject_action","value":"chk_8842"}],"user":{"id":"U012AB3CD"}}'

Expected output: each failure mode resolves to a known state, rejected, timed out, retried, never to “agent proceeded anyway” or “checkpoint lost.”

If it breaks: if killing the webhook service loses pending checkpoints, your queue isn’t durable. Fix that before this goes anywhere near production, not after.

Step 8: roll out behind a flag, starting with your riskiest action

Turn on checkpoints for exactly one high-risk action type in production, watch it for a week, then expand.

Expected output: real approval latency numbers and a sense of how often people actually reject something, which tells you whether the risk tiers from step 1 were right.

If it breaks: if approvers start clicking Approve without reading, that’s not an approver problem, that’s a step 3 problem. The message doesn’t have enough context to make a real decision.

Common pitfalls

  • gating everything instead of just the risky tiers. once approvers are seeing 40 messages a day for things that don’t matter, they stop reading any of them, including the ones that do
  • one approver as the whole system. that person goes on leave and the queue either stalls completely or someone quietly disables checkpoints to keep things moving, which defeats the point
  • deciding the timeout policy during an incident instead of ahead of time. by the time you’re arguing about whether to default-allow, something has already gone wrong
  • treating agent traces and the approval audit trail as the same system. they answer different questions, “what did the agent do” versus “who told it to,” and conflating them means you usually end up with neither one complete

Scaling this

At roughly 10x volume, one Slack channel and one on-call approver still work, but route by action type so the person reviewing payment checkpoints isn’t also fielding form-fill approvals meant for someone else.

At 100x, a single person can’t read and reason about each message from scratch anymore. This is where you build a real approval queue UI, Retool or a small internal app, that shows the approver their own history alongside each new request, and you start auto-approving the specific sub-cases where your own history shows they’re never rejected, sending only the genuinely uncertain ones to a human.

At 1000x, you need shift coverage, not a single on-call person, because an unattended queue for a few hours is now a backlog, not an annoyance. Approval queue depth over a set threshold becomes its own alert, treated like an outage. Most teams at this scale also split approvers by tenant or account, so a bad decision stays contained to one blast radius instead of touching everyone.

I haven’t run this pattern past a few hundred checkpoints a day myself, so beyond that, agents making a checkpoint decision every few seconds rather than every few minutes, you’re in different territory, and I don’t have a clean answer for it yet.

Where to go next

If you’re setting this up alongside a broader launch, start with our browser agent production checklist, which covers the other operational gaps that bite teams before approval checkpoints even become the priority. Once checkpoints are logging decisions, pair that with a proper trace and replay setup so you can see the exact agent state at the moment each checkpoint fired, not just the decision that came out of it. More tutorials like this live on the blog index.

For the governance side of this, NIST’s AI Risk Management Framework is a reasonable reference if you need to document why checkpoints exist for an audit or a compliance review, not just an engineering one. Worth noting this article is a practical build guide, not legal advice, if your use case involves regulated actions like payments or healthcare, check with your own counsel on top of anything here.

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-16.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →