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-gate identity for all approval requests so you can filter your inbox easily.
  • Parse looselygrep -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 levelExamplesRecommendation
Read-onlyFetch data, searchNo approval needed
Read privateAccess credentials, vaultsLog only
WriteSend emails, modify filesOptional approval
Communicate on behalfSend to customers, post publiclyRequire approval
Destructive / irreversibleDelete data, prod deployRequire approval

Next steps