Human in the loop for AI agents: require approval before actions
Published:2026-11-08
Put a human approval gate only on actions that are irreversible, reach outside systems, or cost money. Decide the approval outside the model, in a hook in the agent's runtime or in the system being changed, and treat no answer, an expired request, or a network failure as a rejection. An MCP approval tool is easy to add, but a model may skip it or ignore the answer, so it cannot enforce a stop.
Which actions need approval
Ask for approval on everything and people start approving without reading. Gates work best when they are rare. Four tests help.
| Irreversible | Deleting production data, migrations, git push --force, overwriting backups |
|---|---|
| External | Sending email or chat messages, pushing to a public repo, releasing, changing customer data |
| Costly | Creating cloud resources, heavy use of paid APIs, payments |
| Privileged | Issuing keys, changing permissions, using production credentials |
Reading, editing local files, and running tests can be undone and stay on your machine. Letting those run without a gate is safer overall, because it saves human attention for the actions that matter.
The OWASP Top 10 for LLM applications lists the same two controls against Excessive Agency: require a human to approve high-impact actions, and enforce authorization in downstream systems instead of relying on the model to decide.
Where the decision is made
"Ask for approval" means very different things depending on where the check happens.
- Ask the model. A prompt or an approval tool tells the agent to check before risky actions. If the model skips the check or misreads the answer, nothing stops. Prompt injection can override the instruction
- Stop it in the agent runtime. Permission rules and hooks in Claude Code decide before a tool runs, outside the model. Whatever the model intends, the call does not run without passing the check
- Stop it in the target system. The deploy script, API, or database permissions verify approval. This also catches actions that do not come from the agent
The Claude Code docs say the same: permission rules are enforced by Claude Code, not by the model, and instructions in a prompt or CLAUDE.md do not change what is allowed. Decide must-stop actions at level 2 or 3, and treat level 1 as a helper.
The MCP specification says there should always be a human in the loop who can deny tool invocations, and asks clients to show confirmation prompts. It also says clients must treat tool annotations such as destructiveHint as untrusted unless they come from trusted servers. In other words, decide on the client side, not on the server's word.
Design patterns
Allowlist plus ask
Allow safe actions automatically, deny dangerous ones outright, and ask a person only about the rest. Fewer prompts means each one gets read.
An approval tool over MCP
Give the agent a tool that asks a person, and let it call the tool when it needs to. It can return a choice or a short instruction, which suits questions like "which environment?". The decision stays inside the model, so do not use it as an enforced stop.
Gate in a hook
Check approval at a point every tool call must pass. It stops the action even if the model never calls an approval tool. Use this for anything that must stop.
Deadlines and default deny
Give each request a deadline and treat expiry as a rejection. Do the same for network failures and unknown states. Never treat no response as approval. Without a deadline, the agent waits forever, or gives up and looks for another way.
Return the rejection reason
A bare "rejected" makes agents retry the same action in different words. A reason like "use staging, not production" or "take a backup first" lets the agent change its plan.
Two-person approval and escalation
For changes nobody should make alone, such as production database changes, wait for two approvals. Telling someone else when the first person does not answer also cuts down on expired requests.
Idempotency and audit
Give each request a unique key so a retry does not ask twice, and keep a record of who approved what and when.
Approaches without Nodd
- Claude Code permission prompts. Permission rules and the built-in prompt are often enough, and simplest when you are at the computer. For when you step away, see approving Claude Code permissions from your iPhone
- Your own Claude Code hook. A PreToolUse hook can return
denyfor specific commands or block with exit code 2. When a rule can decide without a person, this is the most reliable option - Framework interrupts. LangGraph's interrupt pauses a graph, waits for human input, and resumes with approve or reject, saving state with a checkpointer. A good fit when approvals happen in your own app's UI
- CI approvals. If agent-written changes reach production through CI, gate them there. See GitHub Actions deploy approval
- Chat buttons you build yourself. You build the approval screen, deadlines, signature checks, and logging, but it all stays in the chat your team already uses
Comparison
| Instructions in the prompt | Nothing stops a model that skips them. A helper only |
|---|---|
| MCP approval tool | Good for questions and choices. The model may not call it, or may ignore the answer |
| Permission rules | Enforced outside the model. Asking a person needs someone there to answer |
| Hook that asks a person | Stops outside the model and waits for a human. You choose the deadline and the default |
| Checks in the target system | Also stops paths that bypass the agent. Needs work in each system |
Building it with Nodd
Nodd delivers approval requests to an iPhone and returns the Face ID approval or rejection to the program that asked. It fits the patterns above in four ways, depending on where you decide. All of them use the nodd CLI or a send key.
Enforce in a hook: nodd hook
A Claude Code PreToolUse hook. It asks for approval before a tool runs and allows only a recorded approval. Rejection, expiry, cancellation, and network failure are all denials. A rejection reason reaches the agent as Rejected in Nodd: reason.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "nodd hook", "timeout": 1260}]
}
]
}
}
The default response deadline is 300 seconds, and nodd hook --expires-in SECONDS changes it. See Claude Code hook for how to set timeout.
Let the model ask: nodd mcp
nodd mcp is an MCP server over stdio. Add it to Claude Code with claude mcp add nodd -- nodd mcp. Other clients use this configuration.
{"mcpServers":{"nodd":{"command":"nodd","args":["mcp"]}}}
It has three tools.
notify | Sends a notification without waiting |
|---|---|
ask | Asks for approval or a choice. Takes options (2 to 4, omit for approval), input (a text field), expires_in (default 3600), and wait_seconds (0 to 300, default 50) |
check_answer | Looks up the answer for an id returned by ask |
ask and check_answer return a status of pending, approved, rejected, answered, expired, cancelled, or failed, with a rejection reason in note. With no answer within wait_seconds the status is pending, and the agent calls check_answer later. A model can ignore MCP answers. Use the hook for actions that must stop. input needs Nodd 1.1 or later.
Stop it in the target: nodd ask
If the agent runs fixed scripts instead of arbitrary commands, put the approval inside the script. nodd ask exits 0 when approved, 10 when rejected, and 11 when it ends without a response, so a set -e script continues only on approval.
#!/bin/sh
# deploy.sh: the gate lives in the script, not in the agent's prompt
set -e
nodd ask "Deploy to production?" --body "$(git log -1 --oneline)" --level high --expires-in 900
./scripts/release.sh
A rejection reason goes to standard error as Note: reason, so the agent reads it as command output. Two-person approval is --approvals 2 and escalation is --escalate-after SECONDS; both need Team. The exit codes are listed on the CLI page.
Call it from your own agent: the API
Custom agents and services use the API. expires_in is the response deadline in seconds. approvals_required asks for 1 to 3 approvals; more than 1 needs Team, and one rejection settles it. With an Idempotency-Key, the same key gets the first result for 24 hours, so a retry does not send twice.
curl https://api.nodd.blipdrop.net/v1/messages \
-H "Authorization: Bearer $NODD_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: task-913-drop-orders-archive" \
-d '{
"kind": "approval",
"level": "high",
"title": "Drop orders_archive",
"body": "DROP TABLE orders_archive;",
"expires_in": 900,
"approvals_required": 2
}'
Read the result with GET /v1/messages/{id} and continue only when response.option_id is approve. Here is a version using only the Python standard library. Exceptions and expiry both come back as "not approved".
import json, os, time, urllib.request
API = "https://api.nodd.blipdrop.net"
KEY = os.environ["NODD_KEY"]
def call(method, path, body=None, headers=None):
req = urllib.request.Request(
API + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json", **(headers or {})})
with urllib.request.urlopen(req) as r:
return json.load(r), r.headers.get("Retry-After")
def require_approval(title, body, key, expires_in=900):
"""Return (approved, note). Anything other than a recorded approval is a no."""
try:
msg, _ = call("POST", "/v1/messages",
{"kind": "approval", "level": "high", "title": title,
"body": body, "expires_in": expires_in},
{"Idempotency-Key": key})
while True:
state, retry = call("GET", f"/v1/messages/{msg['id']}")
if state.get("action_state") != "open":
break
time.sleep(int(retry or 3))
except Exception as e:
return False, f"could not check approval: {e}"
response = state.get("response") or {}
return response.get("option_id") == "approve", response.get("note")
Feed the returned note into the agent's next turn and it can revise its plan around the reason. Instead of polling, you can receive a signed callback. A response records who answered, whether Face ID was used (assurance), and the device signature.
There is a diagram of the flow in Use cases.
Caveats
- The server does not reject on expiry for you.
on_timeoutis just returned in the event. Your code is what stops the work - The agent writes the request. A title or description can differ from what will actually run. Approvers should read the command itself.
nodd hookputs the command in the body - Keep secrets out of messages. Our servers can read a body until it is encrypted.
metadata, rejection reasons, andinputanswers are not encrypted to devices - MCP cannot receive secrets. For passwords,
nodd ask --secret --env NAME -- COMMANDpasses the value to a command without the model seeing it. It needs Nodd 1.1 or later. See passing secrets to scripts - Open requests are limited. 3 on Free, 500 on Team. Beyond that, sends are refused, and you should treat that as a rejection too
- Do not gate too much. Too many notifications lead to approving without reading. Let safe actions through with an allowlist first
FAQ
- Which AI agent actions need human approval?
- Actions that are irreversible, reach outside systems, cost money, or touch permissions and secrets. Reversible work such as reading and local edits is better left ungated so people stay attentive.
- Is an MCP approval tool enough?
- Not for actions that must stop. If the model does not call the tool, or ignores the answer, the action runs. Decide in a hook or in the target system.
- What should happen when nobody answers?
- Treat it as a rejection. Always set a deadline, and never run the action on expiry, cancellation, or network failure.
- Does the agent learn why I rejected it?
- With Nodd, yes. nodd hook passes it after Rejected in Nodd, MCP returns it as note, the CLI prints it to standard error as Note, and the API returns it as response.note.
- Can I require two approvers?
- The CLI's --approvals and the API's approvals_required ask for 1 to 3 approvals. More than 1 needs Team. nodd hook and MCP have no approver count.
Nodd delivers questions from scripts, CI, and AI agents to your iPhone, and returns your Face ID approval to the program that asked. There is a free plan.