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
jqavailable 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
- Production Patterns — RAVI_ACCESS_TOKEN injection, token refresh for long-running processes
- Credential Vault — storing and retrieving API keys
- Phone & SMS — inbox polling, OTP extraction
- MCP Server — expose Ravi tools to Claude Code and MCP-compatible runtimes