As the world moves to getting things done through agents, the winners are going to be the products that integrate AI with team-based collaboration.
A Durable Session is a state management pattern that naturally makes AI and agentic apps collaborative.
This post introduces the Durable Session pattern and shows how you can implement it using Durable Streams and TanStack DB.
🤝 ✨ Durable Sessions demo
See the TanStack AI - Durable Sessions demo video and source code.
Getting things done with agents
When I sit down to get something done, I increasingly reach for an agent. Not because they're magic or perfect but because, on balance, they boost my productivity.
For me, as a technical startup founder, adapting to this new reality is a challenge. I spent the last 20 years developing craft skills that are now being, somehow, both amplified and commoditized at the same time.
For people with less technical craft skills or who are less used to adaptation, the challenge to evolve is even harder. For companies, made up of normal people working in legacy management structures, the challenge is existential.
Teams, departments, whole industries — why do they even exist any more?
Right place, right time
As a software engineer and a product builder, if you've ever wanted to be in the right place at the right time, you are right now.
You have the opportunity to disrupt and replace whole swathes of previous-generation software. Right as the market is expanding wildly, as software eats into the rump of white-collar payroll spend.
Cracking the enterprise
It's a massive economic shift, with massive customers feeling massive pain.
If you can serve their transformation, as they scramble to up-skill and transform their workforce, there's no limit to what you can achieve.
However, if you build on a single user paradigm, it's not going to cut it. You're not going to win the procurement battle. You're not going to land and expand. You're not going to benefit from product-led growth.
“Today, AI works impressively for individuals but disappointingly for organizations. Closing that gap requires not just more context, but treating agents as social participants in the multiplayer systems they aim to disrupt.”
— Collaborative Intelligence, Aatish Nayak - Product @ Scale AI, Harvey
Instead, to crack the enterprise, you need to support the same kind of team-based collaboration that the software you're replacing was based on. That means people working together on agentic sessions.
With collaborative AI
At the micro-level it's shared sessions, collaborative prompt editing and multi-user token streams. At the mid-level it's audit logs and history. Compliance departments reviewing context and artifacts.
At the macro-level it's weaving your software into the fabric of the enterprise. As the research, the planning, the prompts, the sessions and the outputs get threaded into the collaboration, management, reporting, access control and governance processes that the enterprise runs on.
But how do you build and adapt AI and agentic products to support this kind of collaboration? How do you unlock this level of adoption?
Moving beyond single-user
You need to move beyond the default single‑user <> single‑agent interaction paradigm of today's AI SDKs. To a multi‑user <> multi‑agent paradigm that naturally supports both real-time and asynchronous collaboration.
Evolving the interaction paradigm
The default paradigm of most AI SDKs, like the Vercel AI SDK and TanStack AI, is single‑user <> single‑agent.
You have a chat interface with a sendMessage action:
const { messages, sendMessage } = useChat({...})When the user types their prompt and hits enter, you call sendMessage, it adds the message to some local state and then blocks the UI whilst it makes an API request to a backend endpoint. That constructs an LLM instruction request and streams the response back to the client.
async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai(`gpt-4o`),
prompt: convertToModelMessages(messages),
abortSignal: req.signal,
})
return result.toUIMessageStreamResponse()
}The client then consumes the response stream, writes out the agent response one chunk at a time and unblocks the chat thread once the active generation is finished.


