Human Approval in OpenClaw Agents

Why Ravi email and SMS — not a polling loop

The naive approach to human-in-the-loop is to block: send a request, sit in a polling loop, wait for a reply. That works for scripted processes. It doesn’t work for agents.

OpenClaw agents respond to messages and heartbeats — they don’t block on loops. And Ravi gives each agent its own email address and phone number, which means the approval request can reach the human wherever they are — inbox, phone, even offline — not just in an active chat session.

The pattern:

  1. Gate — when the agent hits a high-stakes action, write the pending decision to memory and send the human an approval request via Ravi email or SMS
  2. Yield — end the current turn. Don’t block. Don’t poll.
  3. Resume — on the next message or heartbeat, check memory for pending decisions and handle the response

Ravi’s email and SMS are the right channel here because they’re durable (the message survives whether the human is online or not), auditable (every request is a real email from a real address), and two-way (the human replies directly, and Ravi routes that reply back to the agent).


Core pattern

Current turn:
  Agent hits high-stakes action
  → Write to memory: PENDING_APPROVAL { action, requested_at, timeout }
  → Send Ravi email (or SMS) to principal: "I need approval for X. Reply APPROVE or DENY."
  → End turn without executing the action

Next turn (human replies "APPROVE" via email or SMS):
  Agent reads inbound message via Ravi inbox
  → Check memory: is there a pending approval for this action?
  → Decision is "APPROVE" → execute the action, clear the pending entry

Next turn (human replies "DENY"):
  → Clear pending entry, log the denial, do not execute

Heartbeat (timeout check):
  → If pending approval is older than timeout → abandon the action, notify human, clear entry

The key insight: the agent’s memory is the state machine. OpenClaw’s memory system (MEMORY.md or a structured file) persists across turns. The heartbeat is the timeout mechanism. Ravi handles delivery and reply routing.


Memory schema

Store pending approvals in a structured block in MEMORY.md, or in a dedicated file like pending-approvals.json:

{
  "pending_approvals": [
    {
      "id": "deploy-prod-v2.3.1",
      "action": "Deploy v2.3.1 to production",
      "requested_at": "2026-03-14T18:00:00Z",
      "timeout_at": "2026-03-14T18:30:00Z",
      "principal_email": "jake@example.com",
      "principal_phone": "+15551234567",
      "channel": "email",
      "status": "waiting"
    }
  ]
}

The agent reads this at the start of every turn and every heartbeat. If a pending approval exists, the agent checks whether the current message is a reply to it (via ravi_inbox_email or ravi_inbox_sms), and whether the timeout has passed.


Skill: approval-gate

Drop this into your workspace skills folder as skills/approval-gate/SKILL.md:

# Skill: approval-gate

Use this skill when an action requires human sign-off before execution.
Approval requests are sent via Ravi email or SMS.

## When to apply
Use approval-gate for any action that is:
- Irreversible (delete data, send bulk messages, prod deploys)
- External-facing (posting publicly, emailing real users)
- High-cost (spending money, provisioning infrastructure)

## How to gate an action

### 1. Check for existing pending approval
Before gating, check `pending-approvals.json` in the workspace:
- If a pending approval for THIS action already exists and is still waiting → remind the principal, do not create a duplicate
- If approved → execute immediately
- If denied → abort

