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:

EventWhenWhat it carries
MESSAGES_SNAPSHOTOn connect, and after every committed changeFull message list with id, role, name, content
TEXT_MESSAGE_START / CONTENT / ENDWhile the AI is replyingPer-token deltas for live streaming
RUN_STARTED / RUN_FINISHED / RUN_ERRORBracketing each AI runLifecycle 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:

  1. Confirmed messages — replaced wholesale on every MESSAGES_SNAPSHOT.
  2. In-flight streaming messages — a Map<messageId, partial> updated by TEXT_MESSAGE_* events. Entries are dropped once the same id appears 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

PathWhat it does
lib/agui-room.tsSingleton room: messages, subscribers, broadcast helpers.
lib/agui-ai.tsCalls 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.tsSSE endpoint. Subscribes the client and forwards every broadcast event.
app/api/agui/send/route.tsPOST endpoint. Appends a UserMessage and triggers an AI reply.
app/page.tsxChat UI. Reads ?name=, subscribes via EventSource, renders the merged view.