The whole flow is designed around request <> response. The request is a single user message and the response is a single assistant message.
Support for collaboration
This fails to support collaboration in so many ways. The local message state isn't shared, so it doesn't sync across users/tabs/devices. The request response model (and blocking the UI) assumes there’s only one user waiting on a response from a one agent.
What we need instead is an interaction paradigm that doesn't bake in this single user <> single agent assumption. That allows multiple users to work in multiple tabs and devices, collaborating with other users and agents on the same session in real-time. So users can join the session half way through and other users can go back to it later.
Persistence and addressability
That means the session needs to be persistent and addressable.
This is generally left up to application developers. If you want multiple users to join the same session or multiple agents to register with it, you have to dig into application-specific routing, storage and authentication.
For example, the Vercel AI SDK useChat hook returns a setMessages function you can use to populate the chat thread:
export default function Chat({ sessionId }) {
const { messages, sendMessage, setMessages } = useChat({
id: sessionId
})
useEffect(() => {
somehow
.fetchMessageHistory(sessionId)
.then(setMessages)
}, [sessionId, setMessages])
// ...
}How you use that sessionId to fetch the message history is up to you. How other processes or apps find, consume and subscribe to it is up to you.
Whereas what's needed is a standard protocol for persistence and addressability of streams and sessions. So initial message hydration is taken care of and external software can plug-in, audit, monitor and integrate (in the same way that today's enterprise systems monitor and audit web service APIs).
Composable sync primitives
That's what we've been building at Electric. A suite of composable sync primitives that give you durable state that's persistent, addressable and subscribable, including:
Postgres Sync
Postgres Sync syncs data out of Postgres into client apps.
It handles partial replication and fan out. Using an HTTP-based sync protocol that scales out data delivery through existing CDN infrastructure.
Durable Streams
We've now generalized the Electric sync protocol into Durable Streams.
This is a lower-level binary streaming protocol that supports more use cases, like token streaming, real-time presence and multi-modal binary data frames.
TanStack DB
TanStack DB is a lightweight, reactive client store with:
- collections a unified data layer to load data into
- live queries super-fast reactivity using differential dataflow
- optimistic mutations that tie into the sync machinery
DB allows you to build real-time, reactive apps on any type of data, from any source. Be it your API response, Electric sync or a Durable Stream.
Durable Session pattern
A Durable Session is a state management pattern that makes AI and agentic apps collaborative. It multiplexes AI token streams with structured state into a persistent, resilient, shared session that users and agents can subscribe to and join at any time.
Layered protocols
The key insight behind the generalization of Electric into Durable Streams was not just that apps needed persistent, addressable, binary streams for presence and token streaming. (Although, of course, they do).
It was also to decouple the payload format from the delivery protocol. So the resilient, scalable, HTTP-based delivery protocol could sync any data format. That way, the Electric sync protocol (originally modelled on the change events emitted by Postgres logical replication) becomes just one of many structured state synchronization protocols layered on top of the core binary streams.
So you have this layered framework of wrapper protocols, where durable streams are wrapped by durable state, which is wrapped by specific transport protocols:
- durable streams — persistent, addressable, payload agnostic, binary stream
- durable state — schema-aware structured state sync over durable stream
- specific protocols — like Postgres sync and AI SDK token streaming
Durable transport
When it comes to building AI apps, the transport protocols are normally defined by the AI SDKs. For example, the Vercel AI SDK has a Data Stream Protocol.
This streams (mostly) JSON message parts over SSE:
data: {"type":"start","messageId":"msg_1234"}
data: {"type":"text-start","id":"msg_1234"}
data: {"type":"text-delta","id":"msg_1234","delta":"Hello "}
data: {"type":"text-delta","id":"msg_1234","delta":" world!"}
data: {"type":"text-end","id":"msg_1234"}
data: {"type":"finish"}
data: [DONE]As you can see from the lack of message ID on the
data: {"type":"finish"}part, it's a protocol that struggles to shake off its single user <> single agent roots. Because how do you multiplex multiple streaming messages at the same time if you don't know which message the finish applies to. And what's[DONE]exactly? The request or the session?
The durable state layer makes it simple to stream this kind of structured protocol, resiliently, with end-to-end type safety, over the Electric delivery protocol.
Example — Vercel AI SDK Transport
The Vercel AI SDK has a Transport adapter mechanism that allows you to plug in a durable transport adapter:
const { messages, sendMessage } = useChat({
transport: new DurableTransport({...})
})See a Durable State based Durable Transport for the Vercel AI SDK here.
Example — TanStack AI Connection Adapter
TanStack AI has a similar Connection Adapter pattern:
const { messages, sendMessage } = useChat({
connection: durableFetch(...)
})See a Durable State based Durable Connection Adapter for TanStack AI here.
Limitations of transport
These transport adapters give you resilience and, in some cases, resumability of active generations. However, they are still limited to request <> response. Which binds them to the single user <> single-agent interaction paradigm.
For real collaboration, we need to go beyond just patching the transport to make individual requests and their streaming responses durable and resilient. We need to patch the state management layer to make the entire session durable.
Sync the entire session
Making the entire session durable allows us to persist multiple messages and active generations over time. You can sync any number of messages to any number of users and any number of agents. Or, for that matter, any other subscribers, workers, applications or interested parties.
In fact, using the Durable State layer, we can multiplex and transfer a variety of structured and multi-modal data both over time and at the same time:
- whole messages
- token streams for active generations
- structured state for presence and agent registration
- CRDTs for typeahead indicators and cursor positions
- binary data frames for multi-modal data
Because the stream is persistent and addressable, clients can always join and catch up from their current offset at any time. Whether that's in real-time as the session is active or later on for asynchronous collaboration and historical access.
What we're describing is a sync-based interaction paradigm. That can combine structured state sync with efficient binary streaming. With principled management of optimistic state, tied into the sync machinery.


