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/)