Locks answer a different question from persistence:
| Concern | Question | Seam |
|---|---|---|
| State | What is durable? | Stores + withPersistence |
| Locks | Who may run this critical section right now? | LockStore + withLocks |
They live in @tanstack/ai as a middleware capability — not in @tanstack/ai-persistence, and never as a key on AIPersistence.stores.
Use locks when more than one process or isolate might enter the same critical section for the same key:
You do not need locks for:
import { chat } from '@tanstack/ai'
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
import { grokBuildText } from '@tanstack/ai-grok-build'
import type { ModelMessage } from '@tanstack/ai'
const messages: Array<ModelMessage> = [{ role: 'user', content: 'hi' }]
chat({
adapter: grokBuildText('grok-build'),
messages,
middleware: [
// Single process. Multi-instance: pass a distributed LockStore instead.
withLocks(new InMemoryLockStore()),
// later middleware can getLocks(ctx) / withSandbox will use the same token
],
})Capability identity is by object reference. withLocks provides the shared LocksCapability from core; any later middleware that reads that token (including @tanstack/ai-sandbox) sees the same store.
Typical order when composing with sandbox:
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
import { withSandbox } from '@tanstack/ai-sandbox'
import type { SandboxDefinition } from '@tanstack/ai-sandbox'
declare const sandbox: SandboxDefinition
const middleware = [
withLocks(new InMemoryLockStore()),
withSandbox(sandbox), // after providers
]import type { LockStore } from '@tanstack/ai/locks'
declare const locks: LockStore
// Mutual exclusion for a key; lease-backed impls abort `signal` on loss.
await locks.withLock('thread:abc', async (signal) => {
// critical section — pass `signal` to cancellable work when using leases
void signal
})| Piece | Role |
|---|---|
| LockStore | Interface: withLock(key, fn) |
| withLocks(store) | Chat middleware that provides LocksCapability |
| InMemoryLockStore | Process-local implementation (promise chain per key) |
| getLocks / provideLocks | Low-level capability accessors for custom middleware |
InMemoryLockStore is correct within one process only. It serializes callers for the same key, does not poison the chain when a critical section throws, and never aborts its signal (ownership cannot be lost in-process).
Wrap your own mutual-exclusion primitive with defineLock. It types the object against the contract inline (autocomplete, no : LockStore annotation). Acquire the key, run fn, and release when fn settles (whether it resolves or throws):
import { defineLock } from '@tanstack/ai/locks'
// Your distributed primitive. `acquire` waits until the key is free and returns
// a `release` (plus, for leases, a `signal` that fires when ownership is lost).
import { acquire } from './my-lock-backend'
export const locks = defineLock({
async withLock(key, fn) {
const { release, signal } = await acquire(key)
try {
return await fn(signal)
} finally {
release()
}
},
})Wire it as middleware with withLocks(locks). The requirements a production store must meet are covered below.
Multi-instance deployments need a distributed implementation (Durable Object, Redis, etc.). A good store:
Callbacks that ignore signal still type-check (() => Promise<T> is assignable), but lease-backed backends cannot protect you if the critical section keeps mutating after abort.
There is no shared lock conformance suite in the chat store testkit — write targeted tests for concurrency, release-on-throw, and lease expiry for your backend. The Cloudflare Durable Object recipe lives in the ai-persistence/build-cloudflare-adapter agent skill (app-owned file, not a shipped package).
import { defineChatMiddleware } from '@tanstack/ai'
import { LocksCapability, getLocks } from '@tanstack/ai/locks'
const serializePerThread = defineChatMiddleware({
name: 'serialize-per-thread',
requires: [LocksCapability],
async onStart(ctx) {
const locks = getLocks(ctx)
await locks.withLock(`thread:${ctx.threadId}`, async (signal) => {
// critical section — honor `signal` under lease-backed locks
void signal
})
},
})Or provide without withLocks by calling provideLocks in your own setup hook if you already own a custom middleware.