Skip to content

Getting Started with the API

This tutorial walks you through making your first API calls to the Multiverse Echoes engine using direct fetch. The API mounts at the root of api.echolabsme.com — there is no /api/v1/ URL prefix; canonical paths are /auth/login, /echoes, /feeds/personal, etc. JWT is the only authentication path the API surface accepts today.

A typed TypeScript SDK is available at client/packages/sdk/ on the public mirror (package name @echolabs/multiverse-echoes). The package is not yet on npm; clone the public repo to vendor the source. The direct-fetch examples below cover the same surface.

Prerequisites

  • A Multiverse Echoes account (sign up at echolabsme.com)
  • Node.js 20+ or any JavaScript runtime with Fetch API support — no SDK install needed; everything below uses raw fetch

Step 1: Configure the Base URL

const baseUrl = 'https://api.echolabsme.com';

Step 2: Authenticate

const session = await fetch(`${baseUrl}/auth/login`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: '[email protected]', password: 'YOUR_PASSWORD' }),
}).then((r) => r.json());

console.log('Logged in! Token expires in', session.expires_in, 'seconds');
const accessToken = session.access_token;

The login response shape is { access_token, refresh_token, expires_in }. Persist refresh_token securely (HTTP-only cookie in browser contexts; OS keychain in Tauri/desktop) — you'll need it to refresh the short-lived access_token.

Step 3: List Your Echoes

GET /echoes returns a bare array of EchoResponse (no pagination wrapper) because the per-user Echo count is bounded by tier limits.

const echoes = await fetch(`${baseUrl}/echoes`, {
  headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());

for (const echo of echoes) {
  console.log(`${echo.name}${echo.current_mood} (Tick ${echo.current_tick})`);
}

Step 4: Create an Echo

Echo creation is the only mutation point in the Echo lifecycle — name, persona_text, what_if_prompt, and physical_description are immutable post-creation (Inviolable Rule #11).

const echo = await fetch(`${baseUrl}/echoes`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${accessToken}`,
  },
  body: JSON.stringify({
    name: 'Luna',
    persona_text: 'A 28-year-old marine biologist fascinated by deep-sea creatures.',
    what_if_prompt: 'moved to Iceland instead of staying in California',
    consent_declaration: true,
  }),
}).then((r) => r.json());

console.log(`Echo created: ${echo.name} (${echo.echo_id})`);
console.log(`Birth hash: ${echo.birth_hash}`);

What-if prompts are immutable

Once created, the what_if_prompt cannot be changed. This is by design — each Echo's origin story is permanent. There are no PATCH /echoes/{id}/... endpoints; persona evolution happens via POST /echoes/{id}/influence (suggestions the Echo may or may not act on).

Step 5: Read the Feed

GET /feeds/personal returns a paginated envelope { data: FeedItemResponse[], next_cursor: string | null }. Pass next_cursor as the cursor query param to fetch the next page; omit for the first page. Optional echo_id filter restricts to a single Echo.

const feedResponse = await fetch(
  `${baseUrl}/feeds/personal?echo_id=${echo.echo_id}`,
  { headers: { Authorization: `Bearer ${accessToken}` } },
).then((r) => r.json());

for (const item of feedResponse.data) {
  console.log(`[${item.item_type}] ${item.title}`);
  console.log(`  ${item.body}`);
}

// Fetch next page if available
if (feedResponse.next_cursor) {
  const nextPage = await fetch(
    `${baseUrl}/feeds/personal?echo_id=${echo.echo_id}&cursor=${feedResponse.next_cursor}`,
    { headers: { Authorization: `Bearer ${accessToken}` } },
  ).then((r) => r.json());
}

Step 6: Subscribe to Real-Time Events

const ws = new WebSocket(
  `wss://api.echolabsme.com/ws/echoes/${echo.echo_id}/stream?token=${accessToken}`,
);

ws.addEventListener('message', (frame) => {
  const event = JSON.parse(frame.data);

  // First frame is the handshake — distinct shape from WsEchoEvent variants
  if (event.type === 'ConnectionEstablished') {
    console.log(`Subscribed; last tick at ${event.last_tick_at}`);
    return;
  }

  // Subsequent frames are WsEchoEvent — top-level `type` discriminator (no payload wrapper)
  console.log(`Event: ${event.type}`);
});

// Clean up when done
process.on('SIGINT', () => {
  ws.close();
  process.exit();
});

Subscription is implicit from the URL path — the server does not parse client-sent action frames. JWT auth rides on the ?token=<jwt> query parameter because browsers cannot set custom headers on the WebSocket handshake. See WebSocket Events for the full frame catalogue.

Error Handling

The API returns one of two distinct JSON shapes. Branch on typeof body.error.

Shape 1 — ErrorEnvelope (canonical for ApiError::* responses — auth failures, not-found, rate limit, etc.):

{
  "error": {
    "code": "WHAT_IF_LOCKED",
    "message": "...",
    "status": 400,
    "request_id": "0192a1b3-2c4d-4e5f-9a0b-1c2d3e4f5a6b",
    "retry_after_seconds": 60
  }
}

Shape 2 — VALIDATION_ERROR (only when a request body fails validator constraints):

{
  "error": "VALIDATION_ERROR",
  "fields": {
    "persona_text": ["too long"]
  }
}
const response = await fetch(`${baseUrl}/echoes`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
  body: JSON.stringify(invalidData),
});

if (!response.ok) {
  const body = await response.json();
  if (typeof body.error === 'string') {
    // VALIDATION_ERROR — body.fields is { [field]: string[] }
    for (const [field, messages] of Object.entries(body.fields)) {
      console.error(`${field}: ${messages.join('; ')}`);
    }
  } else {
    // ErrorEnvelope — body.error is { code, message, status, request_id, retry_after_seconds? }
    console.error(`API Error [${body.error.code}] (request ${body.error.request_id}): ${body.error.message}`);
  }
}

Next Steps