Async Human Approval via Email
The pattern
Some agent actions are too risky to execute autonomously: deploying to production, sending a bulk email, making a financial transaction, deleting data. The standard approach is a human-in-the-loop gate — but most implementations require a Slack bot, a webhook endpoint, or an approval dashboard.
With Ravi, an agent already has a real email address. It can send an approval request, poll its inbox for the reply, and proceed or abort — no extra infrastructure needed.
Agent hits high-stakes action
→ Compose email from agent's Ravi identity: "Reply APPROVE or DENY"
→ Poll inbox for reply (with timeout)
→ Parse APPROVE / DENY from reply body
→ Proceed or abort
The human sees email from worker-3@in.ravi.app. They reply to the same thread. The approval trail is a real email thread with timestamps, audit-ready forever.
Complete bash example
#!/usr/bin/env bash
set -euo pipefail
APPROVER="human@yourcompany.com"
ACTION="kubectl apply -f deploy.yaml (prod)"
TIMEOUT_SECS=600 # 10 minutes
POLL_INTERVAL=15
ELAPSED=0
AGENT_EMAIL=$(ravi get email --json | jq -r '.email')
# 1. Send the approval request
echo "Requesting approval for: $ACTION"
COMPOSE_RESULT=$(ravi email compose \
--to "$APPROVER" \
--subject "Approval required: $ACTION" \
--body "<p>An agent is requesting approval for a high-stakes action.</p>
<p><strong>Action:</strong> $ACTION</p>
<p><strong>Agent:</strong> $AGENT_EMAIL</p>
<p>Reply <strong>APPROVE</strong> to proceed or <strong>DENY</strong> to abort.</p>" \
--json)
THREAD_ID=$(echo "$COMPOSE_RESULT" | jq -r '.thread_id')
# 2. Poll for a reply (with timeout)
DECISION=""
while [ $ELAPSED -lt $TIMEOUT_SECS ]; do
sleep $POLL_INTERVAL
ELAPSED=$((ELAPSED + POLL_INTERVAL))
# Check for a new message in the thread
REPLY=$(ravi inbox email "$THREAD_ID" --json \
| jq -r '.messages | sort_by(.created_at) | last | .text_content // empty')
if [ -z "$REPLY" ]; then
echo "No reply yet (${ELAPSED}s elapsed)..."
continue
fi
# Parse the decision (case-insensitive)
if echo "$REPLY" | grep -qi "^APPROVE"; then
DECISION="approved"
break
elif echo "$REPLY" | grep -qi "^DENY"; then
DECISION="denied"
break
fi
done
# 3. Act on the decision
if [ "$DECISION" = "approved" ]; then
echo "Approved. Proceeding with: $ACTION"
# kubectl apply -f deploy.yaml
elif [ "$DECISION" = "denied" ]; then
echo "Denied. Aborting."
exit 1
else
echo "Timed out after ${TIMEOUT_SECS}s. Aborting."
exit 2
fi
Python example
import subprocess
import json
import time
APPROVER = "human@yourcompany.com"
ACTION = "Deploy v2.3.1 to production"
TIMEOUT = 600 # 10 minutes
POLL_INTERVAL = 15
def ravi(*args) -> dict:
result = subprocess.run(
["ravi", *args, "--json"],
capture_output=True, text=True, check=True
)
return json.loads(result.stdout)
def request_approval(action: str, approver: str) -> str:
"""Send an approval request and return the thread_id."""
agent_email = ravi("get", "email")["email"]
body = f"""<p>An agent is requesting approval for a high-stakes action.</p>
<p><strong>Action:</strong> {action}</p>
<p><strong>Agent:</strong> {agent_email}</p>
<p>Reply <strong>APPROVE</strong> to proceed or <strong>DENY</strong> to abort.</p>"""
result = ravi(
"email", "compose",
"--to", approver,
"--subject", f"Approval required: {action}",
"--body", body,
)
return result["thread_id"]
def wait_for_decision(thread_id: str, timeout: int, poll_interval: int) -> str:
"""Poll for a reply. Returns 'approved', 'denied', or 'timeout'."""
seen_message_count = 1 # the outbound message we sent
elapsed = 0
while elapsed < timeout:
time.sleep(poll_interval)
elapsed += poll_interval
thread = ravi("inbox", "email", thread_id)
messages = sorted(thread["messages"], key=lambda m: m["created_at"])
if len(messages) <= seen_message_count:
print(f"No reply yet ({elapsed}s elapsed)...")
continue
# New message arrived
latest = messages[-1]["text_content"] or ""
if latest.strip().upper().startswith("APPROVE"):
return "approved"
elif latest.strip().upper().startswith("DENY"):
return "denied"
seen_message_count = len(messages)
return "timeout"
# --- Main flow ---
thread_id = request_approval(ACTION, APPROVER)
print(f"Approval request sent. Thread: {thread_id}")
decision = wait_for_decision(thread_id, TIMEOUT, POLL_INTERVAL)
if decision == "approved":
print(f"Approved. Executing: {ACTION}")
# subprocess.run(["kubectl", "apply", "-f", "deploy.yaml"], check=True)
elif decision == "denied":
print("Denied by approver. Aborting.")
raise SystemExit(1)
else:
print(f"Timed out after {TIMEOUT}s. Aborting.")
raise SystemExit(2)
Why this works in production
No infrastructure to run. No Slack app to configure, no webhook to expose, no approval dashboard to host. The agent’s Ravi email address is the approval channel.
The approval trail is real. Every request and reply is a real email thread — timestamped, auditable, searchable. If someone asks “who approved the Friday deploy?”, the answer is in email.
The human knows which agent is asking. The email comes from worker-3@in.ravi.app — not a generic bot address. When you’re running 10 agents, you know exactly which one needs a decision.
No polling pressure. Email delivery is push; your agent can poll slowly (every 15–30 seconds) without creating load. Agents waiting for approval don’t block anything.
Production tips
- Set a timeout — always exit after a reasonable window. Don’t leave agents blocked indefinitely on a reply that never comes.
- Use a dedicated approval identity — create a
deploy-gateidentity for all approval requests so you can filter your inbox easily. - Parse loosely —
grep -qi "^APPROVE"matches “APPROVED”, “Approve”, “approve it”, etc. Don’t require an exact string. - Log the thread ID — store it alongside the task record so you can pull the full audit trail later.
- Parallel agents, parallel approvals — each agent has its own Ravi identity, so 10 parallel agents send 10 separate approval threads. The approver can see the agent name in the From field.
Risk classification
Based on the HumanLayer model, these action categories benefit from async approval:
| Risk level | Examples | Recommendation |
|---|---|---|
| Read-only | Fetch data, search | No approval needed |
| Read private | Access credentials, vaults | Log only |
| Write | Send emails, modify files | Optional approval |
| Communicate on behalf | Send to customers, post publicly | Require approval |
| Destructive / irreversible | Delete data, prod deploy | Require approval |
Next steps
- Multi-Agent Setup — isolate each agent with its own Identity
- Email — full email reference
- Real-World Scenarios — more end-to-end agent workflows