- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Locks
A lock makes one worker create the sandbox for a thread. A second run for that thread waits, then uses the same sandbox.
Only one caller at a time runs the critical section for a key.
Quick setup#
1. Provide a lock store#
Add withLocks to the middleware array. Put it before the middleware that uses the lock:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
import { openaiText } from '@tanstack/ai-openai'
import { onePrepPerThread } from './one-prep-per-thread'
// Create the store once, so all requests share it.
// For more than one process, pass a distributed LockStore.
const locks = new InMemoryLockStore()
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
middleware: [withLocks(locks), onePrepPerThread],
})
return toServerSentEventsResponse(stream)
}2. Use the lock in your middleware#
Read the store with getLocks(ctx). Then wrap the critical section in withLock:
// one-prep-per-thread.ts
import { defineChatMiddleware } from '@tanstack/ai'
import { LocksCapability, getLocks } from '@tanstack/ai/locks'
export const onePrepPerThread = defineChatMiddleware({
name: 'one-prep-per-thread',
requires: [LocksCapability],
async onStart(ctx) {
const locks = getLocks(ctx)
await locks.withLock(`thread:${ctx.threadId}`, async (signal) => {
// Only one run for this thread is in here at a time.
// Pass `signal` to cancellable work when you use a lease-backed store.
console.log(`start ${ctx.threadId}`)
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log(`end ${ctx.threadId}, lost lock: ${signal.aborted}`)
})
},
})When two runs for the same thread start at the same time, the server console shows start, end, start, end. The two critical sections never overlap.
How it works#
- withLocks(store) is a middleware. It provides the LocksCapability in its setup hook. See Capabilities.
- withLocks does not lock a whole chat() turn. It only gives the store to later middleware. A consumer calls withLock when it needs the lock.
- The capability is the same object everywhere. So each later middleware that reads LocksCapability sees the same store, including @tanstack/ai-sandbox.
When you use a sandbox, put withLocks before withSandbox:
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 the providers
]When you need a lock#
Use a lock when more than one process or isolate can enter the same critical section for the same key:
- Sandbox resume-or-create (withSandbox / ensure). Two runs for the same thread must not both create a provider sandbox. See Sandbox Instance Durability.
- Your own middleware. Any work with more than one writer that you want to run one at a time across workers. For example, "one active job per thread".
You do not need a lock for:
- Local development with one process. InMemoryLockStore is enough there.
- Saving chat state. Stores and withPersistence do that. See Persistence Controls.
Advanced#
The contract#
import type { LockStore } from '@tanstack/ai/locks'
declare const locks: LockStore
// Mutual exclusion for a key. Lease-backed stores abort `signal` on loss.
await locks.withLock('thread:abc', async (signal) => {
// Critical section. Pass `signal` to cancellable work when you use leases.
void signal
})| Piece | Role |
|---|---|
| LockStore | Interface: withLock(key, fn) |
| withLocks(store) | Chat middleware that provides LocksCapability |
| InMemoryLockStore | Implementation for one process (a promise chain per key) |
| defineLock(lock) | Types your own LockStore inline |
| getLocks / provideLocks | Low-level capability accessors for custom middleware |
InMemoryLockStore is correct in one process only:
- It runs callers for the same key one at a time.
- When a critical section throws, the next caller still runs.
- It never aborts its signal, because a process cannot lose its own lock.
Implement a lock#
Wrap your own mutual-exclusion primitive with defineLock. It checks the object against the contract inline, so you get autocomplete and need no : LockStore annotation.
- Acquire the key.
- Run fn.
- Release the key when fn settles, also when it 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). A production store must meet the rules in the next section.
Distributed locks and leases#
A deployment with more than one instance needs a distributed store, for example a Durable Object or Redis. A good store:
- Lets one owner at a time hold each key.
- Uses leases (or something like them), so an owner that crashes cannot block the key forever.
- Passes an AbortSignal into fn. When the lease is lost, it aborts the signal. The callback then stops starting work that others can see, and passes the signal to cancellable dependencies.
A callback that ignores signal still type-checks, because () => Promise<T> is assignable. But a lease-backed store cannot protect you if the critical section keeps changing data after the abort.
To test your store:
- The chat store testkit has no lock conformance suite. Write your own tests for concurrency, release on throw, and lease expiry.
- The Cloudflare Durable Object recipe is in the ai-persistence/build-cloudflare-adapter agent skill. It is a file that your app owns, not a published package.
On Redis, upstashLocks() from @upstash/agentkit-tanstack-ai is a published third-party LockStore on Upstash Redis. It renews its lease while fn runs, and it aborts signal if a renewal fails. See Upstash.
Provide a store without withLocks#
If you already own a custom middleware, call provideLocks(ctx, store) in its setup hook, and list LocksCapability in its provides. Later middleware then reads the store with getLocks(ctx).