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 (already live) | The web app calls it | Production 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/upgrademigrates 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.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. 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 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
- 401 / CORS errors on the SSE stream.
EventSourcecan't send anAuthorizationheader and Amy has no query-string key, so you can't subscribe tostream_urlonamy.heyamy.xyzdirectly. 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'samy.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
- 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
A production-quality iOS-first React Native app for the Amy backend — Expo SDK 56, Expo Router v6 native tabs, Clerk Core 3 (@clerk/expo), NativeWind v4, Reanimated 3, TanStack Query v5, @amy/sdk as a workspace import. Words-first, journal-like — not a dashboard.
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…