DEV Community

sekera-radim
sekera-radim

Posted on

How to put a real human approval step between your AI agent and any real-world action

I have been building AI agents for a while now. Claude Code, a few scheduled agentic scripts, that kind of thing. Over a few months I kept assembling the same piece of infrastructure from scratch, and each time it was the same shape with different nouns.

At some point the agent wants to do something for real. Send an email. Post a reply. Run a command that is annoying to undo. And every time, I wanted to be the one saying yes before it happened. Not once did I want the agent to just go.

So each project grew the same appendage: a small approval step. A cron job that produced a draft, a seen.json to avoid duplicates, and a tiny UI so I could tap yes or no from my phone before anything went out. Three separate projects, same skeleton. So I pulled it out into a reusable thing. This post is about the pattern, not the product, because the pattern is the useful part even if you build your own.

A prompt is not a gate

The obvious first attempt is to write "always ask the user before sending" into the system prompt. This does not work as a control. A capable model will, sooner or later, reason its way around a natural-language instruction: it decides the current case is obviously fine, or it forgets, or an edge case slips through. You are trusting the same component you are trying to guard.

What you actually want is for the "did a human approve this?" check to be a data dependency, not an instruction. The agent should not be able to reach the execution branch without a real value coming back that says approved.

The shape

The agent submits a proposed action. That returns immediately as pending. A human sees it, approves, rejects, or edits the draft first. The agent polls and only proceeds when it sees approved. The branch that actually does the thing sits behind a returned value, so there is no wording an edge case can talk itself past.

Here is the whole loop in three REST calls:

# 1. Propose the action
ACTION=$(curl -s -X POST https://api.impri.dev/v1/actions \
  -H "Authorization: Bearer $IMPRI_API_KEY" \
  -d '{"kind":"email.send","title":"Outreach: Acme","preview":{"format":"markdown","body":"Hi Sarah, ..."}}')
ID=$(echo "$ACTION" | jq -r .action_id)

# 2. Poll until a human decides
while true; do
  DECISION=$(curl -s "https://api.impri.dev/v1/actions/$ID" \
    -H "Authorization: Bearer $IMPRI_API_KEY")
  STATUS=$(echo "$DECISION" | jq -r .status)
  [ "$STATUS" != "pending" ] && break
  sleep 5
done

# 3. Only now, and only if approved, execute
if [ "$STATUS" = "approved" ]; then
  send_email "$(echo "$DECISION" | jq -r .preview.body)"   # use the human-edited version
fi
Enter fullscreen mode Exit fullscreen mode

If your agent speaks MCP, the same thing is three tool calls (impri_push_action, impri_await_decision, impri_report_result) and the model does not need any SDK code. The tools show up in Claude Code, Cursor, Claude Desktop, or any MCP client.

The part people get wrong (including my own first pitch)

When I first wrote about this I said the gate is "structural" and the agent "literally cannot reach the execution code." Someone on r/mcp rightly pushed back, and they were correct, so let me be precise here.

The gate is only real as long as the approved path is the agent's only path to the side effect.

There are two ways to wire it:

  1. Cooperative. The agent calls the approval tool, waits, then the agent itself executes. This is better than a prompt because the check is a data dependency in the control flow. But if the agent already holds the raw email credential, a buggy or misaligned agent could just call send directly and skip the whole thing.

  2. Interception. You wrap the target tool so the proposed tool_use is intercepted before execution, and the executor is not called until the decision comes back approved. Now your gate is literally the only path to the real function. The tool definition the model sees is unchanged; only the execution path goes through the gate.

Even the second one is only a real gate if that wrapper is the single chokepoint. Hand the agent raw keys or a second tool that reaches the same service and it routes around you. So the honest framing is: this is a chokepoint you confine the agent to, not a magic network-level interceptor of every egress. Build accordingly, and do not give the agent a second door.

Getting the human step off your desk

The approval itself does not have to live in a dashboard you check. The request can land in Slack, Discord, or Telegram as a message with Approve and Reject buttons, and you tap one from your phone. Every decision goes into an audit log: who approved what, when, and whether they edited it first. For agents that need it, there is also edit-before-approve, so the human can fix the wording and the agent receives that version to act on.

If you want to try it instead of building it

The thing I extracted is called Impri. The full core (inbox, chat approvals, MCP server, audit log) is MIT and self-hostable with Docker Compose in one command. There is a hosted version too, but it is early beta, so self-host is the path I would point you at.

It is genuinely early. A handful of real users and a couple dozen approvals through it so far. If you are building agents that take real actions and have cobbled together your own version of this gate, I would love to hear where it belongs in your setup and whether the tool interface makes sense. That is the thing I am trying to save people from rewriting a fourth time.

Top comments (0)