How to Build Human-in-the-Loop Approval Gates for AI Coding Agents
Your agent just ran something you didn't ask for. Here's the three-pattern stack — PreToolUse hooks, ThumbGate blocklists, and mobile approval forwarding — that keeps agents fast without giving them a blank check.
AI coding agents like Claude Code and Codex default to autonomous execution — writing files, running shell commands, and making architectural decisions without pausing for review. Human-in-the-loop (HITL) approval gates fix this by inserting explicit confirmation checkpoints for high-stakes operations while letting agents move freely on safe ones. This tutorial covers three escalating patterns: PreToolUse hooks for Claude Code, ThumbGate for feedback-driven blocklists, and async mobile permission forwarding for unattended runs.
TL;DR
- No gates (YOLO mode): maximum throughput, maximum blast radius — acceptable only on throwaway branches
- PreToolUse hooks: intercept tool calls before execution, block by pattern or tool type, auto-approve reads; works today with Claude Code's
settings.json - ThumbGate: one thumbs-down builds a persistent blocklist from real agent behavior, shareable across team sessions
- Async mobile forwarding: permission requests route to your phone for one-tap approve/deny — no terminal watch required, the right layer for unattended runs
What You'll Build
By the end of this tutorial you'll have a working approval gate stack that:
- Auto-approves read-only tool calls (file reads, grep, glob)
- Prompts for writes and shell commands before they execute
- Hard-blocks known-destructive patterns unconditionally
- Optionally routes pending approvals to your phone when you're away from the terminal
The core patterns are tool-agnostic and work without any cloud dependency. The async mobile layer is where Grass comes in — covered in its own section below.
Prerequisites
- Claude Code installed and authenticated (
claudeCLI in PATH) jqinstalled (JSON parsing in hook scripts)- Node.js 18+ (for ThumbGate, optional)
- Recommended: Grass for remote and mobile approval forwarding on unattended sessions
Why Default Agent Behavior Isn't Enough
Codex's full-auto mode (--approval-mode full-auto) executes everything without pausing — no checkpoint before a database migration, no architecture sign-off, no pause before git push --force. Codex's actual default is suggest mode, but most developers switch to full-auto for long tasks. Claude Code's default interactive mode is better, but the --dangerously-skip-permissions flag removes all gates entirely — which is exactly how most developers run long-horizon tasks.
The community has noticed. A thread in r/ClaudeCode on the missing edit approval problem describes agents making architectural decisions without sign-off as "terrible for anyone who actually reads the code." A separate thread in r/ClaudeAI asks directly: "Would you ever want to pause and approve a tool call before it executes?" — and the responses show clear demand for exactly this.
As researchers studying AI agent execution have shown, blast radius scales directly with the permissions an agent holds. Disciplined AI coding practices frame approval gates not as friction but as checkpoints: "Checkpoints help you inspect direction before the agent moves further." That framing is correct — gates aren't about distrust, they're about staying in the loop on the actions that matter.
As we've covered in The Permission Layer Is 98% of Agent Engineering, this layer is where most of the real safety engineering happens — not in the AI model's reasoning, but in what it's allowed to execute.
Step 1: Add a PreToolUse Hook Gate to Claude Code
Claude Code's settings.json supports PreToolUse hooks — shell commands that run before any tool execution. The hook receives the full tool call as JSON on stdin and controls whether execution proceeds via stdout and exit code.
Hook configuration
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/bash-gate.sh"
}
]
}
]
}
}
The matcher field targets a specific tool type. "Bash" intercepts all shell commands. You can add multiple matchers — one per tool type — with separate gate logic for each.
A working gate script
This script auto-approves read operations, hard-blocks destructive patterns, and prompts interactively for everything else:
#!/bin/bash
# ~/.claude/hooks/bash-gate.sh
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Hard block: destructive patterns — require manual action outside the session
DESTRUCTIVE='(rm -rf|DROP TABLE|DROP DATABASE|git push --force|git reset --hard)'
if echo "$COMMAND" | grep -qiE "$DESTRUCTIVE"; then
echo '{"decision":"block","reason":"Destructive operation — requires manual approval outside agent session"}'
exit 0
fi
# Auto-approve: read-only tool calls
if echo "$TOOL" | grep -qE '^(Read|Glob|Grep|LS)$'; then
exit 0 # exit 0, no stdout = allow
fi
# Interactive prompt for everything else
echo "[gate] Tool: $TOOL" >&2
[ -n "$COMMAND" ] && echo "[gate] Command: $COMMAND" >&2
read -rp "[gate] Allow? [y/N] " REPLY < /dev/tty >&2
[[ "$REPLY" =~ ^[Yy]$ ]] && exit 0
echo '{"decision":"block","reason":"Denied at terminal gate"}'
exit 0
Make it executable: chmod +x ~/.claude/hooks/bash-gate.sh
The exit code contract:
- Exit
0, no stdout → allow the tool call - Exit
0, stdout contains{"decision":"block","reason":"..."}→ block and surface the reason to the agent - Non-zero exit → also blocks, but without a structured reason
Step 2: Add Risk-Tiered Gates per Tool Type
A single Bash gate covers shell commands. Agents also use Write (create or overwrite files), Edit (patch existing files), and WebFetch (external HTTP). A risk-tiered approach matches gate strictness to consequence level:
| Risk tier | Tool types | Gate behavior |
|---|---|---|
| Auto-approve | Read, Glob, Grep, LS | Pass through — no human needed |
| Prompt | Write, Edit, WebFetch | Interactive or async approval |
| Hard block | Bash (destructive patterns), Write (sensitive paths) | Block, require manual action |
Add a Write gate alongside your Bash gate:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/bash-gate.sh" }]
},
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/write-gate.sh" }]
}
]
}
}
The write gate blocks writes to sensitive paths and prompts for everything else:
#!/bin/bash
# ~/.claude/hooks/write-gate.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Block writes to sensitive locations
SENSITIVE='(\.(env|pem|key|secret)|/migrations/|/seeds/|/config/secrets)'
if echo "$FILE_PATH" | grep -qiE "$SENSITIVE"; then
echo "{\"decision\":\"block\",\"reason\":\"Write to $FILE_PATH requires manual review\"}"
exit 0
fi
echo "[gate] Write to: $FILE_PATH" >&2
read -rp "[gate] Allow? [y/N] " REPLY < /dev/tty >&2
[[ "$REPLY" =~ ^[Yy]$ ]] && exit 0
echo '{"decision":"block","reason":"Denied at write gate"}'
exit 0
SoftwareSeni's guide on implementing approval gates makes an important point here: the key design question is whether each checkpoint is actually reachable at review time. A gate that prompts on every operation creates approval fatigue that gets bypassed; a gate that prompts only on consequential operations gets used.
Step 3: Build a Feedback-Driven Blocklist with ThumbGate
The two patterns above require you to predict what to block upfront. ThumbGate takes the opposite approach: one thumbs-down on an agent action automatically creates a PreToolUse gate that blocks that exact pattern in all future sessions. The blocklist is shareable across team sessions.
The workflow:
- Run your agent with ThumbGate enabled
- Agent attempts an action you want to block — thumbs it down in the ThumbGate UI
- ThumbGate adds the pattern to a persistent blocklist and updates
settings.json - Next time the agent attempts that pattern: blocked before execution
This is a fundamentally different UX — you react to observed agent behavior rather than speculating about it upfront. Over a week of real use, your blocklist reflects actual failure modes from your specific codebase and workflow, not generic dangerous patterns.
ThumbGate hooks into the same PreToolUse mechanism described above. It adds its own entries to settings.json and runs alongside any gate scripts you've already configured.
Step 4: Protocol-Level Gates for Agent-to-Agent Messaging
A less obvious application of HITL approval: multi-agent workflows where one Claude Code instance sends messages or triggers actions that cost real API credits. A recently open-sourced messaging skill for Claude Code implements protocol-level human-in-the-loop approval — the agent pauses and waits for explicit sign-off before posting a message to another agent instance, before spending a credit, or before triggering a downstream action.
This is the approval gate pattern applied at the orchestration layer, not just the tool layer. The same PreToolUse hook mechanism can intercept a custom SendMessage tool and require approval before inter-agent communication executes. Useful for any workflow where agent A dispatches work to agent B and the cost or consequence of that dispatch is non-trivial.
How to Verify Your Gate Stack
Before running an actual agent task, verify the gate scripts directly:
# Test 1: destructive pattern should be hard-blocked
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ./tmp-test"}}' \
| ~/.claude/hooks/bash-gate.sh
# Expected output: {"decision":"block","reason":"Destructive operation..."}
# Test 2: read-only tool should pass without prompting
echo '{"tool_name":"Read","tool_input":{"file_path":"./README.md"}}' \
| ~/.claude/hooks/bash-gate.sh
# Expected: no output, exit 0
# Test 3: normal command should trigger interactive prompt
echo '{"tool_name":"Bash","tool_input":{"command":"npm install"}}' \
| ~/.claude/hooks/bash-gate.sh
# Expected: terminal prompt [gate] Allow? [y/N]
Then run a real agent session with a read-heavy task first. Confirm that file exploration completes without interruption and that your first write or shell command triggers the expected gate. Verify the hard-block list by asking the agent to run a command that matches your destructive pattern — it should refuse before touching the filesystem.
Troubleshooting Common Gate Issues
Hook not firing at all
Check that .claude/settings.json is in the project root or your home ~/.claude/ directory. Validate the JSON: cat .claude/settings.json | jq .. Confirm the hook script is executable: ls -la ~/.claude/hooks/.
Hook blocking every call including reads
Check the matcher field. "matcher": "Bash" only intercepts Bash calls. If you accidentally used "*" or omitted the matcher, it intercepts all tool types. Add set -x to the hook script to trace execution.
Interactive prompt fails in unattended environments
When Claude Code runs from a script or without a TTY, /dev/tty is unavailable. In that case, default-deny and log the blocked action — the agent shouldn't need interactive approval in headless environments. This is exactly the scenario that makes async mobile forwarding (below) necessary.
Gate fires but agent continues anyway
Make sure stdout contains exactly the JSON {"decision":"block","reason":"..."} with no extra whitespace or debug output before the JSON. Write debug output to stderr, not stdout.
Edge cases where hooks don't fire
Hooks have documented bypass vectors — tool calls that arrive through certain invocation paths may not trigger PreToolUse. See Why Claude Code PreToolUse Hooks Can Still Be Bypassed for the full map of where the hook layer has gaps and what to do about them.
How Grass Makes This Workflow Better
The patterns above cover the synchronous case: you're at a terminal, available when the gate fires. Most serious agent work isn't synchronous. You fire off a task before a meeting, start a long-running refactor overnight, or queue up parallel agents across repos. A terminal prompt blocking on [y/N] doesn't help when you're not at your keyboard.
Grass solves the async case by forwarding permission requests to your phone as native modals. The gate stays active; it just stops requiring a terminal.
How it works: Install Grass (npm install -g @grass-ai/ide), run grass start in your project directory, and scan the QR code from the Grass iOS app. Your Claude Code session now runs inside Grass. When the agent hits a PreToolUse gate that requires approval, Grass intercepts the permission_request event and sends it to your phone:
Agent wants to run:
Tool: Bash
Command: git push origin main
[Allow] [Deny]
One tap to approve or deny. Haptic feedback confirms. The session continues or blocks accordingly. The round-trip takes under two seconds from the permission request to the agent receiving your decision.
Why this changes unattended runs entirely: Without mobile forwarding, you have two options for unattended agent tasks: disable gates entirely (--dangerously-skip-permissions), or accept that the agent will block indefinitely waiting for a terminal prompt. Grass gives you a third option — keep the gates active and handle them from wherever you are. You can review diffs, handle permission requests, and check session progress from your phone while your agent works.
As Elementum AI notes in their analysis of human-in-the-loop agentic systems, the governance gap between autonomous agent actions and human-approved ones grows with deployment scale — and so does the blast radius when something goes wrong. Mobile-async approval is what makes HITL practical at scale without making it a bottleneck.
If you're using Grass's cloud VM product (always-on Daytona VMs), the agent keeps running even when your laptop is closed, and permission requests still route to your phone. That's the pattern that makes overnight or multi-hour agent tasks viable: you're in the loop without being at a desk.
For a full walkthrough of the mobile approval UI, How to Approve or Deny a Coding Agent Action from Your Phone covers the exact flow — what each permission request looks like, how the tool preview is formatted, and how to handle a queue of pending requests.
After each session, running a post-run audit is good hygiene even when you had gates active — How to Audit What Your AI Agent Actually Did After the Session covers how to verify the agent stayed within scope. Gates are the prevention layer; audits are the detection layer for the cases gates miss.
FAQ
What is a human-in-the-loop approval gate for AI coding agents?
A human-in-the-loop (HITL) approval gate is a checkpoint in an AI coding agent's task execution where the agent pauses and waits for explicit human confirmation before running a specific operation. Gates are triggered by tool calls — a Bash command, a file write, an API request — and are configured to fire on specific patterns or operation categories. Approved operations proceed; denied ones are blocked and reported back to the agent as a refusal.
How do you add an approval gate to Claude Code?
Claude Code supports approval gates via PreToolUse hooks in .claude/settings.json. Add a PreToolUse entry with a matcher (the tool type to intercept) and a command (the shell script to run before that tool executes). The script receives tool input as JSON on stdin and returns a block/allow decision via stdout and exit code. Place settings.json in the project root for per-project gates or in ~/.claude/settings.json for global gates.
What is the difference between YOLO mode and approval gates in AI coding agents?
YOLO mode (Claude Code's --dangerously-skip-permissions) and Codex's --approval-mode full-auto both disable all approval prompts — the agent executes every tool call without pausing. Codex's default is suggest mode; full-auto is opt-in. Approval gates are the inverse: they intercept tool calls before execution and require human confirmation for specified operations while auto-approving safe ones. YOLO mode maximizes throughput but maximizes blast radius; approval gates let you tune the tradeoff by operation type and risk level.
How do I approve a Claude Code tool call remotely or from my phone?
With Grass, permission requests from Claude Code sessions forward to the Grass mobile app as native modals. The modal shows the tool name and a preview of what will execute. Tap Allow or Deny — the agent session continues or blocks accordingly. This works for local sessions (laptop running Grass CLI) and cloud sessions (always-on Daytona VM via Grass cloud product). No terminal watch required.
Can approval gate configurations be shared across a team?
Yes, in two ways. ThumbGate blocklists are exportable — patterns blocked by one developer can be distributed to teammates so the whole team enforces a shared gate configuration derived from observed failures. Hook-based gate configurations in .claude/settings.json can be committed to the repo directly, making the gate stack part of the codebase and automatically applied to every developer's Claude Code sessions.
What happens when a gate doesn't fire and the agent runs something destructive?
If a gate misconfigures and the agent runs an operation it shouldn't have, the damage depends on what ran. This is why post-run auditing is an important complement to pre-execution gates — gates are the prevention layer, audits are the detection layer. A post-run diff of git diff HEAD and a review of the agent's session transcript will surface anything the gate missed.
Next Steps
The practical sequence for most setups:
- Start with the hook-based gate from Step 1 — 15 minutes to wire up, immediately bounds blast radius on destructive patterns
- Add risk-tiered gates per tool type (Step 2) — extend coverage from Bash to Write and Edit with separate gate logic per risk tier
- Let ThumbGate build your blocklist — run a few real sessions and thumbs-down anything you don't like; your gate list reflects actual failure modes within a week
- Install Grass and scan the QR code — move from terminal-blocking gates to async mobile approval so you can run agents unattended without disabling the gates entirely
Get started with Grass → — 10 free hours, no credit card required. Install the CLI, scan the QR code, and your next agent session has a mobile-native gate layer ready to go.