LettaBot

LettaBot

LettaBot is a personal AI assistant built on Letta (formerly MemGPT) that runs across Telegram, Signal, and voice. Its defining trait is persistent cross-channel memory — LettaBot remembers conversations across sessions and surfaces context at the right moment.

Ravi gives LettaBot a stable phone number and identity it carries across sessions, a safe place to store API keys, and an SMS inbox for verification codes during channel registration.

Where Ravi Fits

1. Phone number for Signal registration

Signal requires a real phone number to register. LettaBot uses signal-cli to drive Signal — and signal-cli requires the number to be provisioned and verified before it can send or receive messages. Ravi provides a real SMS-capable phone number the bot actually controls.

# Get the Ravi phone number to use with signal-cli
PHONE=$(ravi get phone --json | jq -r '.phone_number')
signal-cli -a "$PHONE" register
# Wait for the OTP SMS to arrive at the Ravi inbox, then verify

2. Encrypted API key storage

LettaBot loads API keys for Telegram, ElevenLabs, and OpenAI at startup. Ravi’s secrets vault stores these encrypted — no plaintext config files or .env files on disk.

ravi secrets set TELEGRAM_BOT_TOKEN=<token>
ravi secrets set ELEVENLABS_API_KEY=<key>
ravi secrets set OPENAI_API_KEY=<key>

3. SMS OTP for verification flows

When LettaBot registers a new service or phone verification is required, the SMS arrives in the Ravi inbox. LettaBot polls for it with a retry loop.

Setup

Prerequisites

  • Ravi CLI installed and authenticated (ravi auth login)
  • LettaBot cloned and configured with a Letta server
  • jq available in your shell

Create a dedicated identity

ravi identity create --name "lettabot" --json
ravi identity use lettabot

Store API keys in Ravi vault

ravi secrets set TELEGRAM_BOT_TOKEN=<your-token>
ravi secrets set ELEVENLABS_API_KEY=<your-key>
ravi secrets set OPENAI_API_KEY=<your-key>

Load secrets at startup (Python)

import subprocess, json, os

def ravi(cmd: list[str]) -> dict:
    result = subprocess.run(
        ["ravi"] + cmd,
        capture_output=True, text=True, check=True,
        env={**os.environ, "RAVI_CONFIG_DIR": os.environ.get("RAVI_CONFIG_DIR", "")}
    )
    return json.loads(result.stdout)

def load_config() -> dict:
    return {
        "telegram_bot_token": ravi(["secrets", "get", "TELEGRAM_BOT_TOKEN", "--json"])["value"],
        "elevenlabs_api_key": ravi(["secrets", "get", "ELEVENLABS_API_KEY", "--json"])["value"],
        "openai_api_key":     ravi(["secrets", "get", "OPENAI_API_KEY", "--json"])["value"],
        "phone":              ravi(["get", "phone", "--json"])["phone_number"],
        "email":              ravi(["get", "email", "--json"])["email"],
    }

config = load_config()
# Pass config["telegram_bot_token"] to python-telegram-bot
# Pass config["phone"] to signal-cli --account

Load secrets at startup (TypeScript)

import { execSync } from "child_process";

function ravi(args: string[]): Record<string, unknown> {
  return JSON.parse(
    execSync(["ravi", ...args].join(" "), { encoding: "utf8" })
  );
}

interface BotConfig {
  telegramBotToken: string;
  elevenLabsApiKey: string;
  openAiApiKey: string;
  phone: string;
  email: string;
}

function loadConfig(): BotConfig {
  return {
    telegramBotToken: ravi(["secrets", "get", "TELEGRAM_BOT_TOKEN", "--json"])["value"] as string,
    elevenLabsApiKey: ravi(["secrets", "get", "ELEVENLABS_API_KEY", "--json"])["value"] as string,
    openAiApiKey:     ravi(["secrets", "get", "OPENAI_API_KEY", "--json"])["value"] as string,
    phone:            ravi(["get", "phone", "--json"])["phone_number"] as string,
    email:            ravi(["get", "email", "--json"])["email"] as string,
  };
}

const config = loadConfig();

Signal Registration

Signal-cli requires the phone number to be registered before it can operate. The verification SMS arrives in the Ravi inbox.

#!/usr/bin/env bash
set -e

