Skip to content

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:

  1. API client — fetches data from the REST API
  2. WebSocket listener — receives real-time updates
  3. 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);
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