diff --git a/src/content/docs/agents/api-reference/agents-api.mdx b/src/content/docs/agents/api-reference/agents-api.mdx
index 1d3aa20ee24..9b823ca46ab 100644
--- a/src/content/docs/agents/api-reference/agents-api.mdx
+++ b/src/content/docs/agents/api-reference/agents-api.mdx
@@ -62,7 +62,7 @@ flowchart TD
| `onError(connection, error)` | When a WebSocket error occurs |
| `onClose(connection, code, reason, wasClean)` | When a WebSocket connection closes |
| `onEmail(email)` | When an email is routed to the instance |
-| `onStateUpdate(state, source)` | When state changes (from server or client) |
+| `onStateChanged(state, source)` | When state changes (from server or client) |
## Core properties
@@ -77,7 +77,7 @@ flowchart TD
| Feature | Methods | Documentation |
| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- |
-| **State** | `setState()`, `onStateUpdate()`, `initialState` | [Store and sync state](/agents/api-reference/store-and-sync-state/) |
+| **State** | `setState()`, `onStateChanged()`, `initialState` | [Store and sync state](/agents/api-reference/store-and-sync-state/) |
| **Callable methods** | `@callable()` decorator | [Callable methods](/agents/api-reference/callable-methods/) |
| **Scheduling** | `schedule()`, `scheduleEvery()`, `getSchedules()`, `cancelSchedule()` | [Schedule tasks](/agents/api-reference/schedule-tasks/) |
| **Queue** | `queue()`, `dequeue()`, `dequeueAll()`, `getQueue()` | [Queue tasks](/agents/api-reference/queue-tasks/) |
diff --git a/src/content/docs/agents/api-reference/client-sdk.mdx b/src/content/docs/agents/api-reference/client-sdk.mdx
index b160d119552..997acf2b424 100644
--- a/src/content/docs/agents/api-reference/client-sdk.mdx
+++ b/src/content/docs/agents/api-reference/client-sdk.mdx
@@ -220,7 +220,7 @@ agent.setState({ score: 100, level: 5 });
When you call `setState()`:
1. The state is sent to the agent over WebSocket
-2. The agent's `onStateUpdate()` method is called
+2. The agent's `onStateChanged()` method is called
3. The agent broadcasts the new state to all connected clients
4. Your `onStateUpdate` callback fires with `source: "client"`
diff --git a/src/content/docs/agents/api-reference/readonly-connections.mdx b/src/content/docs/agents/api-reference/readonly-connections.mdx
index c40f18ce795..043bb7e9ad0 100644
--- a/src/content/docs/agents/api-reference/readonly-connections.mdx
+++ b/src/content/docs/agents/api-reference/readonly-connections.mdx
@@ -7,15 +7,15 @@ sidebar:
import { TypeScriptExample } from "~/components";
-Readonly connections allow you to restrict certain WebSocket connections from modifying the agent state while still allowing them to receive state updates and call RPC methods.
+Readonly connections restrict certain WebSocket clients from modifying agent state while still letting them receive state updates and call non-mutating RPC methods.
## Overview
When a connection is marked as readonly:
-- It can **receive** state updates from the server
-- It can **call** RPC methods (callable methods on the agent)
-- It **cannot** send state updates using `setState()`
+- It **receives** state updates from the server
+- It **can call** RPC methods that do not modify state
+- It **cannot** call `this.setState()` — neither via client-side `setState()` nor via a `@callable()` method that calls `this.setState()` internally
This is useful for scenarios like:
@@ -24,11 +24,42 @@ This is useful for scenarios like:
- **Multi-tenant scenarios**: Some tenants have read-only access
- **Audit and monitoring connections**: Observers that should not affect the system
-## Server-side methods
+
-### shouldConnectionBeReadonly
+```ts
+import { Agent, type Connection, type ConnectionContext } from "agents";
-An overridable hook that determines if a connection should be marked as readonly when it connects.
+export class DocAgent extends Agent {
+ shouldConnectionBeReadonly(connection: Connection, ctx: ConnectionContext) {
+ const url = new URL(ctx.request.url);
+ return url.searchParams.get("mode") === "view";
+ }
+}
+```
+
+
+
+
+
+```ts
+// Client - view-only mode
+const agent = useAgent({
+ agent: "DocAgent",
+ name: "doc-123",
+ query: { mode: "view" },
+ onStateUpdateError: (error) => {
+ toast.error("You're in view-only mode");
+ },
+});
+```
+
+
+
+## Marking connections as readonly
+
+### On connect
+
+Override `shouldConnectionBeReadonly` to evaluate each connection when it first connects. Return `true` to mark it readonly.
@@ -38,35 +69,38 @@ export class MyAgent extends Agent {
connection: Connection,
ctx: ConnectionContext,
): boolean {
- // Example: Check query parameters
const url = new URL(ctx.request.url);
- return url.searchParams.get("readonly") === "true";
+ const role = url.searchParams.get("role");
+ return role === "viewer" || role === "guest";
}
}
```
-### setConnectionReadonly
+This hook runs before the initial state is sent to the client, so the connection is readonly from the very first message.
+
+### At any time
-Explicitly mark or unmark a connection as readonly. Can be called at any time.
+Use `setConnectionReadonly` to change a connection's readonly status dynamically:
```ts
-export class MyAgent extends Agent {
- onConnect(connection: Connection, ctx: ConnectionContext) {
- // Dynamic logic to determine readonly status
- if (userIsViewer) {
+export class GameAgent extends Agent {
+ @callable()
+ async startSpectating() {
+ const { connection } = getCurrentAgent();
+ if (connection) {
this.setConnectionReadonly(connection, true);
}
}
@callable()
- async promoteToEditor(connectionId: string) {
- const conn = this.getConnections().find((c) => c.id === connectionId);
- if (conn) {
- this.setConnectionReadonly(conn, false);
+ async joinAsPlayer() {
+ const { connection } = getCurrentAgent();
+ if (connection) {
+ this.setConnectionReadonly(connection, false);
}
}
}
@@ -74,21 +108,21 @@ export class MyAgent extends Agent {
-### isConnectionReadonly
+### Letting a connection toggle its own status
-Check if a connection is currently marked as readonly.
+A connection can toggle its own readonly status via a callable. This is useful for lock/unlock UIs where viewers can opt into editing mode:
```ts
-export class MyAgent extends Agent {
+import { Agent, callable, getCurrentAgent } from "agents";
+
+export class CollabAgent extends Agent {
@callable()
- async checkAccess() {
+ async setMyReadonly(readonly: boolean) {
const { connection } = getCurrentAgent();
if (connection) {
- return {
- canEdit: !this.isConnectionReadonly(connection),
- };
+ this.setConnectionReadonly(connection, readonly);
}
}
}
@@ -96,38 +130,126 @@ export class MyAgent extends Agent {
-## Client-side API
+On the client:
-### onStateUpdateError callback
+
+
+```ts
+// Toggle between readonly and writable
+await agent.call("setMyReadonly", [true]); // lock
+await agent.call("setMyReadonly", [false]); // unlock
+```
+
+
+
+### Checking status
-Handle errors when a readonly connection attempts to update state.
+Use `isConnectionReadonly` to check a connection's current status:
```ts
-// Using AgentClient
-const client = new AgentClient({
- agent: "MyAgent",
- name: "instance",
- onStateUpdateError: (error) => {
- console.error("State update failed:", error);
- alert("You do not have permission to modify the state");
- },
-});
+export class MyAgent extends Agent {
+ @callable()
+ async getPermissions() {
+ const { connection } = getCurrentAgent();
+ if (connection) {
+ return { canEdit: !this.isConnectionReadonly(connection) };
+ }
+ }
+}
+```
-// Using React Hook
+
+
+## Handling errors on the client
+
+Errors surface in two ways depending on how the write was attempted:
+
+- **Client-side `setState()`** — the server sends a `cf_agent_state_error` message. Handle it with the `onStateUpdateError` callback.
+- **`@callable()` methods** — the RPC call rejects with an error. Handle it with a `try`/`catch` around `agent.call()`.
+
+:::note
+`onStateUpdateError` also fires when `validateStateChange` rejects a client-originated state update (with the message `"State update rejected"`). This makes the callback useful for handling any rejected state write, not just readonly errors.
+:::
+
+
+
+```ts
const agent = useAgent({
agent: "MyAgent",
name: "instance",
+ // Fires when client-side setState() is blocked
onStateUpdateError: (error) => {
setError(error);
- // Show user-friendly message
},
});
+
+// Fires when a callable that writes state is blocked
+try {
+ await agent.call("updateSettings", [newSettings]);
+} catch (e) {
+ setError(e instanceof Error ? e.message : String(e)); // "Connection is readonly"
+}
```
+To avoid showing errors in the first place, check permissions before rendering edit controls:
+
+```tsx
+function Editor() {
+ const [canEdit, setCanEdit] = useState(false);
+ const agent = useAgent({ agent: "MyAgent", name: "instance" });
+
+ useEffect(() => {
+ agent.call("getPermissions").then((p) => setCanEdit(p.canEdit));
+ }, []);
+
+ return ;
+}
+```
+
+## API reference
+
+### `shouldConnectionBeReadonly`
+
+An overridable hook that determines if a connection should be marked as readonly when it connects.
+
+| Parameter | Type | Description |
+| ------------ | ------------------- | ---------------------------- |
+| `connection` | `Connection` | The connecting client |
+| `ctx` | `ConnectionContext` | Contains the upgrade request |
+| **Returns** | `boolean` | `true` to mark as readonly |
+
+Default: returns `false` (all connections are writable).
+
+### `setConnectionReadonly`
+
+Mark or unmark a connection as readonly. Can be called at any time.
+
+| Parameter | Type | Description |
+| ------------ | ------------ | ----------------------------------------- |
+| `connection` | `Connection` | The connection to update |
+| `readonly` | `boolean` | `true` to make readonly (default: `true`) |
+
+### `isConnectionReadonly`
+
+Check if a connection is currently readonly.
+
+| Parameter | Type | Description |
+| ------------ | ------------ | ----------------------- |
+| `connection` | `Connection` | The connection to check |
+| **Returns** | `boolean` | `true` if readonly |
+
+### `onStateUpdateError` (client)
+
+Callback on `AgentClient` and `useAgent` options. Called when the server rejects a state update.
+
+| Parameter | Type | Description |
+| --------- | -------- | ----------------------------- |
+| `error` | `string` | Error message from the server |
+
## Examples
### Query parameter based access
@@ -217,7 +339,7 @@ export class MonitoringAgent extends Agent {
return url.searchParams.get("admin") !== "true";
}
- onStateUpdate(state: SystemState, source: Connection | "server") {
+ onStateChanged(state: SystemState, source: Connection | "server") {
if (source !== "server") {
// Log who modified the state
console.log(`State modified by connection ${source.id}`);
@@ -328,38 +450,88 @@ function GameComponent() {
}
```
-## Behavior details
+## How it works
+
+Readonly status is stored in the connection's WebSocket attachment, which persists through the WebSocket Hibernation API. The flag is namespaced internally so it cannot be accidentally overwritten by `connection.setState()`. This means:
+
+- **Survives hibernation** — the flag is serialized and restored when the agent wakes up
+- **No cleanup needed** — connection state is automatically discarded when the connection closes
+- **Zero overhead** — no database tables or queries, just the connection's built-in attachment
+- **Safe from user code** — `connection.state` and `connection.setState()` never expose or overwrite the readonly flag
+
+When a readonly connection tries to modify state, the server blocks it — regardless of whether the write comes from client-side `setState()` or from a `@callable()` method:
+
+```
+Client (readonly) Agent
+ │ │
+ │ setState({ count: 1 }) │
+ │ ─────────────────────────────▶ │ Check readonly → blocked
+ │ ◀─────────────────────────── │
+ │ cf_agent_state_error │
+ │ │
+ │ call("increment") │
+ │ ─────────────────────────────▶ │ increment() calls this.setState()
+ │ │ Check readonly → throw
+ │ ◀─────────────────────────── │
+ │ RPC error: "Connection is │
+ │ readonly" │
+ │ │
+ │ call("getPermissions") │
+ │ ─────────────────────────────▶ │ getPermissions() — no setState()
+ │ ◀─────────────────────────── │
+ │ RPC result: { canEdit: false }│
+```
+
+### What readonly does and does not restrict
-### What happens when a readonly connection tries to update state
+| Action | Allowed? |
+| ------------------------------------------------------ | -------- |
+| Receive state broadcasts | Yes |
+| Call `@callable()` methods that do not write state | Yes |
+| Call `@callable()` methods that call `this.setState()` | **No** |
+| Send state updates via client-side `setState()` | **No** |
-1. The connection sends a state update message
-2. The server checks if the connection is readonly
-3. If readonly, the server sends back an error response:
- ```json
- {
- "type": "cf_agent_state_error",
- "error": "Connection is readonly"
- }
- ```
-4. The client `onStateUpdateError` callback is invoked
-5. The state is not updated on the server
-6. Other connections are not notified
+The enforcement happens inside `setState()` itself. When a `@callable()` method tries to call `this.setState()` and the current connection context is readonly, the framework throws an `Error("Connection is readonly")`. This means you do not need manual permission checks in your RPC methods — any callable that writes state is automatically blocked for readonly connections.
+
+## Caveats
+
+### Side effects in callables still run
+
+The readonly check happens inside `this.setState()`, not at the start of the callable. If your method has side effects before the state write, those will still execute:
+
+
-### State synchronization
+```ts
+export class MyAgent extends Agent {
+ @callable()
+ async processOrder(orderId: string) {
+ await sendConfirmationEmail(orderId); // runs even for readonly connections
+ await chargePayment(orderId); // runs too
+ this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws
+ }
+}
+```
-- Readonly connections still receive state updates from the server
-- When state is updated (by server or other connections), readonly connections get the new state
-- They cannot initiate state changes themselves
+
-### RPC methods
+To avoid this, either check permissions before side effects or structure your code so the state write comes first:
-- Readonly connections can call RPC methods (functions marked with `@callable()`)
-- It is up to you to implement additional authorization checks within RPC methods if needed
+
-### Connection cleanup
+```ts
+export class MyAgent extends Agent {
+ @callable()
+ async processOrder(orderId: string) {
+ // Write state first — throws immediately for readonly connections
+ this.setState({ ...this.state, orders: [...this.state.orders, orderId] });
+ // Side effects only run if setState succeeded
+ await sendConfirmationEmail(orderId);
+ await chargePayment(orderId);
+ }
+}
+```
-- When a connection closes, it is automatically removed from the readonly tracking set
-- No memory leaks from disconnected connections
+
## Best practices
@@ -429,7 +601,7 @@ function EditButton() {
```ts
export class AuditedAgent extends Agent {
- onStateUpdate(state: State, source: Connection | "server") {
+ onStateChanged(state: State, source: Connection | "server") {
if (source !== "server") {
this.audit({
action: "state_update",
@@ -444,74 +616,14 @@ export class AuditedAgent extends Agent {
-## Migration guide
-
-If you have existing agents and want to add readonly connection support:
-
-1. **Server-side**: No breaking changes. The feature is opt-in.
-2. **Client-side**: Add `onStateUpdateError` handlers where needed.
-
-
-
-```ts
-// Before
-const agent = useAgent({
- agent: "MyAgent",
- name: "instance",
-});
-
-// After (with error handling)
-const agent = useAgent({
- agent: "MyAgent",
- name: "instance",
- onStateUpdateError: (error) => {
- console.error("State update error:", error);
- },
-});
-```
-
-
-
-## How it works
-
-### Persistence across hibernation
-
-Readonly connection status is automatically persisted to the agent SQL storage, which means:
-
-- **Survives hibernation** - When an agent hibernates and wakes up, readonly connections maintain their status
-- **No memory leaks** - Connections are automatically cleaned up when they close
-- **Performance optimized** - Uses in-memory cache with SQL fallback
-
-The implementation uses a two-tier approach:
-
-1. **In-memory Set** for fast lookups during active operation
-2. **SQL table** (`cf_agents_readonly_connections`) for persistence across hibernation
-
-When checking if a connection is readonly:
-
-1. First checks the in-memory cache (fast)
-2. If not found, queries SQL storage (handles post-hibernation case)
-3. Populates cache if found in storage
-
-### Storage details
-
-The readonly status is stored in a dedicated table:
-
-```sql
-CREATE TABLE cf_agents_readonly_connections (
- connection_id TEXT PRIMARY KEY NOT NULL,
- created_at INTEGER DEFAULT (unixepoch())
-)
-```
-
-All CRUD operations automatically sync both in-memory and persistent storage.
-
## Limitations
- Readonly status only applies to state updates using `setState()`
- RPC methods can still be called (implement your own checks if needed)
+- Readonly is a per-connection flag, not tied to user identity
## Related resources
- [Store and sync state](/agents/api-reference/store-and-sync-state/)
- [WebSockets](/agents/api-reference/websockets/)
+- [Callable methods](/agents/api-reference/callable-methods/)
diff --git a/src/content/docs/agents/api-reference/store-and-sync-state.mdx b/src/content/docs/agents/api-reference/store-and-sync-state.mdx
index 05a8f2242c4..447e4335afb 100644
--- a/src/content/docs/agents/api-reference/store-and-sync-state.mdx
+++ b/src/content/docs/agents/api-reference/store-and-sync-state.mdx
@@ -47,7 +47,7 @@ export class GameAgent extends Agent {
};
// React to state changes
- onStateUpdate(state: GameState, source: Connection | "server") {
+ onStateChanged(state: GameState, source: Connection | "server") {
if (source !== "server" && state.players.length >= 2) {
// Client added a player, start the game
this.setState({ ...state, status: "playing" });
@@ -184,7 +184,7 @@ Use `setState()` to update state. This:
1. Saves to SQLite (persistent)
2. Broadcasts to all connected clients
-3. Triggers `onStateUpdate()` (after broadcast; best-effort)
+3. Triggers `onStateChanged()` (after broadcast; best-effort)
@@ -235,13 +235,13 @@ this.setState({
## Responding to state changes
-Override `onStateUpdate()` to react when state changes (notifications/side-effects):
+Override `onStateChanged()` to react when state changes (notifications/side-effects):
```ts
class MyAgent extends Agent {
- onStateUpdate(state: GameState, source: Connection | "server") {
+ onStateChanged(state: GameState, source: Connection | "server") {
console.log("State updated:", state);
console.log("Updated by:", source === "server" ? "server" : source.id);
}
@@ -272,7 +272,7 @@ class MyAgent extends Agent<
Env,
{ status: "waiting" | "playing" | "finished" }
> {
- onStateUpdate(state: GameState, source: Connection | "server") {
+ onStateChanged(state: GameState, source: Connection | "server") {
// Ignore server-initiated updates
if (source === "server") return;
@@ -296,7 +296,7 @@ class MyAgent extends Agent<
```ts
class MyAgent extends Agent {
- onStateUpdate(state: State, source: Connection | "server") {
+ onStateChanged(state: State, source: Connection | "server") {
if (source === "server") return;
// Client added a message
@@ -346,7 +346,7 @@ class MyAgent extends Agent {
:::note
-`onStateUpdate()` is not intended for validation; it is a notification hook and should not block broadcasts. Use `validateStateChange()` for validation.
+`onStateChanged()` is not intended for validation; it is a notification hook and should not block broadcasts. Use `validateStateChange()` for validation.
:::
@@ -365,7 +365,7 @@ function GameUI() {
const agent = useAgent({
agent: "game-agent",
name: "room-123",
- onStateUpdate: (state, source) => {
+ onStateChanged: (state, source) => {
console.log("State updated:", state);
}
});
@@ -394,7 +394,7 @@ import { AgentClient } from "agents/client";
const client = new AgentClient({
agent: "game-agent",
name: "room-123",
- onStateUpdate: (state) => {
+ onStateChanged: (state) => {
document.getElementById("score").textContent = state.score;
},
});
@@ -556,7 +556,7 @@ function sendMessage(text: string) {
// Server-side
class MyAgent extends Agent {
- onStateUpdate(state: GameState, source: Connection | "server") {
+ onStateChanged(state: GameState, source: Connection | "server") {
if (source === "server") return;
const pendingMessages = state.messages.filter((m) => m.pending);
@@ -626,12 +626,12 @@ Be careful not to trigger state updates in response to your own updates:
```ts
// Bad - infinite loop
-onStateUpdate(state: State) {
+onStateChanged(state: State) {
this.setState({ ...state, lastUpdated: Date.now() });
}
// Good - check source
-onStateUpdate(state: State, source: Connection | "server") {
+onStateChanged(state: State, source: Connection | "server") {
if (source === "server") return; // Do not react to own updates
this.setState({ ...state, lastUpdated: Date.now() });
}
@@ -705,7 +705,7 @@ This works because each instance of an Agent has its own database, and the state
| Method | Signature | Description |
| --------------------- | ------------------------------------------------------------ | --------------------------------------------- |
| `setState` | `(state: State) => void` | Update state, persist, and broadcast |
-| `onStateUpdate` | `(state: State, source: Connection \| "server") => void` | Called when state changes |
+| `onStateChanged` | `(state: State, source: Connection \| "server") => void` | Called when state changes |
| `validateStateChange` | `(nextState: State, source: Connection \| "server") => void` | Validate before persistence (throw to reject) |
### Workflow step methods
diff --git a/src/content/docs/agents/concepts/agent-class.mdx b/src/content/docs/agents/concepts/agent-class.mdx
index 175c47f00df..af617d6cbe8 100644
--- a/src/content/docs/agents/concepts/agent-class.mdx
+++ b/src/content/docs/agents/concepts/agent-class.mdx
@@ -201,7 +201,7 @@ One of the core features of `Agent` is **automatic state persistence**. Develope
`this.state` is a getter that lazily loads state from storage (SQL). State is persisted across Durable Object evictions when it is updated with `this.setState()`, which automatically serializes the state and writes it back to storage.
-There's also `this.onStateUpdate` that you can override to react to state changes.
+There's also `this.onStateChanged` that you can override to react to state changes.
```ts
class MyAgent extends Agent {
@@ -211,7 +211,7 @@ class MyAgent extends Agent {
this.setState({ count: this.state.count + 1 });
}
- onStateUpdate(state, source) {
+ onStateChanged(state, source) {
console.log("State updated:", state);
}
}
diff --git a/src/content/docs/agents/getting-started/quick-start.mdx b/src/content/docs/agents/getting-started/quick-start.mdx
index d7d4bd0bf15..a7a0a6c1bab 100644
--- a/src/content/docs/agents/getting-started/quick-start.mdx
+++ b/src/content/docs/agents/getting-started/quick-start.mdx
@@ -296,7 +296,7 @@ Now that you have a working agent, explore these topics:
{
);
}
- onStateUpdate(state: State) {
+ onStateChanged(state: State) {
console.log({ stateUpdate: state });
}
}