TypeScript REST API Integration

TypeScript REST API Integration

The CLI subprocess patterns are the fastest path to a working integration. But agents running in containerised environments, serverless functions, or TypeScript-first runtimes often can’t shell out to a binary — they need direct HTTP calls.

This guide shows how to use Ravi’s REST API from TypeScript with fetch, covering the full lifecycle: token management, identity operations, inbox polling, real-time SSE streaming, and vault secrets retrieval.

All examples use the base URL https://ravi.app and the endpoints documented in API Endpoints.


Token loading

The Ravi CLI stores auth tokens in ~/.ravi/auth.json. For REST calls you need the access_token from that file — or you can inject it via an environment variable.

import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";

interface AuthFile {
  access_token: string;
  refresh_token: string;
  expires_at: string; // ISO 8601
}

function loadToken(): string {
  // Prefer environment variable (for containers / CI injection)
  if (process.env.RAVI_ACCESS_TOKEN) {
    return process.env.RAVI_ACCESS_TOKEN;
  }

  const authPath = path.join(homedir(), ".ravi", "auth.json");
  const auth: AuthFile = JSON.parse(readFileSync(authPath, "utf8"));
  return auth.access_token;
}

For containers and CI: run ravi auth login once on a machine with a browser, copy ~/.ravi/auth.json to the target environment as a secret, and set RAVI_ACCESS_TOKEN from it at runtime. See Authentication for the full headless setup.


Ravi client class

A thin client keeps auth headers and identity scoping in one place:

const BASE_URL = "https://ravi.app/api";

class RaviClient {
  private accessToken: string;
  private identityUuid?: string;

  constructor(accessToken: string, identityUuid?: string) {
    this.accessToken = accessToken;
    this.identityUuid = identityUuid;
  }

  private headers(extra?: Record<string, string>): Record<string, string> {
    const h: Record<string, string> = {
      Authorization: `Bearer ${this.accessToken}`,
      "Content-Type": "application/json",
    };
    if (this.identityUuid) {
      h["X-Ravi-Identity"] = this.identityUuid;
    }
    return { ...h, ...extra };
  }

  withIdentity(uuid: string): RaviClient {
    return new RaviClient(this.accessToken, uuid);
  }

  async get<T>(path: string): Promise<T> {
    const res = await fetch(`${BASE_URL}${path}`, { headers: this.headers() });
    if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
    return res.json() as Promise<T>;
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const res = await fetch(`${BASE_URL}${path}`, {
      method: "POST",
      headers: this.headers(),
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`POST ${path} → ${res.status}`);
    return res.json() as Promise<T>;
  }
}

Identity management

interface Identity {
  uuid: string;
  name: string;
  inbox: string;
  phone: string;
  created_dt: string;
}

async function createIdentity(client: RaviClient, name: string): Promise<Identity> {
  return client.post<Identity>("/identities/", { name });
}

async function listIdentities(client: RaviClient): Promise<Identity[]> {
  return client.get<Identity[]>("/identities/");
}

async function getIdentity(client: RaviClient, uuid: string): Promise<Identity> {
  return client.get<Identity>(`/identities/${uuid}/`);
}

// Usage
const token = loadToken();
const ravi = new RaviClient(token);

const identity = await createIdentity(ravi, `worker-${taskId}`);
console.log(`Agent email: ${identity.inbox}`);
console.log(`Agent phone: ${identity.phone}`);

// Scope all subsequent calls to this identity
const agentClient = ravi.withIdentity(identity.uuid);

Polling the email inbox

interface EmailThread {
  thread_id: string;
  subject: string;
  preview: string;
  from_email: string;
  message_count: number;
  unread_count: number;
  latest_message_dt: string;
}

async function pollForEmail(
  client: RaviClient,
  predicate: (thread: EmailThread) => boolean,
  maxAttempts = 20,
  delayMs = 3000
): Promise<EmailThread | null> {
  for (let i = 0; i < maxAttempts; i++) {
    const threads = await client.get<EmailThread[]>("/email-inbox/?unread=true");
    const match = threads.find(predicate);
    if (match) return match;
    await new Promise((r) => setTimeout(r, delayMs));
  }
  return null;
}

// Wait for a verification email
const verificationEmail = await pollForEmail(
  agentClient,
  (t) => t.subject.toLowerCase().includes("verify") || t.subject.toLowerCase().includes("confirm"),
  20,
  3000
);

if (!verificationEmail) {
  throw new Error("Verification email not received within 60 seconds");
}

// Extract a link from the thread
const thread = await agentClient.get<{ messages: Array<{ text_content: string }> }>(
  `/email-inbox/${verificationEmail.thread_id}/`
);
const body = thread.messages[0].text_content;
const link = body.match(/https?:\/\/\S+/)?.[0];

Polling the SMS inbox

interface SmsConversation {
  conversation_id: string;
  from_number: string;
  preview: string;
  message_count: number;
  unread_count: number;
}

async function pollForOtp(
  client: RaviClient,
  maxAttempts = 30,
  delayMs = 2000
): Promise<string | null> {
  for (let i = 0; i < maxAttempts; i++) {
    const conversations = await client.get<SmsConversation[]>("/sms-inbox/?unread=true");
    for (const conv of conversations) {
      const match = conv.preview.match(/\b(\d{4,8})\b/);
      if (match) return match[1];
    }
    await new Promise((r) => setTimeout(r, delayMs));
  }
  return null; // timeout after ~60s
}

const otp = await pollForOtp(agentClient);
if (!otp) throw new Error("OTP not received");

Real-time SSE streaming

For agents that need to react to incoming messages immediately — rather than polling — use the Server-Sent Events stream. The server pushes events for new email and SMS, and sends keepalives every 30 seconds.

import { EventSource } from "eventsource"; // npm install eventsource

interface SseEmailEvent {
  type: "email";
  thread_id: string;
  from_email: string;
  subject: string;
  preview: string;
}

interface SseSmsEvent {
  type: "sms";
  conversation_id: string;
  from_number: string;
  preview: string;
}

type SseEvent = SseEmailEvent | SseSmsEvent;

function openEventStream(
  token: string,
  identityUuid: string,
  onEvent: (event: SseEvent) => void,
  lastEventId?: string
): EventSource {
  const url = new URL(`${BASE_URL}/events/stream/`);
  const es = new EventSource(url.toString(), {
    headers: {
      Authorization: `Bearer ${token}`,
      "X-Ravi-Identity": identityUuid,
      ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}),
    },
  });

  es.addEventListener("email", (e) => {
    onEvent({ type: "email", ...JSON.parse(e.data) } as SseEmailEvent);
  });

  es.addEventListener("sms", (e) => {
    onEvent({ type: "sms", ...JSON.parse(e.data) } as SseSmsEvent);
  });

  es.onerror = (err) => {
    console.error("SSE error — will reconnect automatically", err);
  };

  return es;
}

