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-sidesetState()nor via a@callable()method that callsthis.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 acf_agent_state_errormessage. Handle it with theonStateUpdateErrorcallback. @callable()methods — the RPC call rejects with an error. Handle it with atry/catcharoundagent.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" }, });
