NanoClaw

Overview

NanoClaw is a deliberately minimal Node.js personal AI assistant. Agents run in OS-level containers (Apple Container on macOS, Docker elsewhere). Customization is code — fork the repo or ship a Claude Code skill (/add-telegram, /add-ravi-identity). There is no npm install of an orchestration framework; you own the codebase.

This minimal philosophy creates a specific constraint: the agent process is Node.js inside a container, and adding the Ravi CLI binary to every container image is unwanted overhead. The cleaner path is direct REST API calls via fetch() — no CLI, no subprocess, no extra dependency.

This guide shows that path.

The ravi.js Client

A minimal Ravi client that covers identity, email, and SMS polling. No npm packages required — only the Node.js standard library and fetch (available natively in Node 18+).

// lib/ravi.js
// Minimal Ravi REST client — no dependencies, Node 18+ fetch only.

const BASE = "https://ravi.app";

function headers(identityUuid) {
  const h = {
    Authorization: `Bearer ${process.env.RAVI_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  };
  if (identityUuid) h["X-Ravi-Identity"] = identityUuid;
  return h;
}

async function request(method, path, body, identityUuid) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: headers(identityUuid),
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Ravi API ${method} ${path} → ${res.status}: ${text}`);
  }
  return res.status === 204 ? null : res.json();
}

// --- Identity ---

export async function listIdentities() {
  return request("GET", "/api/identities/");
}

export async function createIdentity(name) {
  return request("POST", "/api/identities/", { name });
}

export async function getOrCreateIdentity(name) {
  const existing = await listIdentities();
  const found = existing.find((i) => i.name === name);
  if (found) return found;
  return createIdentity(name);
}

// --- Email inbox ---

export async function listEmailThreads(identityUuid, { unread = false } = {}) {
  const q = unread ? "?unread=true" : "";
  return request("GET", `/api/email-inbox/${q}`, undefined, identityUuid);
}

// --- SMS inbox ---

export async function listSmsConversations(identityUuid, { unread = false } = {}) {
  const q = unread ? "?unread=true" : "";
  return request("GET", `/api/sms-inbox/${q}`, undefined, identityUuid);
}

// --- Polling helpers ---

export async function pollSmsOtp(identityUuid, { timeoutMs = 30_000, intervalMs = 2_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const convs = await listSmsConversations(identityUuid, { unread: true });
    if (convs.length > 0) {
      const body = convs[0].preview ?? "";
      const match = body.match(/\d{4,8}/);
      if (match) return match[0];
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`pollSmsOtp: no OTP received within ${timeoutMs}ms`);
}

export async function pollEmailInbox(identityUuid, { timeoutMs = 60_000, intervalMs = 3_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const threads = await listEmailThreads(identityUuid, { unread: true });
    if (threads.length > 0) return threads[0];
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`pollEmailInbox: no email received within ${timeoutMs}ms`);
}

// --- Vault secrets (list only — values are E2E encrypted, readable via CLI/MCP) ---

export async function listSecrets(identityUuid) {
  return request("GET", "/api/vault/", undefined, identityUuid);
}

:::note[E2E-encrypted vault fields] ravi.js can list secrets and get their UUIDs via REST. However, secret values are end-to-end encrypted — the server returns e2e::<base64> ciphertext that only the Ravi CLI or MCP server (running locally with PIN access) can decrypt. For reading plaintext vault secrets from inside a container, use the CLI subprocess pattern with the CLI installed in the image, or inject the secret values as environment variables at startup time. See Secrets in containerized deployments. :::

Adding Ravi to a NanoClaw Agent — The /add-ravi-identity Skill

NanoClaw’s customization model is Claude Code skills. To wire a Ravi identity into an agent, add a SKILL.md that Claude Code can execute during container setup:

<!-- skills/add-ravi-identity/SKILL.md -->
# /add-ravi-identity

Provision a Ravi identity for this agent and inject it into the runtime environment.

## Steps

1. Ensure `RAVI_ACCESS_TOKEN` is set in the environment (injected at container launch — see below).
2. Call `lib/ravi.js``getOrCreateIdentity(agentName)` where `agentName` matches the container name.
3. Write the identity `uuid`, `inbox` (email), and `phone` to `.env` in the project root:

RAVI_IDENTITY_UUID= AGENT_EMAIL= AGENT_PHONE=

4. Confirm by logging `Agent identity ready: ${inbox}`.

## Usage

```bash
/add-ravi-identity

Run once per container image build or at first startup. Idempotent — safe to re-run.


The corresponding setup script:

```javascript
// scripts/add-ravi-identity.js
import { getOrCreateIdentity } from "../lib/ravi.js";
import { writeFileSync, readFileSync, existsSync } from "fs";

const agentName = process.env.NANOCLAW_AGENT_NAME ?? "nanoclaw-agent";
const envPath = ".env";

const identity = await getOrCreateIdentity(agentName);

const lines = [
  `RAVI_IDENTITY_UUID=${identity.uuid}`,
  `AGENT_EMAIL=${identity.inbox}`,
  `AGENT_PHONE=${identity.phone}`,
];

const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
const toAppend = lines.filter((l) => !existing.includes(l.split("=")[0])).join("\n");
if (toAppend) writeFileSync(envPath, existing + "\n" + toAppend + "\n");

console.log(`Agent identity ready: ${identity.inbox} / ${identity.phone}`);

Container Deployment

NanoClaw agents run in sandboxed containers. Ravi token injection at launch:

# Apple Container (macOS)
container run \
  -e RAVI_ACCESS_TOKEN="$(cat ~/.ravi/auth.json | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")" \
  -e NANOCLAW_AGENT_NAME="my-nanoclaw-agent" \
  nanoclaw-image

