Mercury
Overview
Mercury is a TypeScript-first personal AI assistant that lives inside your chat. It uses a space model where each space is an isolated context with its own .env-based configuration, extension set, and identity. Mercury’s extension ecosystem follows a @mercuryai/<service> pattern — @mercuryai/github, @mercuryai/google-workspace, and so on — where each extension exposes async tool functions that Mercury calls at runtime.
Ravi maps directly onto this model: one Ravi identity per Mercury space gives each space a real provisioned email address, a phone number for SMS verification, and an E2E-encrypted credentials vault. The identity persists across restarts, container upgrades, and platform migrations.
The “One Identity Per Space” Pattern
Mercury’s space model and Ravi identities are isomorphic. Each space has a unique name; each Ravi identity has a unique name. The convention: derive the Ravi identity name from the space ID at initialization time, and use an idempotent check-or-create so the space always wakes up with the same address.
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
// ravi() helper: run a ravi CLI command scoped to a config directory
function ravi(args: string[], configDir?: string): unknown {
const env = configDir
? { ...process.env, RAVI_CONFIG_DIR: configDir }
: process.env;
try {
const raw = execSync(`ravi ${args.join(" ")} --json`, {
encoding: "utf8",
env,
stdio: ["pipe", "pipe", "pipe"],
});
return JSON.parse(raw);
} catch (err: unknown) {
const e = err as { stderr?: Buffer; stdout?: Buffer };
throw new Error(
`ravi ${args[0]} failed: ${e.stderr?.toString() ?? e.stdout?.toString() ?? String(err)}`
);
}
}
interface RaviIdentity {
uuid: string;
name: string;
inbox: string;
phone: string;
}
// Idempotent: create the identity if it doesn't exist yet, then return it
async function getOrCreateSpaceIdentity(spaceName: string): Promise<{
identity: RaviIdentity;
configDir: string;
}> {
// Derive a stable config dir from the space name
const configDir = path.join(os.homedir(), ".ravi", "spaces", spaceName);
fs.mkdirSync(configDir, { recursive: true });
// Write a config.json scoped to this space
const configPath = path.join(configDir, "config.json");
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify({ identity: spaceName }, null, 2));
}
// Try to create the identity — tolerate "already exists" error
try {
ravi(["identity", "create", "--name", spaceName], configDir);
} catch (err: unknown) {
const msg = String(err);
if (!msg.includes("already exists") && !msg.includes("409")) {
throw err;
}
}
// Set the identity as active in this config dir
ravi(["identity", "use", spaceName], configDir);
const email = (ravi(["get", "email"], configDir) as { email: string }).email;
const phone = (ravi(["get", "phone"], configDir) as { phone_number: string }).phone_number;
return {
identity: { uuid: "", name: spaceName, inbox: email, phone },
configDir,
};
}
:::note[RAVI_CONFIG_DIR is safe for parallel spaces]
Each space gets its own RAVI_CONFIG_DIR. Calls from different spaces never touch the same config file and cannot race. Do not call ravi identity use globally (without a RAVI_CONFIG_DIR) from concurrent processes — it writes shared state.
:::
Wiring Into a Mercury Extension
A Mercury extension is a TypeScript module that exports an array of tool definitions. Here’s a minimal @mercuryai/ravi extension that exposes Ravi primitives as callable tools:
// extensions/ravi-identity/index.ts
import { execSync } from "child_process";
import * as os from "os";
import * as path from "path";
export interface RaviExtensionConfig {
spaceName: string; // set at Mercury space init
}
export function createRaviTools(config: RaviExtensionConfig) {
const configDir = path.join(os.homedir(), ".ravi", "spaces", config.spaceName);
function ravi(args: string[]): unknown {
const raw = execSync(`ravi ${args.join(" ")} --json`, {
encoding: "utf8",
env: { ...process.env, RAVI_CONFIG_DIR: configDir },
stdio: ["pipe", "pipe", "pipe"],
});
return JSON.parse(raw);
}
return [
{
name: "ravi_get_email",
description:
"Get this space's provisioned email address. Use before signing up for a service or composing an email.",
input_schema: { type: "object", properties: {} },
handler: async () => ravi(["get", "email"]),
},
{
name: "ravi_get_phone",
description:
"Get this space's provisioned phone number (E.164 format). Use before triggering SMS verification.",
input_schema: { type: "object", properties: {} },
handler: async () => ravi(["get", "phone"]),
},
{
name: "ravi_poll_sms",
description:
"Poll for an incoming SMS OTP. Call after triggering an SMS verification. Returns the first unread message body, or throws on timeout.",
input_schema: {
type: "object",
properties: {
timeout_seconds: {
type: "number",
description: "How long to wait (default 20s)",
},
},
},
handler: async ({ timeout_seconds = 20 }: { timeout_seconds?: number }) => {
const maxAttempts = Math.ceil(timeout_seconds / 2);
for (let i = 0; i < maxAttempts; i++) {
const msgs = ravi(["inbox", "sms", "--unread"]) as Array<{
preview: string;
}>;
if (msgs.length > 0) {
return { body: msgs[0].preview };
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`ravi_poll_sms: no SMS arrived within ${timeout_seconds}s`);
},
},
{
name: "ravi_poll_email",
description:
"Poll for an incoming email. Call after triggering an email verification. Returns the first unread subject and body preview, or throws on timeout.",
input_schema: {
type: "object",
properties: {
timeout_seconds: {
type: "number",
description: "How long to wait (default 60s)",
},
},
},
handler: async ({ timeout_seconds = 60 }: { timeout_seconds?: number }) => {
const maxAttempts = Math.ceil(timeout_seconds / 3);
for (let i = 0; i < maxAttempts; i++) {
const threads = ravi(["inbox", "email", "--unread"]) as Array<{
subject: string;
preview: string;
}>;
if (threads.length > 0) {
return { subject: threads[0].subject, preview: threads[0].preview };
}
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`ravi_poll_email: no email arrived within ${timeout_seconds}s`);
},
},
{
name: "ravi_get_secret",
description:
"Retrieve a secret from this space's Ravi vault by key name (e.g. OPENAI_API_KEY, GITHUB_TOKEN).",
input_schema: {
type: "object",
properties: {
key: { type: "string", description: "Secret key name (UPPERCASE_WITH_UNDERSCORES)" },
},
required: ["key"],
},
handler: async ({ key }: { key: string }) => {
const secrets = ravi(["secrets", "list"]) as Array<{
key: string;
uuid: string;
}>;
const entry = secrets.find((s) => s.key === key);
if (!entry) throw new Error(`Secret '${key}' not found in vault`);
return ravi(["secrets", "get", entry.uuid]);
},
},
];
}
Space Initialization
In your Mercury space setup hook, provision the identity and write credentials to .env for other extensions to consume:
// space-init.ts
import { getOrCreateSpaceIdentity } from "./ravi-identity";
import * as fs from "fs";
export async function initSpace(spaceName: string, envPath: string) {
const { identity } = await getOrCreateSpaceIdentity(spaceName);
// Append Ravi credentials to the space's .env file
const lines = [
`AGENT_EMAIL=${identity.inbox}`,
`AGENT_PHONE=${identity.phone}`,
`RAVI_SPACE_NAME=${spaceName}`,
];
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
const toAppend = lines
.filter((l) => !existing.includes(l.split("=")[0]))
.join("\n");
if (toAppend) {
fs.appendFileSync(envPath, "\n" + toAppend + "\n");
}
console.log(`Space '${spaceName}' initialized — email: ${identity.inbox}, phone: ${identity.phone}`);
}
Complete Signup Flow in a Mercury Space
The full browser-automation → OTP-intercept → credential-store chain in TypeScript:
import { createRaviTools } from "./extensions/ravi-identity";
import * as os from "os";
import * as path from "path";
import { execSync } from "child_process";
const spaceName = process.env.MERCURY_SPACE_NAME ?? "default";
const tools = createRaviTools({ spaceName });
// Helper to call a tool by name
async function callTool(name: string, input: Record<string, unknown> = {}): Promise<unknown> {
const tool = tools.find((t) => t.name === name);
if (!tool) throw new Error(`Tool '${name}' not registered`);
return tool.handler(input as never);
}
async function signUpWithVerification(serviceUrl: string) {
// 1. Get provisioned credentials
const { email } = (await callTool("ravi_get_email")) as { email: string };
const { phone_number } = (await callTool("ravi_get_phone")) as { phone_number: string };
console.log(`Using email: ${email}, phone: ${phone_number}`);
// 2. Drive signup form via browser automation (Playwright / cmux / Aperant / etc.)
// Substitute your browser tool here:
// await page.fill("#email", email);
// await page.fill("#phone", phone_number);
// await page.click("#submit");
// 3. Intercept OTP
const { body: otp } = (await callTool("ravi_poll_sms", { timeout_seconds: 30 })) as {
body: string;
};
const code = otp.match(/\d{4,8}/)?.[0];
if (!code) throw new Error(`Could not extract OTP from: ${otp}`);
// 4. Complete verification
// await page.fill("#otp", code);
// await page.click("#verify");
// 5. Store credentials in vault
const configDir = path.join(os.homedir(), ".ravi", "spaces", spaceName);
execSync(
`ravi passwords create ${new URL(serviceUrl).hostname} --username "${email}" --json`,
{ env: { ...process.env, RAVI_CONFIG_DIR: configDir } }
);
console.log("Signup complete — credentials saved to Ravi vault.");
}
Headless / Docker Deployment
If Mercury runs in a container without a browser for device-code auth, inject the token at startup:
# On your dev machine — extract the access token
cat ~/.ravi/auth.json | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])"
# In docker-compose.yml or .env
RAVI_ACCESS_TOKEN=<token>
MERCURY_SPACE_NAME=my-assistant
// startup.ts — load token from env before calling any Ravi CLI
if (!process.env.RAVI_ACCESS_TOKEN && !require("fs").existsSync(`${require("os").homedir()}/.ravi/auth.json`)) {
throw new Error("RAVI_ACCESS_TOKEN must be set in containerized environments");
}
See Authentication for the full headless token injection guide.
Space Lifecycle
| Stage | What to do |
|---|---|
| Space created | ravi identity create --name <space-name> (idempotent with || true) |
| Space active | Use RAVI_CONFIG_DIR scoped calls — never global ravi identity use |
| Space archived | ravi identity delete <space-name> to free the inbox |
| Dev / test spaces | Prefix names with dev- or test- for bulk cleanup: ravi identity list --json | jq -r '.[] | select(.name | startswith("dev-")) | .name' | xargs -I{} ravi identity delete {} |
Related Pages
- TypeScript REST API — in-process REST calls, no CLI subprocess
- Parallel Environments —
.ravi/config.jsonper worktree model - Production Patterns — deployment checklist and ephemeral identity lifecycle
- LobsterAI — similar SDK-native tool-definition integration pattern