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: 'you@example.com', 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¶
- Building a Custom Client — deeper integration patterns
- WebSocket Events Reference — all real-time event types
- SDK Documentation — full endpoint table + SDK source location
- API Reference — REST endpoint details (Redoc-rendered OpenAPI)