### 2. Write the pending entry
```json
{
  "id": "<unique-slug>",
  "action": "<human-readable description>",
  "requested_at": "<ISO timestamp>",
  "timeout_at": "<ISO timestamp, 30 min from now>",
  "principal_email": "<email address>",
  "principal_phone": "<E.164 phone number, optional>",
  "channel": "email",
  "status": "waiting"
}

Append to pending-approvals.json. Do NOT execute the action yet.

3. Send the approval request via Ravi

Via email (preferred — durable, auditable, works offline): Use ravi_email_compose to send from your Ravi identity:

Subject: Action requires your approval

Body:

I need your approval before I proceed:

Action: <description>

Reply APPROVE to this email to proceed, or DENY to cancel. This request expires in 30 minutes.

Via SMS (for urgent approvals or when email response is slow): Use ravi_sms_send to send a short approval request:

[AgentName] needs approval: . Reply APPROVE or DENY. Expires in 30 min.

4. End the turn

Do NOT execute the action. End your response here. The action is gated.

On the next turn (human replies)

Check ravi_inbox_email (or ravi_inbox_sms) for new replies. If a reply contains APPROVE or DENY:

  1. Match it to the pending entry (by recency or explicit reference)
  2. If APPROVE → execute the action, remove entry from pending-approvals.json, reply confirming completion
  3. If DENY → remove entry, reply acknowledging: “Understood, action cancelled.”

Heartbeat: timeout check

If a pending entry’s timeout_at is in the past:

  1. Remove the entry from pending-approvals.json
  2. Send an email (or SMS) to the principal: “The approval window for <action> has expired. I’ve cancelled the request — send me a new message when you’re ready.”
  3. Do NOT execute the action.

---

## Complete worked example

### Turn 1 — agent hits a gated action

The agent is asked to send an email blast to 500 users:

User: “Go ahead and send the March newsletter to the mailing list”

Agent turn: → This action is external-facing and irreversible (approval-gate applies) → Read pending-approvals.json — no existing entry for this action → Write pending entry: { “id”: “newsletter-march”, “action”: “Send March newsletter to 500+ subscribers”, “requested_at”: ”…”, “timeout_at”: ”…”, “channel”: “email”, “status”: “waiting” } → Call ravi_email_compose: to: jake@example.com subject: “Action requires your approval” body: “I need your approval before I proceed: Action: Send March newsletter to 500+ subscribers Reply APPROVE to proceed or DENY to cancel. This request expires in 30 minutes.” → Reply to user: “I’ve sent you an approval request by email. Waiting for your sign-off.” → End turn. Email is NOT sent.


### Turn 2 — human replies "APPROVE" via email

Ravi inbox receives reply from jake@example.com: “APPROVE”

Agent turn: → ravi_inbox_email shows unread message from principal → Read thread: reply contains “APPROVE” → Read pending-approvals.json → entry “newsletter-march” found, status: waiting → Decision = approved → Execute: send the newsletter → Remove “newsletter-march” from pending-approvals.json → Reply via email: “Done. Newsletter sent to 523 subscribers.”


### Turn 2 (alternate) — human replies "DENY"

Ravi inbox receives: “DENY — wait until I’ve reviewed the draft”

Agent turn: → Reply contains “DENY” → Remove entry → Reply via email: “Understood. Newsletter cancelled. Let me know when the draft is ready.”


### Heartbeat — timeout fires

Heartbeat fires (30+ min after request)

Agent: → Read pending-approvals.json → entry “newsletter-march” found → timeout_at is in the past → expired → Remove entry → Call ravi_email_compose: to: jake@example.com subject: “Approval request expired” body: “The approval window for sending the March newsletter has expired (requested 30+ minutes ago with no reply). I’ve cancelled the request — send me a message when you’re ready to proceed.”


---

## Email vs. SMS — when to use each

| | Email | SMS |
|---|---|---|
| **Best for** | Rich context, audit trail, async | Urgent, time-sensitive, mobile |
| **Ravi tool** | `ravi_email_compose` | `ravi_sms_send` |
| **Human replies via** | Email reply | SMS reply |
| **Agent reads reply** | `ravi_inbox_email` | `ravi_inbox_sms` |
| **Works offline** | ✅ | ✅ |
| **Character limit** | None | 1600 chars |

For most approval flows, email is the right default — it's durable, gives the human full context, and creates a clean audit trail (each request is a real email from your agent's Ravi address).

SMS is best for urgent approvals where the human needs to act quickly from their phone, or as a fallback when email response is slow.

---

## MEMORY.md integration

Add a standing instruction in MEMORY.md so the agent always checks for pending approvals at the start of every turn:

```markdown
## Pending Approvals

At the start of every turn and every heartbeat:

1. Read `pending-approvals.json`
2. If any entry has `status: waiting` and `timeout_at` is past → cancel it, send expiry email/SMS to principal
3. Check `ravi_inbox_email` and `ravi_inbox_sms` for new replies containing APPROVE or DENY
4. If found → match to the most recent waiting entry, execute or abort

Never execute a gated action until its entry shows `status: approved`.

Multi-channel approval (belt-and-suspenders)

For critical actions, send the request on both channels and resolve on whichever arrives first:

Agent hits gated action
  → Email via Ravi: send approval request (durable, full context)
  → SMS via Ravi: send short approval request (urgent fallback)
  → Memory: record pending entry with 30-min timeout

On next turn: check both ravi_inbox_email and ravi_inbox_sms
Whichever arrives first resolves the gate.
On heartbeat: if timeout expired, cancel and send expiry notice on both channels.

Checklist

  • Add pending-approvals.json to workspace (empty array [] to start)
  • Add the approval-gate standing instruction to MEMORY.md
  • Drop skills/approval-gate/SKILL.md into your workspace
  • Configure heartbeat in OpenClaw (set heartbeat.enabled: true in gateway config)
  • Add principal email and phone to your agent’s MEMORY.md or USER.md
  • Test with a low-stakes action first

See also