Agents SDK foundations

Sync state and call approved methods

Use setState for persisted synchronized state and @callable methods for validated client-to-agent RPC.

An agent instance has state, and connected clients need to see it change. The SDK solves both with one mechanism.

Read the state through this.state and update it with this.setState(). The SDK persists the new state and broadcasts it to every connected client:

export class ProjectAgent extends Agent {
  initialState = { name: 'untitled', tasks: [] }

  addTask(title) {
    this.setState({
      ...this.state,
      tasks: [...this.state.tasks, { title, done: false }],
    })
  }
}

Every browser holding a WebSocket to this instance receives the update. The client hooks expose it as a reactive value, so the UI follows without polling.

One rule saves you from a subtle bug. Important state belongs in Agent state or SQLite, never in ordinary class fields. Durable Objects hibernate when idle, which means the object is unloaded from memory and recreated on the next request. A plain this.pendingItems = [] runs fresh on that wake, and whatever you stored there is gone. this.state survives because it lives in storage, not in memory.

Expose methods, not trust

Clients also need to trigger actions. Expose only methods marked as callable, validate every argument, and authorize the connection for the instance:

@callable()
async renameProject(name) {
  if (typeof name !== 'string' || name.length < 1 || name.length > 80) {
    throw new Error('invalid name')
  }
  this.setState({ ...this.state, name })
}

The client hooks give you a typed stub over the WebSocket, so calling renameProject from the browser feels like a local function call. That convenience is exactly why the validation matters. The arguments arrive from an untrusted client, no matter how typed the stub looks in your editor. TypeScript types are erased at runtime. A hostile client can send anything.

Methods without the decorator stay internal. That’s your authorization boundary at the method level: a client can only invoke what you deliberately exported.

Now put both halves together. Add a callable renameProject and reject invalid names and unauthorized users. Test it three ways. A valid rename updates every connected client. An empty name throws without touching state. A connection for a different user’s instance never reaches the method at all. Keep a second browser window open during the valid rename. Seeing the change arrive without a refresh is the moment the model clicks.

Lesson completed