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 }); } }