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

StageWhat to do
Space createdravi identity create --name <space-name> (idempotent with || true)
Space activeUse RAVI_CONFIG_DIR scoped calls — never global ravi identity use
Space archivedravi identity delete <space-name> to free the inbox
Dev / test spacesPrefix names with dev- or test- for bulk cleanup: ravi identity list --json | jq -r '.[] | select(.name | startswith("dev-")) | .name' | xargs -I{} ravi identity delete {}