{"protocol":"clank-doc/1","frameworkVersion":"0.19.5","slug":"durable-objects","title":"Durable objects","description":"Clank durable objects are stable, stateful server side units addressed by namespace and ID. Calls for one ID are serialized, state commits atomically, leases fence stale runtimes, and different IDs can run concurrently. State, alarms, idemp","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/durable-objects","source":"docs/durable-objects.md","headings":["Define a namespace","Open and call the runtime","State and execution contract","Durable alarms","Evolve state with migrations","Live server subscriptions","Expose selected methods to an app MCP server","Inspect and operate","Placement boundary"],"tableOfContents":[{"id":"define-a-namespace","title":"Define a namespace","level":2},{"id":"open-and-call-the-runtime","title":"Open and call the runtime","level":2},{"id":"state-and-execution-contract","title":"State and execution contract","level":2},{"id":"durable-alarms","title":"Durable alarms","level":2},{"id":"evolve-state-with-migrations","title":"Evolve state with migrations","level":2},{"id":"live-server-subscriptions","title":"Live server subscriptions","level":2},{"id":"expose-selected-methods-to-an-app-mcp-server","title":"Expose selected methods to an app MCP server","level":2},{"id":"inspect-and-operate","title":"Inspect and operate","level":2},{"id":"placement-boundary","title":"Placement boundary","level":2}],"markdown":"# Durable objects\n\nClank durable objects are stable, stateful server-side units addressed by namespace and ID. Calls\nfor one ID are serialized, state commits atomically, leases fence stale runtimes, and different IDs\ncan run concurrently. State, alarms, idempotency results, migrations, and live revision notices all\nlive in the application's isolated SQLite database, so ordinary Clank backups and restores include\nthem automatically.\n\nUse a durable object when behavior naturally belongs to one identity: a cart, game session,\ncollaborative document coordinator, rate limiter, account balance, agent session, or device. Use a\nnormal backend table when records only need transactional CRUD. Use [durable jobs](jobs-and-cron.md)\nwhen work should run later or retry independently of a caller. A durable object can schedule its\nown one-at-a-time alarm, but it is not a general queue.\n\n## Define a namespace\n\nDefinitions are dependency-free TypeScript contracts. The state, arguments, and results use the\nsame runtime schemas as backend actions, so the runtime and agent manifest cannot drift.\n\n```ts\nimport { defineDurableObject, s } from \"@clank.run/framework/durable-objects\";\n\nexport const Cart = defineDurableObject({\n  name: \"carts\",\n  description: \"One serialized shopping cart per signed-in account.\",\n  state: s.object({\n    items: s.array(s.object({\n      productId: s.string({ min: 1, max: 100 }),\n      quantity: s.number({ integer: true, min: 1, max: 100 }),\n    })),\n  }),\n  initial: () => ({ items: [] }),\n  methods: ({ query, mutation }) => ({\n    read: query({\n      args: {},\n      returns: s.object({\n        items: s.array(s.object({\n          productId: s.string(),\n          quantity: s.number({ integer: true }),\n        })),\n      }),\n      description: \"Read the current cart.\",\n      agent: { title: \"Read cart\", idempotent: true },\n      handler: ({ storage }) => storage.get(),\n    }),\n    add: mutation({\n      args: {\n        productId: s.string({ min: 1, max: 100 }),\n        quantity: s.number({ integer: true, min: 1, max: 100 }),\n      },\n      returns: s.number({ integer: true, min: 1 }),\n      description: \"Add a quantity of one product.\",\n      agent: { title: \"Add cart item\", idempotent: true },\n      handler: ({ storage }, input) => {\n        const current = storage.get();\n        const existing = current.items.find((item) => item.productId === input.productId);\n        const items = existing\n          ? current.items.map((item) => item.productId === input.productId\n              ? { ...item, quantity: item.quantity + input.quantity }\n              : item)\n          : [...current.items, input];\n        storage.set({ items });\n        return items.find((item) => item.productId === input.productId)!.quantity;\n      },\n    }),\n  }),\n});\n```\n\nNamespace names are permanent storage identities. Renaming `carts` creates a different namespace;\nit does not rename existing data. Object IDs contain 1–256 letters, numbers, `.`, `_`, `:`, `@`, or\n`-`. Put authorization identity in an ID only when that value is already safe to retain as a\ndatabase key; prefer opaque application IDs over email addresses or other personal data.\n\nMethods can be nested to create readable paths such as `items.add`. A method is invisible to agents\nunless it has an explicit `agent` object. `agent: false` and omitted metadata are equivalent.\n\n## Open and call the runtime\n\nOpen the runtime with the same SQLite database used by the application. Obtaining a stub is inert;\nthe first call activates and initializes the object.\n\n```ts\nimport {\n  defineDatabase,\n  openDurableObjects,\n  openSQLite,\n} from \"@clank.run/framework\";\nimport { Cart } from \"./objects.ts\";\n\nconst schema = defineDatabase({});\nconst database = await openSQLite(schema, {\n  path: process.env.CLANK_DATABASE ?? \"app.sqlite\",\n});\nconst objects = openDurableObjects({ cart: Cart }, { database });\n\nconst cart = objects.get(Cart, accountId);\nawait cart.call(Cart.methods.add, {\n  productId: \"keyboard\",\n  quantity: 1,\n}, {\n  idempotencyKey: `checkout-request:${requestId}`,\n});\n\nconst state = await cart.call(Cart.methods.read, {});\nconst detailed = await cart.invoke(Cart.methods.read, {});\n// detailed = { value, revision, deduplicated }\n```\n\n`call()` returns the method value. `invoke()` also returns the committed object revision and whether\na mutation result came from the idempotency ledger. An idempotency key is scoped to one namespace\nand object ID. Reusing it with different method arguments fails closed. Successful results are\nretained for 24 hours by default and stop deduplicating at that exact deadline even when no\nmaintenance call ran. Reinitializing a deleted object clears its prior incarnation's ledger.\nCallers must not treat that bounded window as a permanent business uniqueness constraint.\n\nDo not call a durable object from inside `database.transaction()`. A call can await and obtain a\ndistributed lease, while Clank database mutation handlers are intentionally synchronous. Call the\nobject before or after the database transaction, or enqueue a durable job transactionally when the\ntwo operations need a recoverable handoff.\n\n## State and execution contract\n\nEvery method receives an immutable current snapshot through `storage.get()`. Mutation methods can:\n\n- `storage.set(next)` to validate and replace all state;\n- `storage.update(current => next)` to derive state synchronously;\n- `storage.deleteAll()` to commit a tombstone after success;\n- `storage.getAlarm()` to inspect the current alarm; and\n- `storage.setAlarm(epochMs | Date | null)` to replace or clear it.\n\nState changes remain staged while the handler runs. A thrown error, invalid result, timeout,\ncancellation, lost lease, invalid state, or oversized value commits none of the handler's staged\nstate. Deletion also commits only after success. A later call to a deleted stable ID creates its\ninitial state again while preserving a monotonic object revision.\n\nCalls for one namespace/ID enter a local FIFO lane and acquire a renewable database lease. Another\nruntime sharing that SQLite file waits. Settlement compares the random token, runtime owner, and\nobject revision, so a stale handler cannot commit after lease expiry. Calls to different IDs do not\nshare a lane and may run concurrently.\n\nThis gives exactly one accepted state transition, not exactly-once external side effects. A method\ncan call a remote API and lose its lease before committing local state. Use the call's stable\nidempotency key with the remote provider too, or move failure-prone delivery into a durable job.\nHonor `context.signal` in fetches and long-running work.\n\nDefaults bound state to 1 MiB, arguments and results to 256 KiB, UTF-8 error diagnostics to 16 KiB,\nretained identities to 100,000 per namespace, retained idempotency results to 10,000 per object,\nleases to 30 seconds, and acquisition to 30 seconds. Tombstones count toward the identity ceiling,\nso repeatedly creating and deleting attacker-selected IDs cannot evade it. Expired retry records\nare removed only from namespaces registered by that runtime, and maintenance failure is reported\nwithout changing an already committed method result. `OpenDurableObjectsOptions` can lower or\nraise these defaults within hard ceilings. State is JSON—not class instances, functions, promises,\ncyclic structures, or binary buffers. Store large bytes in [object storage](object-storage.md) and\nretain only the verified key and metadata in object state.\n\n## Durable alarms\n\nAn object owns at most one alarm. Scheduling a new time replaces the old one. The handler gets the\nsame mutation storage and fencing as an ordinary call:\n\n```ts\nexport const Session = defineDurableObject({\n  // state, initial, and methods...\n  alarm: {\n    description: \"Expire an inactive session.\",\n    retry: {\n      maxAttempts: 5,\n      initialDelayMs: 1_000,\n      factor: 2,\n      maxDelayMs: 15 * 60_000,\n    },\n    timeoutMs: 15 * 60_000,\n    handler({ storage }) {\n      storage.update((state) => ({ ...state, expired: true }));\n    },\n  },\n});\n\nconst scheduler = objects.startAlarmScheduler();\n```\n\nThe scheduler claims due IDs through their ordinary object lease. A successful alarm clears the\ncurrent alarm unless the handler schedules another. Failures retain a bounded error and reschedule\nwith exponential backoff; an exhausted alarm is parked with diagnostics instead of looping\nforever. `runAlarmsOnce()` is available for deterministic process loops and tests. Call\n`await scheduler.close()` and then `await objects.close()` during graceful shutdown.\n\nRun one or more schedulers only where every process shares the same application SQLite file. The\nlease makes duplicate scheduler processes safe. An app that never defines alarms does not need a\nscheduler.\n\n## Evolve state with migrations\n\nNew objects begin at the definition's current version. Existing objects migrate when first\nactivated. Every intermediate migration is required, synchronous, deterministic, and validated by\nthe current state schema before commit:\n\n```ts\nexport const Cart = defineDurableObject({\n  name: \"carts\",\n  version: 2,\n  state: s.object({\n    items: s.array(cartItem),\n    currency: s.string(),\n  }),\n  initial: () => ({ items: [], currency: \"USD\" }),\n  migrations: {\n    2: (old) => ({ ...old as object, currency: \"USD\" }),\n  },\n  methods: ({ query }) => ({\n    read: query({ args: {}, handler: ({ storage }) => storage.get() }),\n  }),\n});\n```\n\nDo not edit an already released migration or reuse a version for a different shape. A runtime whose\ndefinition is older than stored state fails with `SCHEMA_TOO_NEW`; plan rolling releases so old code\ncan stop receiving object calls before the first new-version activation. Back up the application\ndatabase before a destructive migration just as with normal schema changes.\n\n## Live server subscriptions\n\n`stub.subscribe(listener)` immediately receives the current snapshot or `null`, then observes\ncommitted changes through Clank's ordinary database revision journal. It works across application\nprocesses sharing the SQLite file and returns an unsubscribe function:\n\n```ts\nconst stop = objects.get(Cart, accountId).subscribe((snapshot) => {\n  console.log(snapshot?.revision, snapshot?.state);\n});\n```\n\nThis is a trusted server API and exposes the complete object state. Do not forward snapshots to a\nbrowser without application authorization and response shaping. For ordinary browser UI, expose a\ntyped backend query and mutation so auth, ownership, live cache partitioning, and MCP stay on the\nsame reviewed boundary.\n\n## Expose selected methods to an app MCP server\n\n`durableObjectMcpTools()` converts only methods with explicit `agent` metadata. Authorization is\nmandatory and runs for the exact authenticated agent context, namespace, object ID, method, and\nrequest before arguments reach the object:\n\n```ts\nimport {\n  createMcpServer,\n  durableObjectMcpTools,\n} from \"@clank.run/framework\";\n\nconst tools = durableObjectMcpTools(objects, Cart, {\n  async authorize(agent, attempt) {\n    return attempt.id === agent.accountId;\n  },\n});\n\nconst mcp = createMcpServer({\n  name: \"shop\",\n  tools,\n  authenticate: authenticateAgentRequest,\n});\n```\n\nTools wrap input as `{ id, input, idempotencyKey? }`, return `{ value, revision, deduplicated }`,\nand request `agent:read` for queries or `agent:write` for mutations. Destructive, idempotent,\nread-only, and open-world hints come from the method contract. Registration does not grant access,\nand a general account membership check is insufficient when only some object IDs are visible.\n\nUse `durableObjectManifest()` when an operator or generator needs the namespace, state version,\nJSON Schemas, methods, alarms, and agent annotations without opening a runtime.\n\n## Inspect and operate\n\n`stub.inspect()` reads one trusted server snapshot. `namespace.list({ prefix, limit })` returns at\nmost 1,000 non-deleted objects in stable ID order. Neither is an end-user API. `diagnostics()`\nreturns only aggregate namespace, object, alarm, lease, call, and subscription counts; it never\ncontains IDs or state.\n\nThe runtime uses the same `clank_` reserved SQLite namespace and global change journal as other\nfirst-party services. Application migrations must not modify these tables. Database backup,\nrestore, preview policy, filesystem durability, and per-project isolation apply exactly as they do\nto backend documents.\n\n## Placement boundary\n\nThe built-in driver coordinates multiple processes that share one SQLite file on one durable POSIX\nvolume. It is not a multi-region consensus database. Local Clank placement and statefully pinned\nprovider placement satisfy that contract; independently replicated files and network filesystems\ndo not. An infrastructure adapter may move the complete app database after fencing the old\ngeneration, but it may not run writable copies on two nodes.\n\nThat deliberate boundary keeps the application API small and dependency-free while preserving a\nclear path to a future transactional shared-store driver. The namespace/method/state contract does\nnot depend on SQLite-specific application code.\n"}