Group chat with AI
May 21, 2026 · View on GitHub
A multi-user group chat where humans share a room with an AI participant. Built on the AG-UI protocol for event-based agent-to-UI streaming, Next.js, and Claude.
Open the app in 2–3 tabs with different ?name= query parameters and you have a live group chat — everyone sees everyone else's messages, and the AI joins in on every turn.
Run it
npm install
export ANTHROPIC_API_KEY=sk-ant-... # optional — without it, the AI replies with a stub
npm run dev
Then open:
Missing ?name defaults to User.
Architecture
One process holds a shared in-memory chat room. Every connected tab subscribes to the same SSE event stream. When anyone POSTs a message, the room broadcasts it to every subscriber and triggers an AI reply that streams back the same way.
Tab A (?name=Alice) Tab B (?name=Bob) Tab C (?name=Carol)
│ ▲ │ ▲ │ ▲
POST │ │ SSE POST │ │ SSE POST │ │ SSE
▼ │ ▼ │ ▼ │
┌───────────────────────────────────────────────────────────────────┐
│ Next.js route handlers │
│ │
│ POST /api/agui/send ─────► append UserMessage │
│ GET /api/agui/stream ◄──── subscribe to AG-UI event stream │
│ │
│ ┌──────────────────── shared room (in-memory) ──────────────┐ │
│ │ messages: Message[] subscribers: Map<id, send> │ │
│ └──────────────────────────────────────────────────────────-┘ │
│ │ │
│ ▼ │
│ triggerAssistant() ──► Anthropic (Claude) │
│ streams TEXT_MESSAGE_* │
└───────────────────────────────────────────────────────────────────┘
The event protocol
Server → client events are AG-UI protocol events, serialized as SSE frames by @ag-ui/encoder (data: {...json...}\n\n). Two kinds carry the chat:
| Event | When | What it carries |
|---|---|---|
MESSAGES_SNAPSHOT | On connect, and after every committed change | Full message list with id, role, name, content |
TEXT_MESSAGE_START / CONTENT / END | While the AI is replying | Per-token deltas for live streaming |
RUN_STARTED / RUN_FINISHED / RUN_ERROR | Bracketing each AI run | Lifecycle signals |
Snapshots are the source of truth (committed state, with name attached to each UserMessage). The streaming TEXT_MESSAGE_* events let the client render the AI's reply token-by-token before it's committed.
Client merge logic
The client keeps two pieces of state and unions them at render time:
- Confirmed messages — replaced wholesale on every
MESSAGES_SNAPSHOT. - In-flight streaming messages — a
Map<messageId, partial>updated byTEXT_MESSAGE_*events. Entries are dropped once the sameidappears in a snapshot.
This gives both correctness (history always matches the server) and snappy streaming (no waiting for snapshots to see the AI type).
Why this works for multi-tab
Because the room lives in a single process's memory and every tab subscribes to the same broadcast list, fan-out is trivial: posting a message just iterates subscribers and pushes the same event to each. No database, no pub/sub broker, no per-user threads. Trade-off: state is lost on process restart and doesn't scale beyond one Node instance — exactly the right shape for a local demo.
File map
| Path | What it does |
|---|---|
lib/agui-room.ts | Singleton room: messages, subscribers, broadcast helpers. |
lib/agui-ai.ts | Calls Claude (Haiku 4.5) and streams deltas into the room. Falls back to a canned stub if ANTHROPIC_API_KEY isn't set. |
app/api/agui/stream/route.ts | SSE endpoint. Subscribes the client and forwards every broadcast event. |
app/api/agui/send/route.ts | POST endpoint. Appends a UserMessage and triggers an AI reply. |
app/page.tsx | Chat UI. Reads ?name=, subscribes via EventSource, renders the merged view. |