Skip to content

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); the API accepts Authorization: Bearer <jwt>, and Authorization: ApiKey <token> for an API key.

The TypeScript SDK at client/packages/sdk/ on the public mirror (package name @echolabs/multiverse-echoes) is being rebuilt. It does not match the current API, so do not use it. This tutorial uses the API directly.

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
# No SDK install — we use the platform Fetch and WebSocket APIs directly

Do not vendor the SDK's client/packages/sdk/src/types.ts for typed responses. It is being rebuilt, and many of its types differ from what the server returns. Write the types your client needs from the API Reference and the WebSocket Events page.

Step 2: Create the Data Layer

// src/types.ts — written from the API Reference and the WebSocket Events page
export type EchoResponse = { /* see the API Reference */ };
export type FeedItem = { /* see the API Reference */ };
export type WsEchoEvent = { /* see the WebSocket Events page */ };
// 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.

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