ayjnt
All examples
reactuilive-sync

Basic agent UI

Drop an `app.tsx` next to `agent.ts`. The generated `useAgent()` hook is typed to that agent's class and state. Live multi-tab state sync built in — open two tabs, they share state.

What you'll learn
  • How `@ayjnt/<route>` resolves to a per-agent typed hook
  • How the worker serves HTML vs agent on the same URL
  • Why state-sync covers 80% of "realtime UI" without any extra wiring
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

Folder shape — agent + UI side by side

The `--with-ui` scaffold drops a Counter agent for you. Look at what it laid down — one folder with both the server (agent.ts) and the client (app.tsx). The generated typed hook wires them.

my-app/agents/counter/
agent.tsserver — the Durable Object
app.tsxclient — React UI bound to it
03 step

agents/counter/agent.ts

For a counter, state mutation from the client is enough. You don't even need methods on the class — `useAgent().setState` round-trips through the DO. The server just echoes state on onRequest.

agents/counter/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

type State = { count: number };

export default class CounterAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { count: 0 };

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

agents/counter/app.tsx — one typed hook call

`useAgent()` is generated per agent. The state shape is typed to `CounterAgent['state']`. `agent.setState(...)` round-trips through the server DO and fans out to every connected tab over WebSocket. No Redux, no Zustand, no provider.

agents/counter/app.tsx tsx
import { createRoot } from "react-dom/client";
import { useAgent } from "@ayjnt/counter";

function Counter() {
  const agent = useAgent();
  const count = agent.state?.count ?? 0;
  const set = (next: number) => agent.setState({ count: next });

  return (
    <main>
      <h1>Count: {count}</h1>
      <button onClick={() => set(count - 1)}>−</button>
      <button onClick={() => set(count + 1)}>+</button>
    </main>
  );
}

const root = document.getElementById("root");
if (root) createRoot(root).render(<Counter />);
05 step

How the worker picks HTML vs agent on the same URL

The same URL `/counter/:id` serves the HTML shell to browsers and forwards everything else to the agent. Disambiguation is a handful of lines in the generated `entry.ts` — middleware runs for both paths.

dispatch.ts ts
// GET + Accept: text/html + no Upgrade   → HTML shell (the UI)
// GET + Upgrade: websocket               → WS handshake → agent
// anything else (POST, curl without Accept) → agent.onRequest
06 step

Run + open two tabs

Open `/counter/demo` in two browser tabs. The `+` button in tab 1 updates tab 2 immediately. Each path segment after `/counter/` is its own DO — `/counter/room-1` and `/counter/room-2` are independent.

~/my-agent-app
07 step

What it looks like

Two tabs, one DO. Click + in either tab and the count updates in both.

counter — two tabs sharing state result
  tab 1: /counter/demo            tab 2: /counter/demo
  ┌─────────────────────────┐    ┌─────────────────────────┐
  │        Counter           │    │        Counter           │
  │  instance: demo          │    │  instance: demo          │
  │  open this URL in        │    │  open this URL in        │
  │  another tab — state     │    │  another tab — state     │
  │  syncs across tabs       │    │  syncs across tabs       │
  │                          │    │                          │
  │          42              │    │          42              │
  │                          │    │                          │
  │   [−]  [reset]  [+]      │    │   [−]  [reset]  [+]      │
  └─────────────────────────┘    └─────────────────────────┘
           │                              ▲
           │ click +                      │ re-renders with 43
           ▼                              │
           ─────setState({count:43})──────┘
             via CounterAgent DO
08 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