Amy
Recipes

Build a web app

A working web client for Amy. Next.js or Vite, the same API as the CLI, streaming SSE wired in so the user watches Amy think in real time.

Time: ~45 minutes. Cost: $0 — Vercel or Cloudflare Pages free tiers cover it.

You'll end up with a single-page web app where a user signs in with Clerk, asks Amy a question, and watches the answer stream in. Same backend the CLI uses. No new endpoints, no new schemas — just a different shell around them.

What you need

ThingWhyHow to get it
Node 22+Build runtimehttps://nodejs.org
A Clerk publishable keySign-inThe same one already in your .env for the backend
The Amy backend runningThe web app calls itSee Deploying — or hit the hosted amy.heyamy.xyz while you build locally

If you've never touched Next.js or React, that's fine. Claude Code can scaffold the whole thing — see Build a mobile app for that same workflow, applied to React Native.

Step 1 — Scaffold a Next.js app

npx create-next-app@latest amy-web --typescript --tailwind --app
cd amy-web
npm install @clerk/nextjs

That's the whole framework. Next.js has its own opinions about routing and rendering; for Amy you only need the streaming part to work — everything else is plain React.

Step 2 — Drop in Clerk

In app/layout.tsx:

import { ClerkProvider, SignedIn, SignedOut, SignInButton } from '@clerk/nextjs';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <SignedOut>
            <SignInButton mode="modal" />
          </SignedOut>
          <SignedIn>{children}</SignedIn>
        </body>
      </html>
    </ClerkProvider>
  );
}

Add your keys to .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_AMY_BASE_URL=https://amy.heyamy.xyz

That's auth done. Same Clerk app as the backend — the JWT the browser holds is the same one the API accepts.

Step 3 — Ask Amy a question

Make a route that POSTs to /v1/turns and returns the id + stream_url. In app/api/ask/route.ts:

import { auth } from '@clerk/nextjs/server';

export async function POST(req: Request) {
  const { getToken } = await auth();
  const token = await getToken();
  const body = await req.json();

  const res = await fetch(`${process.env.NEXT_PUBLIC_AMY_BASE_URL}/v1/turns`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  return res;
}

We proxy through /api/ask so the Clerk JWT stays server-side. The browser never sees the token — it just calls our own origin.

Step 4 — Subscribe to the stream

Amy's events come over SSE. In app/page.tsx:

'use client';
import { useState } from 'react';

export default function Page() {
  const [answer, setAnswer] = useState('');
  const [thinking, setThinking] = useState(false);

  async function ask(question: string) {
    setAnswer('');
    setThinking(true);

    const startRes = await fetch('/api/ask', {
      method: 'POST',
      body: JSON.stringify({ messages: [{ role: 'user', content: question }] }),
    });
    const { stream_url } = await startRes.json();

    const events = new EventSource(stream_url);
    events.addEventListener('synthesis_delta', (e) => {
      const data = JSON.parse(e.data);
      setAnswer((prev) => prev + data.text);
    });
    events.addEventListener('turn.completed', () => {
      events.close();
      setThinking(false);
    });
    events.addEventListener('turn.failed', () => {
      events.close();
      setThinking(false);
    });
  }

  return (
    <main className="max-w-2xl mx-auto p-8">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const q = new FormData(e.currentTarget).get('q') as string;
          ask(q);
        }}
      >
        <input name="q" placeholder="Ask Amy…" className="w-full border p-3 rounded" />
      </form>
      {thinking && <p className="mt-4 text-gray-500">Thinking…</p>}
      {answer && <article className="mt-6 whitespace-pre-wrap">{answer}</article>}
    </main>
  );
}

That's it for the minimum-viable shell. synthesis_delta is the event type the orchestrator emits while it streams the final answer; the rest are progress events you can render if you want a "watch Amy think" panel. See Streaming for the full event list.

Step 5 — Run it

npm run dev

Open http://localhost:3000. Sign in with Clerk. Ask "What's my average HRV?" Watch the answer arrive token by token.

If you don't have wearable data yet, the answer will be honest about it — Amy never fabricates. Connect a device through the CLI first (see Connect a wearable) and try again.

Step 6 — Deploy

The shortest path is Cloudflare Pages:

npm run build
npx wrangler pages deploy out --project-name amy-web

Or Vercel:

npx vercel

Either works. Both give you a custom domain with one click.

Showing every step Amy takes

The single-input chat is the simplest shape. If you want a trace panel that shows every agent boundary, every Python sandbox call, every validation gate — subscribe to all event types, not just synthesis_delta. Here's the pattern:

events.addEventListener('phase', (e) => addTraceLine(JSON.parse(e.data)));
events.addEventListener('agent_start', (e) => addTraceLine(JSON.parse(e.data)));
events.addEventListener('agent_end', (e) => addTraceLine(JSON.parse(e.data)));
events.addEventListener('validation_start', (e) => addTraceLine(JSON.parse(e.data)));
events.addEventListener('validation_end', (e) => addTraceLine(JSON.parse(e.data)));

Streaming lists every event with the shape it carries.

Common mistakes

  • CORS errors on the SSE stream. EventSource doesn't send custom headers, so the JWT can't ride along directly. The proxy in step 3 is what fixes this — keep the auth on the server.
  • Stream disconnects after 30s. Some hosts kill idle long-poll connections. Cloudflare Workers handles SSE cleanly; on Vercel you may need export const dynamic = 'force-dynamic' on the route.
  • "messages contains no user turn". You sent an empty messages array, or the last role is assistant. Amy's POST body is { messages: [{ role: 'user', content: '...' }] }.

What's next

On this page