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 (already live)The web app calls itProduction is https://amy.heyamy.xyz — use it directly

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   # Core 3 (v6+); needs Next.js 15.2.3+

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, Show, SignInButton } from '@clerk/nextjs';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <Show when="signed-out">
            <SignInButton mode="modal" />
          </Show>
          <Show when="signed-in">{children}</Show>
        </body>
      </html>
    </ClerkProvider>
  );
}

<Show when="…"> (Clerk Core 3, March 2026) consolidates the older <SignedIn>, <SignedOut>, and <Protect> components. If you have existing code on the old API, npx @clerk/upgrade migrates it.

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. The POST returns 202 { id, status: "queued", stream_url } — the turn runs in the background; the next step subscribes to its events.

Step 4, Proxy the SSE stream

Amy's events come over SSE, and the browser's native EventSource cannot set an Authorization header — and Amy has no query-string API key, auth is Bearer-only. So you cannot point EventSource at stream_url on amy.heyamy.xyz directly; it would send no token and get a 401. The fix mirrors Step 3: proxy the stream through your own origin, where the Clerk JWT can ride along as a real header.

Add a streaming proxy route at app/api/stream/[id]/route.ts:

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

export const dynamic = 'force-dynamic'; // never cache an SSE stream

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { getToken } = await auth();
  const token = await getToken();

  const upstream = await fetch(
    `${process.env.NEXT_PUBLIC_AMY_BASE_URL}/v1/turns/${id}/events`,
    {
      headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
    },
  );

  // Pipe the upstream SSE body straight back to the browser.
  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
    },
  });
}

Now the browser subscribes to the same-origin proxy, not the API. 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 { id } = await startRes.json(); // 202 { id, status: "queued", stream_url }

    // EventSource hits OUR origin; the proxy adds the bearer for us.
    const events = new EventSource(`/api/stream/${id}`);
    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

  • 401 / CORS errors on the SSE stream. EventSource can't send an Authorization header and Amy has no query-string key, so you can't subscribe to stream_url on amy.heyamy.xyz directly. The same-origin SSE proxy in step 4 is what fixes this, keep the auth on the server. (Alternatively, drive the stream with the SDK's amy.turns.stream() from a context that can set headers — e.g. a Web Worker — but the proxy is simpler for a Next.js app.)
  • 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