// Usage
const stream = openEventStream(token, identity.uuid, (event) => {
  if (event.type === "sms") {
    console.log(`SMS from ${event.from_number}: ${event.preview}`);
    const otp = event.preview.match(/\b(\d{4,8})\b/)?.[1];
    if (otp) handleOtp(otp);
  }
  if (event.type === "email") {
    console.log(`Email: ${event.subject}`);
  }
});

// Close when done
stream.close();

SSE vs. polling: Use SSE for interactive agents that need sub-second reaction times. Use polling for batch scripts and CI flows — it’s simpler to implement and sufficient for most verification workflows.


Vault secrets retrieval

Vault secrets are stored by key name but the API returns them by UUID. To retrieve a secret by key:

interface VaultEntry {
  uuid: string;
  key: string;
  value: string; // "e2e::<base64>" — decrypted by CLI only
  notes?: string;
}

async function getSecretByKey(
  client: RaviClient,
  key: string
): Promise<VaultEntry | null> {
  const entries = await client.get<VaultEntry[]>("/vault/");
  return entries.find((e) => e.key === key) ?? null;
}

async function getSecretValue(
  client: RaviClient,
  uuid: string
): Promise<VaultEntry> {
  return client.get<VaultEntry>(`/vault/${uuid}/`);
}

// Usage
const entry = await getSecretByKey(agentClient, "GITHUB_TOKEN");
if (!entry) throw new Error("GITHUB_TOKEN not found in vault");
const full = await getSecretValue(agentClient, entry.uuid);
// full.value is the E2E-encrypted ciphertext — decrypt with the CLI or client-side key derivation

E2E encryption note: Vault secret values are returned as "e2e::<base64>" ciphertext. The Ravi CLI decrypts these using your PIN-derived key. If you need plaintext at runtime in a container, store the decrypted value as an environment variable or use the CLI to inject it at startup — the REST API alone cannot decrypt without your PIN.


Token refresh

Tokens expire. Long-running agents should check expiry and refresh proactively:

import { readFileSync, writeFileSync } from "fs";
import path from "path";
import { homedir } from "os";

async function refreshToken(refreshToken: string): Promise<AuthFile> {
  const res = await fetch(`${BASE_URL}/auth/token/refresh/`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh: refreshToken }),
  });
  if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
  return res.json() as Promise<AuthFile>;
}

async function ensureFreshToken(): Promise<string> {
  const authPath = path.join(homedir(), ".ravi", "auth.json");
  const auth: AuthFile = JSON.parse(readFileSync(authPath, "utf8"));

  const expiresAt = new Date(auth.expires_at).getTime();
  const thirtyMinutes = 30 * 60 * 1000;

  if (Date.now() + thirtyMinutes > expiresAt) {
    const refreshed = await refreshToken(auth.refresh_token);
    writeFileSync(authPath, JSON.stringify({ ...auth, ...refreshed }));
    return refreshed.access_token;
  }

  return auth.access_token;
}

// In a long-running event loop
while (true) {
  const token = await ensureFreshToken();
  const ravi = new RaviClient(token, identityUuid);
  // ... agent work ...
  await new Promise((r) => setTimeout(r, 5 * 60 * 1000)); // check every 5 min
}

End-to-end example: parallel workers

Putting it together — five parallel agents, each with an isolated identity, using REST for all Ravi operations:

import { promisePool } from "./utils"; // your concurrency limiter

async function runAgentWorker(taskId: string): Promise<void> {
  const token = await ensureFreshToken();
  const ravi = new RaviClient(token);

  // Provision a per-task identity
  const identity = await createIdentity(ravi, `worker-${taskId}`);
  const agentClient = ravi.withIdentity(identity.uuid);

  try {
    // ... do the work that requires email/SMS ...
    const otp = await pollForOtp(agentClient);
    if (!otp) throw new Error(`Worker ${taskId}: OTP timeout`);
    // ... continue with the task ...
  } finally {
    // Clean up the ephemeral identity
    await fetch(`${BASE_URL}/identities/${identity.uuid}/`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${token}` },
    });
  }
}

// Run up to 5 workers concurrently
const taskIds = ["t1", "t2", "t3", "t4", "t5"];
await Promise.all(taskIds.map(runAgentWorker));

Each worker gets its own identity, inbox, and phone number. There is no shared state — no global ravi identity use call, no config file races.