Co-located React UI
Write a React component in app.tsx next to your agent. ayjnt bundles it, serves it on the same URL as the agent, and generates a typed useAgent hook so state syncs automatically across every connected tab.
The one-file setup
If agents/counter/agent.ts exists and you add agents/counter/app.tsx next to it, the next ayjnt build will:
-
Generate a typed
useAgenthook at.ayjnt/client/counter/index.tsx. -
Generate a mount wrapper at
.ayjnt/client/counter/mount.tsxthat imports your default-exported component, wraps it in<StrictMode>+ an error boundary, and mounts it to#root. -
Bundle the wrapper (which imports your
app.tsx) with Bun, output to.ayjnt/assets/__ayjnt/counter/app.js. -
Write an HTML shell at
.ayjnt/assets/__ayjnt/counter/index.htmlthat references the bundled JS. -
Add the
assetsbinding to the generatedwrangler.jsoncso Cloudflare serves the assets. -
Generate worker-side dispatch that serves the HTML shell when a
browser navigates to
/counter/room-1.
The agent and the UI
This is a complete working pair (from examples/with-ui):
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 });
}
}import { useAgent } from "@ayjnt/counter";
export default 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>
<p>instance: <code>{agent.name}</code></p>
<button onClick={() => set(count - 1)}>−</button>
<button onClick={() => set(0)}>reset</button>
<button onClick={() => set(count + 1)}>+</button>
</main>
);
}
Notice there's no createRoot, no document.getElementById("root"), and no DOM wiring of
any kind. You export default your component — the
framework owns the mount. The same "write the unit, framework
writes the glue" contract as agent.ts.
useAgent() from @ayjnt/counter is the
typed hook ayjnt generated for this folder. No arguments — basePath is derived from the current URL. The returned agent has state synced from the DO (which arrives via
WebSocket after connect) and setState that round-trips
to the server.
What the mount wrapper looks like
The framework emits this at .ayjnt/client/counter/mount.tsx on every build. It's
what actually gets bundled; your app.tsx is pulled in
as an import.
// GENERATED by ayjnt — do not edit.
import { StrictMode, Component, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import App from "../../../agents/counter/app.tsx";
class AyjntErrorBoundary extends Component<
{ children: ReactNode },
{ error: unknown }
> {
// ...renders the thrown stack in a red <pre> if App throws
}
const el = document.getElementById("root");
if (!el) throw new Error("[ayjnt] mount target #root missing");
createRoot(el).render(
<StrictMode>
<AyjntErrorBoundary>
<App />
</AyjntErrorBoundary>
</StrictMode>,
);
Every UI gets <StrictMode> and a minimal error
boundary for free. Future framework additions — view transitions,
Suspense scaffolding, dev-only overlays — land here without you
having to rewrite your app.tsx.
Before v0.5, app.tsx was authored with an explicit createRoot(document.getElementById("root")).render(...)
tail. If your file doesn't have a default export, ayjnt falls
back to bundling it as-is — your existing code keeps working,
but the build prints a one-line deprecation warning pointing at
the file. Delete the manual mount, add export default to your component, and the warning
goes away.
Required tsconfig setup
The path alias @ayjnt/counter resolves via tsconfig.
The ayjnt new --with-ui template configures it; if
you're adding a UI to an existing project, add this to your tsconfig.json:
{
"compilerOptions": {
"paths": {
"@ayjnt/env": ["./.ayjnt/env.d.ts"],
"@ayjnt/*": ["./.ayjnt/client/*"]
}
}
}
TypeScript rejects path mappings without baseUrl
unless they're relative. .ayjnt/env.d.ts
without the ./ prefix will fail with TS5090: Non-relative paths are not allowed when 'baseUrl'
is not set.
If you'd rather not inline the paths: add "extends": "./.ayjnt/tsconfig.json"
to your tsconfig. ayjnt regenerates that file on every build with
the correct mappings. Works only after a first build, since the
file has to exist before TypeScript can read it.
What the generated hook looks like
For debugging, here's the (slightly abbreviated) code ayjnt
generates at .ayjnt/client/counter/index.tsx:
import {
useAgent as useAgentUpstream,
type UseAgentOptions,
} from "agents/react";
import type CounterAgent from "../../../agents/counter/agent.ts";
export type { default as CounterAgent } from "../../../agents/counter/agent.ts";
type Instance = InstanceType<typeof CounterAgent>;
type DefaultState = Instance extends { state: infer S } ? S : unknown;
export function useAgent<State = DefaultState>(
options?: Omit<UseAgentOptions<State>, "agent" | "basePath"> & {
name?: string;
},
): ReturnType<typeof useAgentUpstream<State>> {
const { name: overrideName, ...rest } = options ?? {};
const instanceName = overrideName ?? deriveInstance();
return useAgentUpstream<State>({
agent: "CounterAgent",
basePath: "counter" + "/" + instanceName,
...rest,
});
}
function deriveInstance(): string {
if (typeof window === "undefined") return "default";
const prefix = "/counter";
const p = window.location.pathname;
if (p !== prefix && !p.startsWith(prefix + "/")) return "default";
const remainder = p.slice(prefix.length);
const parts = remainder.split("/").filter(Boolean);
return parts[0] ?? "default";
}The hook:
-
Derives the instance name from the URL —
/counter/room-1→"room-1"— or falls back to"default"if the URL doesn't match. -
Uses
basePath(notpath) so ayjnt's URL shape is preserved. See Client integration for why. -
Types the default
StateasInstanceType<typeof CounterAgent>["state"]— soagent.stateis typed to your actual state shape.
HTML vs agent on the same URL
The worker serves three different responses from /counter/room-1 depending on what the request looks
like:
| Request | Response |
|---|---|
Browser navigation (GET + Accept: text/html)
| HTML shell from Assets binding |
useAgent handshake (Upgrade: websocket)
| WebSocket upgrade → agent |
curl or fetch with no Accept: text/html | Agent's onRequest |
Live state sync across tabs
Open /counter/room-1 in two browser tabs. Click + in one. The counter updates in the other immediately.
This is handled entirely by the SDK — the DO broadcasts a CF_AGENT_STATE message to every connected client every
time setState is called (server-side or
client-side).
The counter example intentionally doesn't expose any methods —
everything goes through agent.setState from the client.
For richer APIs (place order, cancel, redeem) you'll want
dedicated agent methods; see
Inter-agent RPC
.
How the bundling works
-
Bun.buildruns withtarget: "browser"on everyapp.tsx. React,react-dom,agents/react, and the generated hook all get bundled in. -
Output is written as
app.jsunder.ayjnt/assets/__ayjnt/<route>/. -
The HTML shell references the bundle via
<script type="module" src="/__ayjnt/counter/app.js">. - Cloudflare Assets serves both files. The worker is only involved for the initial HTML fetch (where it proxies to the asset); the JS bundle is served directly from Cloudflare's edge.
The generated wrangler config sets html_handling: "none" on the Assets binding.
This isn't cosmetic — it's a bug fix.
Cloudflare Assets defaults to auto-trailing-slash,
which redirects /foo/index.html to /foo/. That redirect would leak into the browser URL
bar, and the useAgent hook — which reads window.location.pathname to derive the instance —
would see the wrong URL and fall back to "default". Every user would end up on the
same instance regardless of which URL they visited.
html_handling: "none" disables the
rewrite. The browser URL stays at the user's original path
and the instance is derived correctly.
Adding react and react-dom
React isn't a transitive dependency of ayjnt — you bring it
yourself. ayjnt new --with-ui adds these; for an
existing project:
bun add react react-dom
bun add -d @types/react @types/react-dom
Also add DOM and DOM.Iterable to your tsconfig.json's lib array so browser
types are available in the app.tsx file.
Bundle size
A typical app.tsx bundle is 150–450 KB after gzip —
mostly React. This is inlined into Cloudflare Workers Assets, which
has a 25 MB limit per asset file. In practice you'll never hit
that; if you do, code-splitting via dynamic imports works.
If you want to share React across multiple app.tsx files (one React copy per bundle is wasteful),
the current workaround is to keep the UI in one place and have
other agents link to it. A shared-chunk story is on the roadmap.