Readonly connections

Last updated Jun 3, 2026 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 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:

  • 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
import { Agent } from "agents";

export class DocAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        const url = new URL(ctx.request.url);
        return url.searchParams.get("mode") === "view";
    }
}
import { Agent, type Connection, type ConnectionContext } from "agents";

export class DocAgent extends Agent<Env, DocState> {
    shouldConnectionBeReadonly(connection: Connection, ctx: ConnectionContext) {
        const url = new URL(ctx.request.url);
        return url.searchParams.get("mode") === "view";
    }
}
// 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");
    },
});
// 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.

export class MyAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        const url = new URL(ctx.request.url);
        const role = url.searchParams.get("role");
        return role === "viewer" || role === "guest";
    }
}
export class MyAgent extends Agent<Env, State> {
    shouldConnectionBeReadonly(
        connection: Connection,
        ctx: ConnectionContext,
    ): boolean {
        const url = new URL(ctx.request.url);
        const role = url.searchParams.get("role");
        return role === "viewer" || role === "guest";
    }
}

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

Use setConnectionReadonly to change a connection's readonly status dynamically:

export class GameAgent extends Agent {
    @callable()
    async startSpectating() {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, true);
        }
    }

@callable()
    async joinAsPlayer() {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, false);
        }
    }
}
export class GameAgent extends Agent<Env, GameState> {
    @callable()
    async startSpectating() {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, true);
        }
    }

@callable()
    async joinAsPlayer() {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, false);
        }
    }
}

Letting a connection toggle its own status

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:

import { Agent, callable, getCurrentAgent } from "agents";

export class CollabAgent extends Agent {
    @callable()
    async setMyReadonly(readonly) {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, readonly);
        }
    }
}
import { Agent, callable, getCurrentAgent } from "agents";

export class CollabAgent extends Agent<Env, State> {
    @callable()
    async setMyReadonly(readonly: boolean) {
        const { connection } = getCurrentAgent();
        if (connection) {
            this.setConnectionReadonly(connection, readonly);
        }
    }
}

On the client:

// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock
// Toggle between readonly and writable
await agent.call("setMyReadonly", [true]); // lock
await agent.call("setMyReadonly", [false]); // unlock

Checking status

Use isConnectionReadonly to check a connection's current status:

export class MyAgent extends Agent {
    @callable()
    async getPermissions() {
        const { connection } = getCurrentAgent();
        if (connection) {
            return { canEdit: !this.isConnectionReadonly(connection) };
        }
    }
}
export class MyAgent extends Agent<Env, State> {
    @callable()
    async getPermissions() {
        const { connection } = getCurrentAgent();
        if (connection) {
            return { canEdit: !this.isConnectionReadonly(connection) };
        }
    }
}

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.

const agent = useAgent({
    agent: "MyAgent",
    name: "instance",
    // Fires when client-side setState() is blocked
    onStateUpdateError: (error) => {
        setError(error);
    },
});

// 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"
}
const agent = useAgent({
    agent: "MyAgent",
    name: "instance",
    // Fires when client-side setState() is blocked
    onStateUpdateError: (error) => {
        setError(error);
    },
});

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

function Editor() {
    const [canEdit, setCanEdit] = useState(false);
    const agent = useAgent({ agent: "MyAgent", name: "instance" });

useEffect(() => {
        agent.call("getPermissions").then((p) => setCanEdit(p.canEdit));
    }, []);

return <button disabled={!canEdit}>{canEdit ? "Edit" : "View Only"}</button>;
}

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

export class DocumentAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        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");
    },
});
export class DocumentAgent extends Agent<Env, DocumentState> {
    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

export class CollaborativeAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        const url = new URL(ctx.request.url);
        const role = url.searchParams.get("role");
        return role === "viewer" || role === "guest";
    }

onConnect(connection, ctx) {
        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");
    }
}
export class CollaborativeAgent extends Agent<Env, CollabState> {
    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

export class MonitoringAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        const url = new URL(ctx.request.url);
        // Only admins can modify state
        return url.searchParams.get("admin") !== "true";
    }

onStateChanged(state, source) {
        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");
    },
});

```ts export class MonitoringAgent extends Agent<Env, SystemState> { shouldConnectionBeReadonly( connection: Connection, ctx: ConnectionContext, ): boolean { const url = new URL(ctx.request.url); // Only admins can modify state return url.searchParams.get("admin") !== "true"; }

onStateChanged(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" }, });