Building a Custom Client¶
This tutorial shows how to build a custom dashboard that displays your Echoes' activity in real-time.
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
npm install @echolabs/multiverse-echoes
Step 2: Create the Data Layer¶
// src/data.ts
import {
createClient,
subscribeToEcho,
type EchoResponse,
type FeedItem,
type WorldEvent,
} from '@echolabs/multiverse-echoes';
const API_URL = 'https://api.echolabsme.com';
const WS_URL = 'wss://api.echolabsme.com';
export async function initDashboard(email: string, password: string) {
const client = createClient({ baseUrl: API_URL });
const session = await client.login({ email, password });
// Fetch initial state
const echoes = await client.echoes.list();
const feed = await client.feeds.personal();
// 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
const subscriptions = echoes.map((echo) =>
subscribeToEcho(WS_URL, echo.echo_id, session.access_token, {
onEvent(event) {
handleEvent(echo, event);
},
}),
);
return { client, echoes, feedByEcho, subscriptions };
}
function handleEvent(echo: EchoResponse, event: WorldEvent) {
const { type } = event.payload;
switch (type) {
case 'DiaryEntryCreated':
console.log(`[${echo.name}] New diary entry`);
break;
case 'MoodChanged':
if ('mood' in event.payload) {
console.log(`[${echo.name}] Mood: ${event.payload.mood}`);
}
break;
case 'LifeEventOccurred':
console.log(`[${echo.name}] Life event!`);
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 {
createClient,
subscribeToEcho,
type EchoResponse,
type FeedItem,
} from '@echolabs/multiverse-echoes';
function EchoDashboard() {
const [echoes, setEchoes] = useState<EchoResponse[]>([]);
const [feed, setFeed] = useState<FeedItem[]>([]);
useEffect(() => {
const client = createClient({ baseUrl: 'https://api.echolabsme.com' });
// Restore tokens from storage
const access = localStorage.getItem('access_token');
const refresh = localStorage.getItem('refresh_token');
if (access && refresh) {
client.setTokens(access, refresh);
}
client.echoes.list().then(setEchoes);
client.feeds.personal().then(setFeed);
}, []);
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 Exports¶
// Request a PDF export
const exp = await client.exports.request({
echo_id: echo.echo_id,
format: 'pdf',
});
// Poll for completion
const poll = setInterval(async () => {
const status = await client.exports.status(exp.export_id);
if (status.status === 'Ready') {
clearInterval(poll);
console.log('Download:', status.download_url);
} else if (status.status === 'Failed') {
clearInterval(poll);
console.error('Export failed');
}
}, 2000);
Step 5: Add Search¶
const results = await client.search.diary({
q: 'discovery',
echo_id: echo.echo_id,
date_from: '2026-01-01',
});
for (const result of results) {
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 method list