Building a Custom Client¶
This tutorial shows how to build a custom dashboard that displays your Echoes' activity in real-time using direct fetch calls. The API mounts at the root of api.echolabsme.com (no /api/v1/ URL prefix); 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.
Architecture¶
A custom client typically has three layers:
- API client — fetches data from the REST API
- WebSocket listener — receives real-time updates
- UI layer — renders the data (React, Vue, Svelte, or plain HTML)
Step 1: Set Up the Project¶
mkdir my-echo-dashboard && cd my-echo-dashboard
npm init -y
# No SDK install — we use the platform Fetch and WebSocket APIs directly
If you want strongly-typed responses, vendor client/packages/sdk/src/types.ts from the public mirror:
git clone https://github.com/echolabs-me/multiverse-echoes-client.git
cp multiverse-echoes-client/packages/sdk/src/types.ts src/types.ts
Step 2: Create the Data Layer¶
// src/types.ts — vendored from client/packages/sdk/src/types.ts on the public mirror
export type EchoResponse = { /* see client/packages/sdk/src/types.ts */ };
export type FeedItem = { /* see client/packages/sdk/src/types.ts */ };
export type WsEchoEvent = { /* see client/packages/sdk/src/types.ts */ };
// src/data.ts
import type { EchoResponse, FeedItem, WsEchoEvent } from './types';
const API_URL = 'https://api.echolabsme.com';
export async function initDashboard(email: string, password: string) {
// Authenticate
const session = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
}).then((r) => r.json());
const auth = { Authorization: `Bearer ${session.access_token}` };
// GET /echoes is a bare array (NOT paginated)
const echoes: EchoResponse[] = await fetch(`${API_URL}/echoes`, { headers: auth }).then((r) =>
r.json(),
);
// GET /feeds/personal returns { data, next_cursor }
const feedResponse = await fetch(`${API_URL}/feeds/personal`, { headers: auth }).then((r) =>
r.json(),
);
const feed: FeedItem[] = feedResponse.data;
// Group feed by echo
const feedByEcho = new Map<string, FeedItem[]>();
for (const item of feed) {
const existing = feedByEcho.get(item.echo_id) ?? [];
existing.push(item);
feedByEcho.set(item.echo_id, existing);
}
// Subscribe to real-time updates — one socket per Echo
const subscriptions = echoes.map((echo) => {
const ws = new WebSocket(
`wss://api.echolabsme.com/ws/echoes/${echo.echo_id}/stream?token=${session.access_token}`,
);
ws.addEventListener('message', (frame) => {
handleEvent(echo, JSON.parse(frame.data) as WsEchoEvent);
});
return ws;
});
return { session, echoes, feedByEcho, subscriptions };
}
function handleEvent(echo: EchoResponse, event: WsEchoEvent) {
// First frame is the handshake — ignore
if (event.type === 'ConnectionEstablished') return;
// Subsequent frames are tagged-union with top-level `type` discriminator
switch (event.type) {
case 'DiaryEntryCreated':
console.log(`[${echo.name}] new diary entry ${event.diary_id}`);
break;
case 'MoodChanged':
console.log(`[${echo.name}] mood: ${event.mood}`);
break;
case 'LifeEventOccurred':
console.log(`[${echo.name}] life event ${event.event_id}`);
break;
case 'EchoAvatarReady':
console.log(`[${echo.name}] avatar at ${event.avatar_url}`);
break;
}
}
Step 3: Build the UI¶
Plain HTML Example¶
<!DOCTYPE html>
<html>
<head>
<title>My Echo Dashboard</title>
<style>
body { font-family: system-ui; background: #0f1923; color: #e8e0d8; padding: 2rem; }
.echo-card { background: #1a2633; border: 1px solid #2e4052; border-radius: 12px; padding: 1.5rem; margin: 1rem 0; }
.echo-name { color: #d4915c; font-size: 1.25rem; font-weight: 600; }
.mood { color: #9ba8b4; font-size: 0.875rem; }
.diary { color: #e8e0d8; margin-top: 0.5rem; font-style: italic; }
</style>
</head>
<body>
<h1>My Echoes</h1>
<div id="echoes"></div>
<script type="module" src="./dashboard.js"></script>
</body>
</html>
React Example¶
import { useEffect, useState } from 'react';
import type { EchoResponse, FeedItem } from './types';
const API_URL = 'https://api.echolabsme.com';
function EchoDashboard() {
const [echoes, setEchoes] = useState<EchoResponse[]>([]);
const [feed, setFeed] = useState<FeedItem[]>([]);
useEffect(() => {
const accessToken = localStorage.getItem('access_token');
if (!accessToken) return;
const auth = { Authorization: `Bearer ${accessToken}` };
// Bare array
fetch(`${API_URL}/echoes`, { headers: auth })
.then((r) => r.json())
.then(setEchoes);
// Paginated envelope
fetch(`${API_URL}/feeds/personal`, { headers: auth })
.then((r) => r.json())
.then((body) => setFeed(body.data));
}, []);
return (
<div>
<h1>My Echoes</h1>
{echoes.map((echo) => (
<div key={echo.echo_id} style={{ padding: '1rem', margin: '0.5rem 0' }}>
<h2>{echo.name}</h2>
<p>Mood: {echo.current_mood}</p>
<p>Tick: {echo.current_tick}</p>
{feed
.filter((f) => f.echo_id === echo.echo_id)
.slice(0, 3)
.map((item) => (
<p key={item.item_id}>{item.body}</p>
))}
</div>
))}
</div>
);
}
Step 4: Handle Story Exports¶
Story exports turn one or more Echoes into a downloadable artifact (PDF, video, etc.). The request takes echo_ids as a plural array; the response is a job-status envelope.
const auth = { Authorization: `Bearer ${accessToken}` };
// Request a story export — note `echo_ids` (plural array), not `echo_id`
const exp = await fetch(`${API_URL}/account/me/story-export`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
echo_ids: [echo.echo_id],
format: 'pdf',
// optional: from_date, to_date in ISO date format
}),
}).then((r) => r.json());
// Response: { export_id, format, status, created_at, download_path?, subtitle_path? }
// `status` is a string ("Processing", "Complete", "Failed").
// `download_path` is a path to the rendered artifact, populated when status === "Complete".
// Poll for completion
const poll = setInterval(async () => {
const status = await fetch(`${API_URL}/account/me/story-export/${exp.export_id}`, {
headers: auth,
}).then((r) => r.json());
if (status.status === 'Complete') {
clearInterval(poll);
// Download via the dedicated download endpoint
const artifactResponse = await fetch(
`${API_URL}/account/me/story-export/${exp.export_id}/download`,
{ headers: auth },
);
// artifactResponse.body is the rendered file stream
} else if (status.status === 'Failed') {
clearInterval(poll);
console.error('Export failed');
}
}, 2000);
The full GDPR right-of-access export (all your data, not just Echo-specific) lives at a different path: POST /account/me/export (no body) returns the entire DataExportResponse inline.
Step 5: Add Search¶
All search endpoints return paginated { data: SearchResult[], next_cursor }. Pass q as the query string.
const params = new URLSearchParams({
q: 'discovery',
echo_id: echo.echo_id,
date_from: '2026-01-01',
});
const results = await fetch(`${API_URL}/search/diary?${params}`, { headers: auth }).then((r) =>
r.json(),
);
for (const result of results.data) {
console.log(`${result.title}: ${result.snippet}`);
}
Clean Up¶
Always close WebSocket connections when the user navigates away:
for (const sub of subscriptions) {
sub.close();
}
Next Steps¶
- Modding Guide — extend the client with custom themes and plugins
- WebSocket Events — all event types
- SDK Reference — full endpoint table + SDK source location