Readonly connections·续

// Viewer client (readonly) const viewerAgent = useAgent({ agent: "MonitoringAgent", name: "system", query: { admin: "false" }, onStateUpdateError: (error) => { console.log("Viewer cannot modify state"); }, });


### Dynamic permission changes

```js
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,
        };
    }
}
export class GameAgent extends Agent&lt;Env, GameState> {
    @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 (
        &lt;div>
            &lt;button onClick={() => agent.call("joinAsPlayer")} disabled={canEdit}>
                Join as Player
            &lt;/button>

&lt;button
                onClick={() => agent.call("startSpectatorMode")}
                disabled={!canEdit}
            >
                Switch to Spectator
            &lt;/button>

&lt;div>{canEdit ? "You can modify the game" : "You are spectating"}&lt;/div>
        &lt;/div>
    );
}

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(). The same mechanism is used by protocol message control — both flag coexist safely in the attachment. 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

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

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:

export class MyAgent extends Agent {
    @callable()
    async processOrder(orderId) {
        await sendConfirmationEmail(orderId); // runs even for readonly connections
        await chargePayment(orderId); // runs too
        this.setState({ ...this.state, orders: [...this.state.orders, orderId] }); // throws
    }
}
export class MyAgent extends Agent&lt;Env, State> {
    @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
    }
}

To avoid this, either check permissions before side effects or structure your code so the state write comes first:

export class MyAgent extends Agent {
    @callable()
    async processOrder(orderId) {
        // 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);
    }
}
export class MyAgent extends Agent&lt;Env, State> {
    @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);
    }
}

Best practices

Combine with authentication

export class SecureAgent extends Agent {
    shouldConnectionBeReadonly(connection, ctx) {
        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;
    }
}
export class SecureAgent extends Agent&lt;Env, State> {
    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

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

function EditButton() {
    const [canEdit, setCanEdit] = useState(false);
    const agent = useAgent({
        /* ... */
    });

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

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

Log access attempts

export class AuditedAgent extends Agent {
    onStateChanged(state, source) {
        if (source !== "server") {
            this.audit({
                action: "state_update",
                connectionId: source.id,
                readonly: this.isConnectionReadonly(source),
                timestamp: Date.now(),
            });
        }
    }
}
export class AuditedAgent extends Agent&lt;Env, State> {
    onStateChanged(state: State, source: Connection | "server") {
        if (source !== "server") {
            this.audit({
                action: "state_update",
                connectionId: source.id,
                readonly: this.isConnectionReadonly(source),
                timestamp: Date.now(),
            });
        }
    }
}

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

Was this helpful?

YesNo

On this page

Readonly connections·续 - 图1Docs

{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/#page","headline":"Readonly connections","description":"Restrict WebSocket clients to view-only access so they receive state updates without modifying Agent state.","url":"https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/","inLanguage":"en","image":"https://developers.cloudflare.com/agents/runtime/communication/readonly-connections/og.png?v=070be9463b8b07b0","dateModified":"2026-06-03","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}


description: Route HTTP and WebSocket requests to Agents SDK instances using routeAgentRequest() and getAgentByName(). title: Routing image: https://developers.cloudflare.com/agents/runtime/communication/routing/og.png?v=4f8fb7776b8e581c


Skip to content

Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/agents/llms.txt
Use this file to discover all available pages before exploring further.