When you build a multi-tenant agent platform, the first architectural question is where conversation state lives. The default answer is a shared database keyed by conversation ID, with a stateless worker reading and writing rows. It works, and it is what most teams reach for. I went the other way: one Durable Object per agent, holding conversation state, memory and a task ledger in its own embedded SQLite.
What does a single-tenant object actually buy you?
A Durable Object is a single-threaded, addressable actor with strongly consistent storage attached. Requests for the same agent ID land on the same object, in order. That property alone removes an entire class of problem:
- No optimistic locking around conversation appends.
- No read-your-writes race between a tool result and the next turn.
- No distributed lock to stop two concurrent messages double-spending a rate budget.
The concurrency model is the feature. You get serialisation because the platform gives it to you, not because you built a queue. Cloudflare documents the guarantee directly: each object has a single-threaded execution model, and storage operations are backed by a SQLite database per object.
The task ledger
Conversation history alone is not enough state for an agent that does real work. Long-running tool calls need to survive an eviction, and a user needs to be able to ask what happened. So every unit of work goes into a ledger table inside the object.
The class extends DurableObject from cloudflare:workers and takes the SQLite handle off
the context in the constructor. That handle is
ctx.storage.sql, and
exec() takes a query plus positional bindings:
import { DurableObject } from 'cloudflare:workers'
export class Agent extends DurableObject {
sql: SqlStorage
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
this.sql = ctx.storage.sql
this.sql.exec(`
CREATE TABLE IF NOT EXISTS tasks(
id TEXT PRIMARY KEY,
parent_id TEXT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
input TEXT NOT NULL,
output TEXT,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER
);
`)
}
recordTask(id: string, parentId: string | null, kind: string, input: unknown) {
this.sql.exec(
`INSERT INTO tasks (id, parent_id, kind, status, input, created_at)
VALUES (?, ?, ?, 'running', ?, ?)`,
id,
parentId,
kind,
JSON.stringify(input),
Date.now()
)
}
// A delegation tree falls out of one recursive query.
tree(rootId: string) {
return this.sql
.exec(
`WITH RECURSIVE walk(id) AS (
SELECT id FROM tasks WHERE id = ?
UNION ALL
SELECT t.id FROM tasks t JOIN walk w ON t.parent_id = w.id
)
SELECT t.* FROM tasks t JOIN walk USING (id) ORDER BY t.created_at`,
rootId
)
.toArray()
}
}
The class needs a SQLite-backed migration in the Wrangler config, not the older key-value class binding:
[[durable_objects.bindings]]
name = "AGENT"
class_name = "Agent"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Agent"]
Because parent_id is there, a delegation tree is one recursive query. When someone asks why
an agent answered the way it did, you can show them the actual tree of sub-agent calls with
token counts on each node, rather than reconstructing it from log lines.
Delegation: synchronous or ephemeral
The orchestrator inside an agent object has two ways to hand off work.
For anything short, it calls the sub-agent synchronously and blocks the turn. Simple, and the result lands in the same ledger write.
For anything long, it spawns an ephemeral queue object: a separate Durable Object created for
that one job, which does the work, writes back to the parent’s ledger, and then deletes
itself. The parent stays responsive. The user gets a task ID immediately and a proactive
message when it finishes. Work that must survive the request uses
ctx.waitUntil or an
alarm rather than a dangling
promise.
The rule I settled on is a time budget, not a task type. If the estimated work exceeds a few seconds, it goes ephemeral.
The costs, honestly
This is not free.
Storage is per object. A million agents means a million small SQLite databases. Cheap per unit, but you need a lifecycle policy, and you need it before you have a million of them. Cloudflare publishes the per-object limits, including database size, in the Durable Objects limits page; read them before you design around the model.
Cross-agent queries are hard. Asking “how many tokens did this tenant spend today” cannot be a single query against a shared table any more. I stream metering events out to an analytics store and treat the objects as the source of truth for behaviour, not for reporting.
Cold starts exist. An object that has not been touched in a while pays a wake-up cost. For a chat interface it is unnoticeable. For a latency-critical synchronous API it might not be.
For an agent platform, where the workload is naturally partitioned by agent and the hard problems are ordering and state rather than analytics, the trade has been clearly worth it.
Sources
- Durable Objects overview, Cloudflare
- Durable Objects SQL storage API, Cloudflare
- Access Durable Objects storage, Cloudflare
- Durable Objects alarms, Cloudflare
- Durable Objects limits, Cloudflare
Elson Tan