Agent state
Each agent instance is a Durable Object with its own persistent state. The Agents SDK wraps it in a typed this.state / this.setState API. Understanding when state is loaded, saved, and broadcast is the difference between a working app and one with subtle data bugs.
Classes, bindings, and instances
Three concepts that often get conflated:
- Class — what you wrote in
agent.ts. One class per folder. - Binding — the Durable Object namespace wrangler
creates. One binding per class. ayjnt names it
UPPER_SNAKEof your class name (ChatAgent→CHAT_AGENT). - Instance — one actual object with its own storage.
Created on demand by the URL segment
/<route>/:instanceId. There can be an unbounded number of instances per class.
So /chat/alice and /chat/bob are two
different instances of the same ChatAgent class, sharing one CHAT_AGENT binding. Each has its own state, storage, in-memory lifetime,
and hibernation cycle.
Declaring state
State is a typed field on the agent class. Declare its shape as the
second generic on Agent<Env, State> and provide an initialState:
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";
type Message = { role: "user" | "assistant"; text: string; at: number };
type State = { messages: Message[]; topic: string };
export default class ChatAgent extends Agent<GeneratedEnv, State> {
override initialState: State = {
messages: [],
topic: "general",
};
// ...
}
The override keyword is required because the base class
declares initialState as a field. Your State
type must be JSON-serializable: no functions, no class instances, no Date objects (store as number for epoch ms),
no Map/Set (use plain objects/arrays).
Reading state
Inside the class, this.state is always a current,
strongly-typed snapshot:
override async onRequest(): Promise<Response> {
return Response.json({
topic: this.state.topic,
count: this.state.messages.length,
recent: this.state.messages.slice(-5),
});
}
On the first request to a new instance, the SDK initializes this.state to your initialState. On every
subsequent request, it's whatever setState last
wrote — even if the DO was hibernated in between.
Writing state
Call this.setState(newState). Pass the complete next
state, not a diff — the SDK replaces the whole object and broadcasts
it:
async append(msg: Message) {
this.setState({
...this.state,
messages: [...this.state.messages, msg],
});
} this.state.messages.push(msg) appears to work but is a
bug:
- The storage persistence hook doesn't fire.
- Connected clients don't receive a broadcast.
- On next DO hibernation, your mutation is lost.
Treat this.state as immutable. setState
is the only writer.
What setState actually does
From the SDK side, every setState call:
- Replaces the in-memory
this.statesynchronously. - Enqueues a persistence write against the DO's SQLite-backed storage. (This is handled by the SDK — you don't write SQL.)
-
Broadcasts a
CF_AGENT_STATEmessage to every currently connected WebSocket client. TheuseAgentReact hook picks it up and re-renders.
State survives restarts
Durable Object state is persistent by design. The worker process can
be evicted, the region can fail over, your deployment can cycle — the
next request to /chat/alice reloads that instance's state from
storage. This is usually what you want (agents remember their
conversations across restarts), but it's a notable trap in
development.
.wrangler/ across ayjnt dev restarts. If a
demo script depends on starting fresh, either:
-
Wipe storage with
rm -rf .wrangler && bun run dev. -
Add a reset endpoint or method to your agent that sets state back
to
initialState, and call it at the top of your script.
examples/inter-agent sample uses both techniques —
see its client.ts.
State size limits
Cloudflare DO storage has a per-key-value size limit (currently 128
KB for SQLite-backed storage, higher for KV-backed storage — check
the
Cloudflare DO limits docs
for current values). setState serializes your whole
state as one value, so very large states (thousands of messages,
hundreds of KB of text) start hitting that ceiling.
For chat-like workloads with unbounded growth:
-
Keep a summary / recent-window in
state, archive older data to direct DO storage via the lower-levelthis.ctx.storageAPI. - Or shard by instance — one DO per conversation keeps each individual state small.
- Or externalize to R2/D1 and keep only pointers in agent state.
The this.sql helper on the Agent base class gives you
direct access to the DO's embedded SQLite; for anything past
trivial state sizes that's the API you want.
Reading state from the client
When you use the React useAgent hook, the initial state
arrives on connect (as a CF_AGENT_STATE WebSocket
message) and every subsequent setState call on the
server re-broadcasts. The client hook exposes it as agent.state:
import { useAgent } from "@ayjnt/chat";
export function Chat() {
const agent = useAgent();
// Undefined until the first CF_AGENT_STATE message arrives:
const messages = agent.state?.messages ?? [];
// ...
} agent.state can be undefined before the
first state message lands. Handle that case — it's the difference
between a render that flashes NaN and one that shows a
loading placeholder. The generated hook types it correctly, so
TypeScript will force you to handle it.
agent.setState(newState) also exists on the client. It
sends an update to the server, which persists and re-broadcasts. This
is what the with-ui counter example uses — no server
method needed, just optimistic state replacement from the UI.