# Docker
docker run \
  -e RAVI_ACCESS_TOKEN="$RAVI_ACCESS_TOKEN" \
  -e NANOCLAW_AGENT_NAME="my-nanoclaw-agent" \
  nanoclaw-image

In compose.yml:

services:
  nanoclaw:
    image: nanoclaw-image
    environment:
      RAVI_ACCESS_TOKEN: ${RAVI_ACCESS_TOKEN}
      NANOCLAW_AGENT_NAME: nanoclaw-agent

:::note[Token refresh in long-running containers] RAVI_ACCESS_TOKEN is a short-lived JWT. For containers running more than a few hours, set up a token refresh loop or mount ~/.ravi/auth.json read-only and call POST /api/auth/token/refresh/ periodically. See Authentication for the refresh pattern. :::

Using Ravi in Agent Logic

With lib/ravi.js loaded and RAVI_IDENTITY_UUID set from the init script:

// agent.js
import { pollSmsOtp, pollEmailInbox, listSecrets } from "./lib/ravi.js";

const identityUuid = process.env.RAVI_IDENTITY_UUID;

// Wait for an SMS OTP after triggering a verification
async function handleSmsVerification() {
  const otp = await pollSmsOtp(identityUuid, { timeoutMs: 30_000 });
  console.log(`OTP received: ${otp}`);
  return otp;
}

// Wait for a verification email after signup
async function handleEmailVerification() {
  const thread = await pollEmailInbox(identityUuid, { timeoutMs: 60_000 });
  // Extract verification link from subject/preview
  const linkMatch = thread.preview?.match(/https?:\/\/[^\s]+/);
  return linkMatch?.[0] ?? null;
}

// List available vault secrets (keys only — values E2E encrypted)
async function listAvailableSecrets() {
  const secrets = await listSecrets(identityUuid);
  return secrets.map((s) => s.key);
}

When to Use CLI Subprocess Instead

The fetch()-only path covers identity creation, email/SMS polling, and vault key listing. Use the CLI subprocess approach (with the ravi binary in the container image) when you need to:

OperationCLI needed?Reason
Read plaintext vault secretsYesValues are E2E encrypted; only CLI can decrypt
Read plaintext passwordsYesSame E2E encryption constraint
Send email from the agentNoPOST /api/email-messages/compose/ works via REST
Store credentials after signupYesCLI encrypts locally before writing

For secrets that must be readable from code, inject them at container launch as environment variables (extract once on the host, pass in via -e). See Credential Vault — containerized deployments.