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