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
| Thing | Why | How to get it |
|---|---|---|
| Node 22+ | Build runtime | https://nodejs.org |
| A Clerk publishable key | Sign-in | The same one already in your .env for the backend |
| The Amy backend running | The web app calls it | See 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/nextjsThat'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.xyzThat'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 devOpen 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-webOr Vercel:
npx vercelEither 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
- Streaming, every event type, with shapes
- SDK · TypeScript, same API as a typed wrapper
- Concepts · Turns, what's actually happening inside a turn
Build a mobile app with Claude Code
For non-developers. This recipe assumes you have no coding experience and are using Claude Code (the terminal AI agent) to drive the work. You don't need to understand any of the code Claude generate…
Add a new wearable adapter
Goal: make Amy understand a wearable it doesn't already speak to. Two paths: either Terra already supports it (config-only) or it doesn't (a small custom adapter conforming to Amy's normalizer contra…