agents
Version:
A home for your AI agents
446 lines (356 loc) • 17.3 kB
Markdown
# Durable Object lifecycle
> **Experimental.** Everything exported from `agents/lifecycle` — and the
> capabilities built on it, including `Scheduler` — may change between
> releases while the composition surface stabilizes.
`agents/lifecycle` lets reusable durable capabilities work in both `Agent` and a
plain Cloudflare Durable Object. It uses composition: your class extends the
platform `DurableObject`, then constructs a lifecycle with `this`.
## Plain Durable Object
```ts
import { DurableObject } from "cloudflare:workers";
import { Lifecycle } from "agents/lifecycle";
export class MyObject extends DurableObject<Env> {
readonly lifecycle = Lifecycle.install(this);
onStart(): void {
// Runs once per in-memory object lifetime, before work is handled.
}
onRequest(request: Request): Response {
return new Response(`Hello from ${this.lifecycle.name}: ${request.url}`);
}
onAlarm(): void {
// Runs once per alarm invocation, after due jobs are driven.
}
}
```
The side-effect-named static factory constructs the lifecycle and installs the
runtime-facing `fetch`, `alarm`, `webSocketMessage`, `webSocketClose`, and
`webSocketError` handlers. Do not define forwarding versions of those methods.
Implement the semantic callbacks instead.
The expanded equivalent is available when useful:
```ts
readonly lifecycle = new Lifecycle(this);
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.lifecycle.installHandlers();
}
```
Route named objects from the outer Worker when you want URL routing:
```ts
import { routeAgentRequest } from "agents";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
}
};
```
The default URL shape is `/agents/:binding/:name`. Direct
`env.MY_OBJECT.getByName(name).fetch(request)` calls work as well.
`Agent` already constructs this lifecycle (and installs the `WebSockets`
capability for its connections). Existing Agent classes continue to override
`onStart`, `onRequest`, `onConnect`, `onMessage`, `onClose`, and `onError`
normally.
## Request call path
The lifecycle-installed `fetch` is the request handler. It offloads each
request to the installed capabilities, which act as middleware: the first
capability registered that matches the request handles it by returning a
`Response`. A capability that returns `undefined` passes the request on to
the next capability, and a request no capability claims falls through to the
host's `onRequest`.
```text
routeAgentRequest(request)
└─ named Durable Object stub.fetch(request)
└─ lifecycle-installed fetch
├─ lifecycle startup capabilities
├─ host onStart
├─ capability middleware, in registration order
│ └─ first Response handles the request
└─ host onRequest
```
A warm object skips startup but still offers every request to its middleware.
There is no `next()` today: a capability either handles a request or declines
it, and cannot wrap or observe a downstream response.
## Reusable capabilities
A capability implements only the phases it needs:
```ts
import type { DurableObjectCapability } from "agents/lifecycle";
class AuditLog implements DurableObjectCapability {
constructor(private readonly storage: DurableObjectStorage) {}
onStart(): void {
this.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
message TEXT NOT NULL
)
`);
}
onRequest({ request }: { request: Request }): Response | undefined {
if (new URL(request.url).pathname.endsWith("/health")) {
return new Response("ok");
}
}
}
```
Install it before startup:
```ts
export class MyObject extends DurableObject<Env> {
private readonly audit = new AuditLog(this.ctx.storage);
readonly lifecycle = Lifecycle.install(this).use(this.audit);
onRequest(): Response {
return new Response("application response");
}
}
```
Capabilities run in registration order. Startup runs every hook
sequentially. Request handling is middleware dispatch: it stops at the first
returned `Response`, and returning `undefined` passes the request on. A phase
failure propagates, and failed startup can be retried.
A capability declares how it claims traffic with `claims`, which defaults to
`"selective"`. A `"catch-all"` capability dispatches after every other
capability, whenever it was installed. Catch-alls are unique per dispatch
hook: Lifecycle refuses a second catch-all for `onRequest` or for
`onWebSocketUpgrade`, since it could never be reached, but one of each
coexists. The `WebSockets` capability is a catch-all for upgrades only, so
an HTTP catch-all installs beside it, and selective HTTP capabilities that
target their own routes run before both. A subclass that installs request or
upgrade middleware from its own constructor still runs first, even though
`Agent`'s constructor ran earlier.
Capabilities extending `LifecycleCapability` receive one standard service
surface: storage, readiness, startup state, the job queue, a host
invocation boundary, best-effort events, and capability routing.
Host-specific bindings, authentication, and protocol adapters remain explicit
constructor dependencies. Lifecycle never grants a capability the complete
host implicitly.
Capability hooks run outside host context, but user callbacks run through
`this.lifecycle.runInHostContext(fn)` inside the host invocation context.
Scheduler and Queue dispatch their registered callbacks through this
boundary, and a future capability that calls user code should do the same.
## The job queue
Lifecycle owns the Durable Object's queue of durable work and its single
physical alarm. A job is a serialisable callback address — the owning
capability plus a function name — with a due time and a payload. A capability
that needs future work pushes a job and implements `onJob()`:
```ts
import {
LifecycleCapability,
type LifecycleJobContext
} from "agents/lifecycle";
class Cleanup extends LifecycleCapability {
constructor() {
super("cleanup");
}
async scheduleCleanup(time: number): Promise<void> {
await this.lifecycle.jobs.push({ id: "cleanup", fn: "sweep", time });
}
async onJob({ job }: LifecycleJobContext): Promise<void> {
// job.fn === "sweep"; returning nothing completes the job.
await this.lifecycle.storage.delete("cleanup:marker");
}
}
```
The queue is ordered by timestamp, and every queue mutation re-arms the
physical alarm automatically — there is no explicit rearm call. When the
alarm fires, Lifecycle drives due jobs in due order as an event loop, then
runs host `onAlarm()`, then re-arms from queue state. Before driving any job
it arms a deadman pre-alarm so an isolate death mid-drive still wakes the
object to resume.
A job's drive result decides what happens next: returning nothing completes
and deletes it, `{ rescheduleAt }` suspends it until a future time, and
`"yield"` leaves it due so the object wakes again immediately. Lifecycle also
owns dispatch retries: a job's `retry` options bound in-process attempts,
platform-class failures (a superseded isolate after a deploy, a memory-limit
reset) preserve the job for a fresh invocation, and a terminal application
failure reaches the owner's `onJobError()`, whose result decides advancement.
A job pushed with `exclusive: true` suppresses ordinary alarm candidates
while it is pending — Agent's deferred destroy uses this so a condemned
object cannot be kept alive by other work. A `singleflight` job is skipped
while a previous run is still in flight, until it crosses its hung timeout.
The host pushes jobs through `lifecycle.jobs` and implements the same
`onJob()` hook (a host job's terminal failure completes it; the host
re-derives its jobs from durable state). Capabilities do not depend on
Scheduler or
on each other merely to receive wakes.
## Capability events
Capabilities publish best-effort telemetry through their standard service
surface. Lifecycle assigns the capability source from the stable ID passed to
`super()`:
```ts
class Cleanup extends LifecycleCapability {
constructor() {
super("cleanup");
}
reportRemoval(key: string): void {
this.lifecycle.events.emit("cleanup:remove", { key });
}
}
```
Lifecycle publishes events from a plain Lifecycle Object to the existing
`agents:*` diagnostics channels according to the event type. Delivery is
best-effort, runs outside ambient host context, and does not fail the emitting
capability when a telemetry sink throws. Persist an outbox in the capability
when delivery is part of the durable business operation.
## Capability routing
Every `LifecycleCapability` also receives `lifecycle.routes`. `toRoot()` routes
a message to the matching capability ID on the root Lifecycle; `to(address, …)`
routes to another addressed Lifecycle. Lifecycle owns the generic envelope and
dispatch. A host with child objects supplies the transport internally.
Agent uses this for facet schedules: Scheduler sends owner-scoped CRUD to the
root Scheduler and routes due callbacks back to the matching facet Scheduler.
Facet schedules live as jobs in the root's queue. Scheduler does not
implement facet traversal, and Agent exposes only one internal generic Lifecycle
route aperture.
## Explicit disposal
`lifecycle.dispose()` calls each capability's optional `dispose()` method in
reverse installation order. This phase releases live resources such as MCP
transports and listeners. It does not delete capability tables. An explicit
Lifecycle Object destruction disposes live resources once, then calls
`storage.deleteAll()` once for all shared durable state. Eviction calls neither.
## Lifecycle Object context
`agents/lifecycle` exports the `LifecycleObject` interface for a
`DurableObject` with an installed `Lifecycle` and the semantic hooks Lifecycle
dispatches. This is a host type, not the batteries-included `Agent` class
exported from `agents`.
Lifecycle establishes the `getCurrentAgent()` context only while it invokes
host hooks. Capability hooks run outside that ambient context and use their own
`this`, hook arguments, and explicitly supplied dependencies.
```ts
import { getCurrentAgent } from "agents/lifecycle";
function currentRequestOrigin(): string | undefined {
const { request } = getCurrentAgent();
return request ? new URL(request.url).origin : undefined;
}
export class MyObject extends DurableObject<Env> {
readonly lifecycle = Lifecycle.install(this);
onRequest(): Response {
return Response.json({ origin: currentRequestOrigin() });
}
}
```
Pass the concrete host class when shared host code needs its additional APIs:
```ts
const { agent: object } = getCurrentAgent<MyObject>();
```
Host context values follow the invocation:
- `onStart` and `onAlarm`: object;
- `onRequest`: object and request;
- `WebSockets` capability handlers `onConnect`: object, connection, and
upgrade request;
- `WebSockets` capability handlers `onMessage`, `onClose`, and `onError`:
object and connection.
`getConnectionTags(connection, { request })` remains argument-driven because it
already receives both values explicitly. The root `agents` package continues
to export `getCurrentAgent()` for the `Agent` class as a compatibility alias.
## WebSockets are an opt-in capability
Lifecycle itself does not model WebSockets. Hosts that want connections
install the `WebSockets` capability, which owns the subsystem end to end —
it claims upgrades, dispatches handlers inside the host invocation boundary,
and answers `getConnections()`:
```ts
import { WebSockets } from "agents/websockets";
export class MyObject extends DurableObject<Env> {
readonly webSockets = new WebSockets({
handlers: {
onConnect: (connection) => {
connection.setState({ authenticated: true });
},
onMessage: (connection, message) => {
connection.send(`echo:${message}`);
}
},
callables: new MyCallables(this)
});
readonly lifecycle = Lifecycle.install(this).use(this.webSockets);
}
```
Without the capability installed, WebSocket upgrades are declined.
### A plain host works with `useAgent`
The capability speaks the Agent protocol on every connection, so a plain
Durable Object is reachable from `useAgent` and `AgentClient` exactly like an
`Agent`:
- On connect it sends the identity frame, which resolves the client's
`ready` and `identified`, then the current state when `state` is set.
`protocol` controls this: `true` (default) for every connection, a
function to decide per connection — `false` marks it no-protocol, so it
gets no protocol text frames on connect or by broadcast but still sends
and receives ordinary messages and callables — or `false` to have the
host drive the sequence itself with `sendIdentity()` and `sendState()`.
`Agent` passes `false`, because it must decide whether a connection
belongs to a facet before any frame is sent.
- `readonly` decides per connection whether state writes over the wire are
refused; `setReadonly()` flips it later. Both flags are stored in the
connection's own state under `_cf_` keys, hidden from `connection.state`
and preserved across `setState`, so they survive hibernation. `Agent`'s
`isConnectionReadonly`, `setConnectionReadonly`, and
`isConnectionProtocolEnabled` delegate to the same storage.
- `callables` is an `RpcTarget` whose prototype methods are the host's
complete remote interface, reached through `call()` and `stub`. On the
`cf-websocket` wire the capability answers them as JSON `rpc` frames. On
the `capnweb` wire they are native Cap'n Web methods on the session root:
a returned `RpcTarget` arrives as a live stub the client can keep
calling, a `ReadableStream` streams, and chained calls pipeline. Methods
run through the host invocation boundary with the calling connection in
scope.
- `state` takes a `State` capability (from `agents/state`) and syncs it over
connections, so `useAgent().state` and `setState()` work against a plain
host:
```ts
readonly state = new State({
initialState: { count: 0 },
// The state owner decides who hears about a change.
onChanged: (_state, source) => this.webSockets.broadcastState(source)
});
readonly webSockets = new WebSockets({ state: this.state });
readonly lifecycle = Lifecycle.install(this)
.use(this.state)
.use(this.webSockets);
```
The current value is pushed to each new connection after identity. A
client's `cf_agent_state` frame goes through the `State` capability, so
the host's own `validateStateChange` decides; a readonly connection is
refused, and a rejected change is logged server-side and answered with a
generic `cf_agent_state_error`. `broadcastState(source)` pushes the
current value to every protocol-enabled connection except `source`. This
is distinct from `connection.setState()`, which is per-connection and
never leaves the host. Without the option, state is never sent or
accepted over connections.
- Any other frame goes to `handlers.onMessage`.
An `Agent` adds no new surface for this: its `@callable()`-decorated methods
are its JSON-wire interface, answered by its own message handler. They are
not mirrored onto the Cap'n Web root; an Agent that wants native calls on
`capnweb` passes a `callables` target like any other host.
### Two wires
The client chooses how frames travel:
- **`cf-websocket`** (default) — accepted with Cloudflare's WebSocket Hibernation
API. Idle clients remain connected while the Durable Object leaves memory;
when a message wakes it, the constructor and lifecycle startup run again
before `onMessage`. State needed after a wake belongs in storage or
`connection.setState()`.
- **Cap'n Web** (`?__agents_transport=capnweb`, or
`useAgent({ transport: "capnweb" })`) — protocol frames travel through one
pipe method on a Cap'n Web session whose root also carries the host's
`callables` natively. The connection is an in-memory socket and does not
hibernate: the object stays pinned while it is open, and clients reconnect
after an eviction.
Handlers are wire-agnostic. Both kinds of connection dispatch the same
`onConnect`/`onMessage`/`onClose`/`onError`, appear in `getConnections()`,
and honour `connection.close(code, reason)`.
## Native RPC
Native Durable Object RPC does not pass through `fetch`. An RPC method that
requires initialized capabilities starts the lifecycle explicitly:
```ts
async runTask(): Promise<void> {
await this.lifecycle.start();
// initialized work
}
```
Agent's internal RPC entry points already enforce this boundary.
## Object names
Use `idFromName()` or `getByName()`. The lifecycle reads the authoritative name
from `ctx.id.name` and exposes it as `lifecycle.name`.
For migration only, the lifecycle can read an existing `__ps_name` record
written by an older PartyServer release. It never writes that key. Deprecated
name headers and bootstrap methods are not supported.
If a name cannot be resolved, the error covers named addressing, updating local
Wrangler/workerd and the compatibility date, unsupported raw IDs and oversized
names, and rescheduling alarms created before 2026-03-15.