Middleware

Locks

Locks answer a different question from persistence:

ConcernQuestionSeam
StateWhat is durable?Stores + withPersistence
LocksWho 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.

When you need them

Use locks when more than one process or isolate might enter the same critical section for the same key:

  • Sandbox resume-or-create (withSandbox / ensure) — two concurrent runs for the same thread must not both create a provider sandbox. See Sandboxes.
  • Your own middleware — any multi-writer work you want to serialize across workers (e.g. a custom “one active job per thread” gate).

You do not need locks for:

  • Single-process local dev (optional: InMemoryLockStore is fine).
  • Ordinary chat state durability — that is stores, not mutexes.
  • Automatically locking an entire chat() turn — withLocks only provides the capability; consumers call withLock when they need exclusion.

Wire it up

ts
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:

ts
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
]

The contract

ts
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
})
PieceRole
LockStoreInterface: withLock(key, fn)
withLocks(store)Chat middleware that provides LocksCapability
InMemoryLockStoreProcess-local implementation (promise chain per key)
getLocks / provideLocksLow-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).

Implement a lock

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):

ts
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.

Distributed locks and leases

Multi-instance deployments need a distributed implementation (Durable Object, Redis, etc.). A good store:

  1. Serializes owners per key.
  2. Uses leases (or equivalent) so a crashed owner cannot block forever.
  3. Passes an AbortSignal into fn; when the lease is lost, abort so the callback stops starting externally visible work and passes the signal to cancellable dependencies.

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).

Consume in custom middleware

ts
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.

See also