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); 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:

  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

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.

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