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=
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:
| Operation | CLI needed? | Reason |
|---|---|---|
| Read plaintext vault secrets | Yes | Values are E2E encrypted; only CLI can decrypt |
| Read plaintext passwords | Yes | Same E2E encryption constraint |
| Send email from the agent | No | POST /api/email-messages/compose/ works via REST |
| Store credentials after signup | Yes | CLI 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.
Related Pages
- API Overview — base URL, auth headers, response format
- API Endpoints — full REST endpoint reference
- TypeScript REST API — complete TypeScript REST client with token refresh and SSE streaming
- Production Patterns — deployment checklist and identity lifecycle
- Credential Vault — E2E encryption and container secrets injection