PHONE=$(ravi get phone --json | jq -r '.phone_number')

# Register the number
signal-cli -a "$PHONE" register

# Poll for the SMS OTP (up to 30 attempts × 2s = 60s)
CODE=""
for i in $(seq 1 30); do
  CODE=$(ravi inbox sms --unread --json 2>/dev/null \
    | jq -r '.[0].preview // empty' \
    | grep -oE '[0-9]{6}' | head -1)
  [ -n "$CODE" ] && break
  sleep 2
done

if [ -z "$CODE" ]; then
  echo "Error: no SMS OTP received after 60s" >&2
  exit 1
fi

signal-cli -a "$PHONE" verify "$CODE"
echo "Signal registered: $PHONE"

Python equivalent:

import subprocess, json, re, time

def poll_sms_otp(timeout_s: int = 60, interval_s: int = 2) -> str:
    """Poll Ravi SMS inbox until a 6-digit code arrives. Raises on timeout."""
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        msgs = json.loads(
            subprocess.check_output(["ravi", "inbox", "sms", "--unread", "--json"])
        )
        for msg in msgs:
            match = re.search(r'\b(\d{6})\b', msg.get("preview", ""))
            if match:
                return match.group(1)
        time.sleep(interval_s)
    raise TimeoutError(f"No SMS OTP received in {timeout_s}s")

phone = json.loads(
    subprocess.check_output(["ravi", "get", "phone", "--json"])
)["phone_number"]

subprocess.run(["signal-cli", "-a", phone, "register"], check=True)
code = poll_sms_otp()
subprocess.run(["signal-cli", "-a", phone, "verify", code], check=True)
print(f"Signal registered: {phone}")

Headless / Docker Deployment

If running LettaBot in a container or CI environment where ravi auth login (browser device-code flow) is not available, inject the access token directly:

# Dockerfile — inject Ravi token at runtime
ENV RAVI_ACCESS_TOKEN=""
# The CLI reads this env var and skips file-based auth
CMD ["python", "-m", "lettabot"]
# Launch with token from your host's auth.json
RAVI_TOKEN=$(jq -r '.access_token' ~/.ravi/auth.json)
docker run -e RAVI_ACCESS_TOKEN="$RAVI_TOKEN" lettabot

Or call the Ravi REST API directly without the CLI:

import httpx, os

BASE = "https://ravi.app/api"
HEADERS = {
    "Authorization": f"Bearer {os.environ['RAVI_ACCESS_TOKEN']}",
    "X-Ravi-Identity": os.environ["RAVI_IDENTITY_UUID"],
}

def get_secret(key: str) -> str:
    # Step 1: list secrets to find UUID by key name
    secrets = httpx.get(f"{BASE}/secrets/", headers=HEADERS).json()
    match = next((s for s in secrets if s["key"] == key), None)
    if not match:
        raise KeyError(f"Secret not found: {key}")
    # Step 2: fetch decrypted value
    return httpx.get(f"{BASE}/secrets/{match['uuid']}/", headers=HEADERS).json()["value"]

def get_phone() -> str:
    return httpx.get(f"{BASE}/identity/phone/", headers=HEADERS).json()["phone_number"]

def poll_sms_inbox(unread: bool = True) -> list[dict]:
    params = {"unread": "true"} if unread else {}
    return httpx.get(f"{BASE}/sms/inbox/", headers=HEADERS, params=params).json()

Persistent Identity Across Restarts

LettaBot’s core value is persistent memory — it remembers context across sessions. Ravi complements this: the same email address and phone number persist across container restarts, redeployments, and version updates. Signal contacts see consistent identity. Telegram users reply to the same address. Service registrations remain valid.

To avoid reprovisioning on every restart, name the identity predictably and check whether it already exists:

import subprocess, json

def get_or_create_identity(name: str) -> dict:
    """Return existing identity by name, or create it."""
    existing = json.loads(
        subprocess.check_output(["ravi", "identity", "list", "--json"])
    )
    for identity in existing:
        if identity["name"] == name:
            return identity
    return json.loads(
        subprocess.check_output(
            ["ravi", "identity", "create", "--name", name, "--json"]
        )
    )

identity = get_or_create_identity("lettabot")
subprocess.run(["ravi", "identity", "use", identity["uuid"]], check=True)

Next Steps