From f6a559d0d347667cc2b4b3f016507c2ba8cfabce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Nov 2025 12:05:57 +0000 Subject: [PATCH] Add readonly connections documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from cloudflare/agents PR #610 (https://github.com/cloudflare/agents/pull/610) Introduces documentation for the readonly connections feature which allows restricting certain WebSocket connections from modifying Agent state while still allowing them to receive state updates and call RPC methods. Key features documented: - Server-side methods: shouldConnectionBeReadonly, setConnectionReadonly, isConnectionReadonly - Client-side API: onStateUpdateError callback - Usage examples for query parameter based access, role-based access control, admin dashboards, and dynamic permission changes - Behavior details, best practices, and migration guide - Implementation details including persistence across hibernation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../api-reference/readonly-connections.mdx | 529 ++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 src/content/docs/agents/api-reference/readonly-connections.mdx diff --git a/src/content/docs/agents/api-reference/readonly-connections.mdx b/src/content/docs/agents/api-reference/readonly-connections.mdx new file mode 100644 index 000000000000000..e4ad54059a45803 --- /dev/null +++ b/src/content/docs/agents/api-reference/readonly-connections.mdx @@ -0,0 +1,529 @@ +--- +title: Readonly connections +pcx_content_type: concept +sidebar: + order: 7 +--- + +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. + +## 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 via `setState()` + +This is useful for scenarios like: + +- **View-only modes**: Users who should only observe but not modify +- **Role-based access**: Restricting state modifications based on user roles +- **Multi-tenant scenarios**: Some tenants have read-only access +- **Audit and monitoring connections**: Observers that should not affect the system + +## Server-side methods + +### shouldConnectionBeReadonly + +An overridable hook that determines if a connection should be marked as readonly when it connects. + + + +```ts +export class MyAgent extends Agent { + shouldConnectionBeReadonly( + connection: Connection, + ctx: ConnectionContext + ): boolean { + // Example: Check query parameters + const url = new URL(ctx.request.url); + return url.searchParams.get("readonly") === "true"; + } +} +``` + + + +### setConnectionReadonly + +Explicitly mark or unmark a connection as readonly. Can be called at any time. + + + +```ts +export class MyAgent extends Agent { + onConnect(connection: Connection, ctx: ConnectionContext) { + // Dynamic logic to determine readonly status + if (userIsViewer) { + this.setConnectionReadonly(connection, true); + } + } + + @callable() + async promoteToEditor(connectionId: string) { + const conn = this.getConnections().find((c) => c.id === connectionId); + if (conn) { + this.setConnectionReadonly(conn, false); + } + } +} +``` + + + +### isConnectionReadonly + +Check if a connection is currently marked as readonly. + + + +```ts +export class MyAgent extends Agent { + @callable() + async checkAccess() { + const { connection } = getCurrentAgent(); + if (connection) { + return { + canEdit: !this.isConnectionReadonly(connection) + }; + } + } +} +``` + + + +## Client-side API + +### onStateUpdateError callback + +Handle errors when a readonly connection attempts to update state. + + + +```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"); + } +}); + +// Using React Hook +const agent = useAgent({ + agent: "MyAgent", + name: "instance", + onStateUpdateError: (error) => { + setError(error); + // Show user-friendly message + } +}); +``` + + + +## Usage examples + +### Query parameter based access + + + +```ts +export class DocumentAgent extends Agent { + shouldConnectionBeReadonly( + connection: Connection, + ctx: ConnectionContext + ): boolean { + const url = new URL(ctx.request.url); + const mode = url.searchParams.get("mode"); + return mode === "view"; + } +} + +// Client connects with readonly mode +const agent = useAgent({ + agent: "DocumentAgent", + name: "doc-123", + query: { mode: "view" }, + onStateUpdateError: (error) => { + toast.error("Document is in view-only mode"); + } +}); +``` + + + +### Role-based access control + + + +```ts +export class CollaborativeAgent extends Agent { + shouldConnectionBeReadonly( + connection: Connection, + ctx: ConnectionContext + ): boolean { + const url = new URL(ctx.request.url); + const role = url.searchParams.get("role"); + return role === "viewer" || role === "guest"; + } + + onConnect(connection: Connection, ctx: ConnectionContext) { + const url = new URL(ctx.request.url); + const userId = url.searchParams.get("userId"); + + console.log( + `User ${userId} connected (readonly: ${this.isConnectionReadonly(connection)})` + ); + } + + @callable() + async upgradeToEditor() { + const { connection } = getCurrentAgent(); + if (!connection) return; + + // Check permissions (pseudo-code) + const canUpgrade = await checkUserPermissions(); + if (canUpgrade) { + this.setConnectionReadonly(connection, false); + return { success: true }; + } + + throw new Error("Insufficient permissions"); + } +} +``` + + + +### Admin dashboard + + + +```ts +export class MonitoringAgent extends Agent { + shouldConnectionBeReadonly( + connection: Connection, + ctx: ConnectionContext + ): boolean { + const url = new URL(ctx.request.url); + // Only admins can modify state + return url.searchParams.get("admin") !== "true"; + } + + onStateUpdate(state: SystemState, source: Connection | "server") { + if (source !== "server") { + // Log who modified the state + console.log(`State modified by connection ${source.id}`); + } + } +} + +// Admin client (can modify) +const adminAgent = useAgent({ + agent: "MonitoringAgent", + name: "system", + query: { admin: "true" } +}); + +// Viewer client (readonly) +const viewerAgent = useAgent({ + agent: "MonitoringAgent", + name: "system", + query: { admin: "false" }, + onStateUpdateError: (error) => { + console.log("Viewer cannot modify state"); + } +}); +``` + + + +### Dynamic permission changes + + + +```ts +export class GameAgent extends Agent { + @callable() + async startSpectatorMode() { + const { connection } = getCurrentAgent(); + if (!connection) return; + + this.setConnectionReadonly(connection, true); + return { mode: "spectator" }; + } + + @callable() + async joinAsPlayer() { + const { connection } = getCurrentAgent(); + if (!connection) return; + + const canJoin = this.state.players.length < 4; + if (canJoin) { + this.setConnectionReadonly(connection, false); + return { mode: "player" }; + } + + throw new Error("Game is full"); + } + + @callable() + async getMyPermissions() { + const { connection } = getCurrentAgent(); + if (!connection) return null; + + return { + canEdit: !this.isConnectionReadonly(connection), + connectionId: connection.id + }; + } +} + +// Client-side React component +function GameComponent() { + const [canEdit, setCanEdit] = useState(false); + + const agent = useAgent({ + agent: "GameAgent", + name: "game-123", + onStateUpdateError: (error) => { + toast.error("Cannot modify game state in spectator mode"); + } + }); + + useEffect(() => { + agent.call("getMyPermissions").then(perms => { + setCanEdit(perms?.canEdit ?? false); + }); + }, [agent]); + + return ( +
+ + + + +
+ {canEdit ? "You can modify the game" : "You are spectating"} +
+
+ ); +} +``` + +
+ +## Behavior details + +### State update errors + +When a readonly connection tries to update state: + +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 + +### State synchronization + +- 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 just cannot initiate state changes themselves + +### RPC methods + +- Readonly connections can call RPC methods (functions marked with `@callable()`) +- Implement additional authorization checks within RPC methods if needed + +### Connection cleanup + +- When a connection closes, the connection is automatically removed from the readonly tracking set +- No memory leaks from disconnected connections + +## Best practices + +### Combine with authentication + + + +```ts +export class SecureAgent extends Agent { + shouldConnectionBeReadonly( + connection: Connection, + ctx: ConnectionContext + ): boolean { + const url = new URL(ctx.request.url); + const token = url.searchParams.get("token"); + + // Verify token and get permissions + const permissions = this.verifyToken(token); + return !permissions.canWrite; + } +} +``` + + + +### Provide clear user feedback + + + +```ts +const agent = useAgent({ + agent: "MyAgent", + name: "instance", + onStateUpdateError: (error) => { + // User-friendly messages + if (error.includes("readonly")) { + showToast("You are in view-only mode. Upgrade to edit."); + } + } +}); +``` + + + +### Check permissions before UI actions + + + +```ts +function EditButton() { + const [canEdit, setCanEdit] = useState(false); + const agent = useAgent({ /* ... */ }); + + useEffect(() => { + agent.call("checkPermissions").then(perms => { + setCanEdit(perms.canEdit); + }); + }, []); + + return ( + + ); +} +``` + + + +### Log access attempts + + + +```ts +export class AuditedAgent extends Agent { + onStateUpdate(state: State, source: Connection | "server") { + if (source !== "server") { + this.audit({ + action: "state_update", + connectionId: source.id, + readonly: this.isConnectionReadonly(source), + timestamp: Date.now() + }); + } + } +} +``` + + + +## Migration guide + +If you have existing Agents and want to add readonly connection support: + +**Server-side**: No breaking changes. The feature is opt-in. + +**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); + } +}); +``` + + + +## Implementation details + +### 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 via `setState()` +- RPC methods can still be called (implement your own checks if needed) + +## Related resources + +- [Store and sync state](/agents/api-reference/store-and-sync-state/) +- [WebSockets](/agents/api-reference/websockets/) +- [Agent class](/agents/concepts/agent-class/)