/AYjnt/
All examples
websocketrealtimeui

Agent chat rooms with UI

Multi-user realtime chat. One DO per room, broadcast via the Agents connection API, co-located React UI with presence + typing indicators — demonstrates the state-sync vs broadcast trade-off.

What you'll learn
  • WebSocket lifecycle: `onConnect`, `onMessage`, `onClose`
  • Per-connection state via `conn.setState({ name })`
  • When to use state sync (persistent) vs `broadcast()` (ephemeral)
01 step

Start from the blank scaffold (with UI)

Same starter as the blank template but with React, react-dom and matching @types preinstalled so your agent.ts can have an app.tsx next to it. The default project is a Counter agent — we'll replace it with the example's agent in the next steps.

~/my-agent-app
my-app/ (--with-ui scaffold)
agent.ts
app.tsx
package.json
tsconfig.json
02 step

Replace counter/ with room/

One folder, one room class. Path segments after /room/ are separate DOs, so /room/general and /room/random are independent rooms with their own history and presence.

~/my-agent-app
03 step

agents/room/agent.ts — history + broadcast

The SDK's `Agent` extends partyserver's `Server`, which gives you `onConnect`, `onMessage`, `onClose`, plus `this.broadcast(msg, without?)` and `this.getConnections()`. State sync carries history + presence (new connections see them on join); broadcast carries transient events like typing indicators.

agents/room/agent.ts ts
import { Agent, type Connection, type WSMessage } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

type Message = { id: string; from: string; text: string; at: number };
type State = { messages: Message[]; members: string[] };

type ClientFrame =
  | { kind: "hello"; name: string }
  | { kind: "say"; text: string }
  | { kind: "typing"; on: boolean };

export default class RoomAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { messages: [], members: [] };

  override async onConnect(conn: Connection) {
    conn.setState({ name: null });   // anonymous until `hello`
  }

  override async onMessage(conn: Connection, message: WSMessage) {
    if (typeof message !== "string") return;
    const frame = JSON.parse(message) as ClientFrame;
    const name = (conn.state as { name: string | null } | null)?.name;

    switch (frame.kind) {
      case "hello": {
        conn.setState({ name: frame.name });
        this.refreshMembers();
        break;
      }
      case "say": {
        if (!name) return;
        const msg = { id: crypto.randomUUID(), from: name, text: frame.text, at: Date.now() };
        this.setState({ ...this.state, messages: [...this.state.messages, msg].slice(-100) });
        break;
      }
      case "typing": {
        if (!name) return;
        // Transient — broadcast, don't persist. Skip the sender by passing [conn.id].
        this.broadcast(JSON.stringify({ kind: "typing", from: name, on: frame.on }), [conn.id]);
        break;
      }
    }
  }

  override async onClose() { this.refreshMembers(); }

  private refreshMembers() {
    const names = new Set<string>();
    for (const c of this.getConnections()) {
      const n = (c.state as { name: string | null } | null)?.name;
      if (n) names.add(n);
    }
    const members = Array.from(names).sort();
    this.setState({ ...this.state, members });
  }

  override async onRequest() { return Response.json({ instance: this.name, ...this.state }); }
}
04 step

agents/room/app.tsx — UI

`useAgent()` reads state (history + members). `agent.send(JSON.stringify(...))` writes frames back to the server. The component also passes an `onMessage` callback to catch broadcast frames (typing indicators) that don't live in state.

agents/room/app.tsx tsx
import { useEffect, useState } from "react";
import { useAgent } from "@ayjnt/room";

export default function Room() {
  const [name] = useState(() => prompt("name?") ?? "guest");
  const [draft, setDraft] = useState("");
  const [typing, setTyping] = useState<Record<string, true>>({});

  const agent = useAgent({
    onMessage: (e: MessageEvent) => {
      try {
        const m = JSON.parse(e.data as string);
        if (m.kind === "typing") {
          setTyping((t) => m.on ? { ...t, [m.from]: true } : (({ [m.from]: _, ...rest }) => rest)(t));
        }
      } catch { /* not ours */ }
    },
  });

  useEffect(() => { agent.send(JSON.stringify({ kind: "hello", name })); }, [agent, name]);

  const send = () => {
    if (!draft.trim()) return;
    agent.send(JSON.stringify({ kind: "say", text: draft.trim() }));
    agent.send(JSON.stringify({ kind: "typing", on: false }));
    setDraft("");
  };

  const messages = agent.state?.messages ?? [];
  const members = agent.state?.members ?? [];
  const typingNames = Object.keys(typing).filter((n) => n !== name);

  return (
    <main>
      <h1>#{agent.name}</h1>
      <div>online: {members.join(", ")}</div>
      <ul>{messages.map((m) => <li key={m.id}><b>{m.from}:</b> {m.text}</li>)}</ul>
      <div>{typingNames.length ? `${typingNames.join(", ")} typing…` : ""}</div>
      <input value={draft}
        onChange={(e) => {
          setDraft(e.target.value);
          agent.send(JSON.stringify({ kind: "typing", on: e.target.value.length > 0 }));
        }}
        onKeyDown={(e) => e.key === "Enter" && send()} />
      <button onClick={send}>send</button>
    </main>
  );
}
05 step

State sync vs broadcast — the trade-off

If you tried to drive typing indicators through state, every new connection would see 'alice typing' frozen in time, and every keystroke would re-snapshot the entire messages array. Broadcast is the right tool for events that shouldn't persist.

trade-off.ts ts
// setState(...) ─┬─ persisted on the DO
//                ├─ sent to every new connection at connect time
//                ├─ ships a state diff to every live connection
//                └─ use for: history, members, anything a fresh tab should see
//
// this.broadcast(JSON.stringify(...)) ─┬─ fire-and-forget to live sockets only
//                                      ├─ not persisted
//                                      ├─ skip sender by passing [conn.id]
//                                      └─ use for: typing, presence pulses, game events
06 step

What it looks like

Open /room/general in two tabs, pick different names. Messages sync both ways; typing indicator shows for the other party; shared presence list updates in seconds.

two tabs, one room result
  tab 1 (alice)                  tab 2 (bob)
  ┌────────────────────────┐    ┌────────────────────────┐
  │ #general               │    │ #general               │
  │ you: alice · 2 online  │    │ you: bob   · 2 online  │
  │ [alice] [bob]          │    │ [alice] [bob]          │
  ├────────────────────────┤    ├────────────────────────┤
  │ alice  hi everyone     │    │ alice  hi everyone     │
  │ bob    oh hey!         │    │ bob    oh hey!         │
  │ alice  what's up       │    │ alice  what's up       │
  │                        │    │ alice is typing…       │
  │ [ about to send… ]     │    │                        │
  └────────────────────────┘    └────────────────────────┘
           │                              ▲
           │ JSON.stringify(              │
           │   { kind:"typing", on:true } │
           │ ) over WebSocket             │
           ▼                              │
        server broadcast() → bob ─────────┘
07 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app