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