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.
What to read next
- Production Patterns — Python patterns and CLI subprocess approach
- Harness Integration — per-workspace identity injection for orchestrators
- API Endpoints — full endpoint reference
- API Overview — auth model, headers, error codes