Reference implementation
Which is exactly what Durable Streams and TanStack DB were designed for. So, with them as composable sync primitives, the implementation becomes simple.
Using a standard schema
You simply provide a Standard Schema for the multiplexed message types that you would like in your session (here's an example) and that gives you the data available in a reactive store in the client.
Example schema
Here's a cut down of the example schema linked above, that multiplexes whole messages, active token streams, user presence and agent registration data, with end-to-end type-safety, over a single Durable Stream.
It starts by defining the schemas for the different data types:
import { z } from 'zod'
import { createStateSchema } from '@durable-streams/state'
// N.b.: this wrapper schema supports any message or chunk
// payload format in `chunk`, which is then, as we'll see,
// parsed and hydrated into typed messages by the AI SDK.
export const chunkSchema = z.object({
messageId: z.string(),
actorId: z.string(),
role: z.enum(['user', 'assistant', 'system']),
chunk: z.string(),
seq: z.number(),
createdAt: z.string(),
})
export const presenceSchema = z.object({
userId: z.string(),
userName: z.string(),
deviceId: z.string(),
status: z.enum(['online', 'offline']),
})
export const agentSchema = z.object({
agentId: z.string(),
agentName: z.string(),
triggers: z.enum(['all', 'user-messages']),
endpoint: z.string(),
})It then combines them into a unified session schema:
export const sessionSchema = createStateSchema({
chunks: {
schema: chunkSchema,
type: 'chunk',
primaryKey: 'id', // injected as `${messageId}:${seq}`
allowSyncWhilePersisting: true,
},
presence: {
schema: presenceSchema,
type: 'presence',
primaryKey: 'id', // injected as `${actorId}:${deviceId}`
},
agent: {
schema: agentSchema,
type: 'agent',
primaryKey: 'agentId'
},
})This is then passed to the durable state layer StreamDB, which streams the data over a Durable Stream and routes the message streams and session state into TanStack DB collections for you. The schema provides end-to-end type-safety and the transport and reactivity is delegated to the sync machinery.
Derived collections
You can then derive reactive views on the data, in the form of derived live query collections. For example, the code to derive a collection of messages out of the raw chunks looks like this (see full example and materializeMessage source):
import { createLiveQueryCollection, collect, count, minStr } from '@tanstack/db'
const messagesCollection = createLiveQueryCollection({
query: (q) => {
// The first query groups chunks into messages, see:
// https://tanstack.com/db/latest/docs/guides/live-queries#aggregate-functions
const collected = q
.from({ chunk: chunksCollection })
.groupBy(({ chunk }) => chunk.messageId)
.select(({ chunk }) => ({
messageId: chunk.messageId,
rows: collect(chunk),
startedAt: minStr(chunk.createdAt),
rowCount: count(chunk),
}))
// The second query materializes the grouped chunks into
// messages with `materializeMessage` using the built-in
// TanStack AI `StreamProcessor`:
// https://tanstack.com/ai/latest/docs/reference/classes/StreamProcessor
return q
.from({ collected })
.orderBy(({ collected }) => collected.startedAt, 'asc')
.fn.select(({ collected }) => materializeMessage(collected.rows))
},
getKey: (row) => row.id,
})This is highly efficient. There are no for loops over the client data when a new chunk arrives. Instead, the messages are constructed using a live query pipeline based on differential data flow. So only the changed data needs to be re-calculated.
You can then derive further collections from the materialized messages, using additional live query pipelines to filter and coerce the data. For example, to get a collection of pending tool call approval messages:
const approvalsCollection = createLiveQueryCollection({
query: (q) =>
q
.from({ message: messagesCollection })
.fn.where(({ message }) =>
message.parts.some(
(p) =>
p.type === 'tool-call' &&
p.approval?.needsApproval === true &&
p.approval.approved === undefined
)
)
getKey: (row) => row.id,
})The key here again is there's no imperative code looping over client state. It's all materialized and derived in the live query pipeline. With automatic reactivity thanks to TanStack DB. So you can just bind the derived collections to your components and everything works, with end-to-end, surgical reactivity:
import { useLiveQuery } from '@tanstack/react-db'
const LatestPendingApprovals = () => {
const { data: approvals } = useLiveQuery(q =>
q
.from({ msg: approvalsCollection })
.orderBy(({ msg }) => msg.createdAt, 'desc')
.limit(3)
)
return <List items={ approvals } />
}It's also pure TypeScript, so it works across any environment — web, mobile, desktop, Node.js, Bun — simplifying multi-user, multi-device and multi-worker collaboration.
Integrating sessions into your wider data model
Because the session data is synced into TanStack DB collections, it can be joined up into a wider client data model.
For example, we can load user profile data into a collection from your API:
import { QueryClient } from "@tanstack/query-core"
import { createCollection } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
const queryClient = new QueryClient()
const profileCollection = createCollection(
queryCollectionOptions({
queryKey: ["profile"],
queryFn: async () => {
const response = await fetch("/api/user-profiles")
return response.json()
},
queryClient,
getKey: (item) => item.id,
})
)And then join the profile data to the session presence data when displaying active session users:
const ActiveSessionUsers = () => {
const { data } = useLiveQuery(q =>
q
.from({ presence: presenceCollection }) // from the session
.innerJoin({ profile: profileCollection }, // from your API
({ presence, profile }) => eq(presence.userId, profile.userId)
)
.select(({ presence, profile }) => ({
id: presence.userId,
avatar: profile.avatarUrl,
}))
)
// ...
}Thus allowing the data streaming in over the durable session to be joined up naturally into your wider data model.
Write-path actions
When it comes to handling user actions and adding messages to the sessions, you switch the sendMessage calls to use TanStack DB optimistic mutations.
For example, the default TanStack AI ChatClient and useChat hook provide a sendMessage action:
const ChatPage = () => {
const { messages, sendMessage } = useChat({...})
// ...
}We can swap that out for an optimistic mutation using createOptimisticAction.
import { createOptimisticAction } from '@tanstack/db'
interface MessageActionInput {
content: string
messageId: string
role: 'user' | 'assistant' | 'system'
}
const sendMessage = createOptimisticAction<MessageActionInput>({
onMutate: ({ content, messageId, role }) => {
const createdAt = new Date()
// Insert optimistic state into messages collection directly
// This propagates to all derived collections, so the local UI
// updates instantly.
messagesCollection.insert({
id: messageId,
role,
parts: [{ type: 'text' as const, content }],
isComplete: true,
createdAt,
})
},
mutationFn: async ({ content, messageId, role, agent }) => {
const txid = crypto.randomUUID()
await this.postToProxy(`/v1/sessions/${this.sessionId}/messages`, {
messageId,
content,
role,
actorId: this.actorId,
actorType: this.actorType,
txid,
...(agent && { agent }),
})
// Wait for this write to sync back on the durable stream
// before discarding optimistic state.
await streamDb.utils.awaitTxId(txid)
},
})In our demo code, we implement this in a customized DurableChatClient, which pairs with a useDurableChat hook:
const ChatPage = () => {
const { messages, sendMessage } = useDurableChat({...})
// ...
}As you can see, the usage from component code is exactly the same. So this works as a drop-in replacement. With the actual, underlying, state handling and transfer wired properly via the durable session.
This solves the limitations of the transport level durability we discussed above. By having principled management of local optimistic state and syncing user messages to the other subscribers to the session.
As you can see from the mutationFn in the code sample above, it still POSTs the write to your backend. So you're in control of authentication and any other custom business logic and you handle writes to the session in your backend code.
Session CRUD
This is standard CRUD stuff and you can implement it using whatever framework you prefer or already use.
For example, in the reference example, we have a handler for message actions which (simpified and on the happy path) looks something like this:
async function handleSendMessage(c: Context, protocol: Protocol): Promise<Response> {
// Validate and parse the request
const sessionId = c.req.param('sessionId')
const body = messageRequestSchema.parse(await c.req.json())
// Write to the stream
const stream = await protocol.getOrCreateSession(sessionId)
await protocol.writeUserMessage(stream, sessionId, body)
return c.json({}, 200)
}Importantly, you'll notice that handler doesn't proxy the request through to an LLM provider. It just writes to the stream.
To instruct the LLM, you register agents that subscribe to the stream and the session backend calls them when new messages are posted:
state.modelMessages.subscribeChanges(async () => {
const history = await this.getMessageHistory(sessionId)
notifyRegisteredAgents(stream, sessionId, 'user-messages', history)
})The agents themselves are backend API endpoints, where you can manage your control flow and perform context engineering as normal. For example this is the main code from the default agent in the demo:
async ({ request }) => {
if (request.signal.aborted) {
return new Response(null, { status: 499 })
}
const { messages } = await request.json()
const abortController = new AbortController()
const stream = chat({
adapter: openai(),
model: 'gpt-4o',
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(10),
messages,
abortController,
})
return toStreamResponse(stream, { abortController })
}It's your code and it can be exactly the same as if it was handling a user message and streaming the response back in a request <> response paradigm. Except now it's being invoked by the backend session, which consumes the response and writes it iteratively onto the durable stream.
Optimum AX/DX/UX
As a result, you get an app that fully supports multi-tab, multi-device, multi-user and multi-agent. For both real-time and asynchronous collaboration.
With minimal changes to your component code and zero changes to your real AI engineering.
Example — TanStack AI - Durable Sessions
See the full code example and working demo app of a TanStack AI - Durable Sessions reference implementation here.
The key pattern for collaborative AI
As the world moves to getting things done through agents, the winners are going to be the products that combine AI with team-based collaboration. Building AI apps on the Durable Session pattern is the best way to do that.
Durable Streams and TanStack DB allow you to build Durable Sessions with your existing stack, schema and AI SDK.
Next steps
Dive into the projects and docs for more information:
Check out the reference implementations in the electric-sql/transport repo:
Reach out on our Discord channel if you have any questions, or if you need help implementing any of the technologies or patterns outlined in this post.

