Optimistic updates
Your users see their messages the instant they hit send. AI Transport inserts the message into the local tree before the server confirms, and reconciles automatically using the `codec-message-id`.
User messages appear in the conversation immediately, before the server confirms. The client inserts the message into the tree optimistically. When the server publishes it to the session, the transport reconciles the optimistic message with the published one using the codec-message-id. The message keeps its content and its place in the conversation.
Optimistic updates need a durable session, because the SDK reconciles your optimistic message against the conversation tree it holds.
How it works
When a user sends a message, the client inserts it into the conversation tree before the POST request reaches the server. The message appears in the UI immediately.
Behind the scenes:
- The client generates a
codec-message-idand inserts the message into the conversation tree. - The client publishes the input directly to the session, carrying that
codec-message-id. - The application POSTs the invocation pointer (
inputEventId,sessionName) to the agent endpoint to wake the agent. The POST does not carry the message body; the agent reads the input event off the session. - The client receives the message it published back through its session subscription.
- The session matches the echo to the optimistic insert by
codec-message-idand promotes it to a confirmed message.
After reconciliation, the optimistic message is replaced by the wire-confirmed version.
Serial promotion
Optimistic messages do not have an Ably serial number; they exist only in the local tree. When the message the client published arrives back through its session subscription, the reconciled message receives a real Ably serial. This is serial promotion.
Serial promotion matters for ordering. Messages in the conversation tree are ordered by their Ably serial. Optimistic messages sit at the end of the tree until they are promoted. Once promoted, their position reflects the true order of publication assigned by the Ably channel.
Send several inputs at once
A call to send takes an array, and at most one entry in it may introduce a new message. The rest of the entries have to be wire-only references to messages that already exist: a regenerate signal, or the tool resolutions (tool-result, tool-result-error, or tool-approval-response) of one assistant turn. An input carrying a codecMessageId is a reference; a user-message never is.
The check runs before any local insertion or publish, so a send carrying two new messages rejects with InvalidArgument and leaves the tree untouched:
1
2
3
4
5
// Client-side. Throws: two user-message inputs introduce two new messages.
await view.send([
{ kind: 'user-message', message: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Here is my updated question' }] } },
{ kind: 'user-message', message: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'And some additional context' }] } },
]);A valid array pairs one new message with references. Resolving two tool calls on the same assistant turn and following them with a prompt looks like this:
1
2
3
4
5
6
// Client-side. One new message, two wire-only tool resolutions.
await view.send([
{ kind: 'tool-result', codecMessageId, payload: { toolCallId: firstCallId, output: firstOutput } },
{ kind: 'tool-result', codecMessageId, payload: { toolCallId: secondCallId, output: secondOutput } },
{ kind: 'user-message', message: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Now summarise both.' }] } },
]);The new message is inserted optimistically and reconciled by its codec-message-id when the echo arrives. The wire-only entries are published without an optimistic insert, because the messages they reference are already in the tree.
To send two prompts as two turns, call send twice.
Handle errors
A publish failure rejects send(). It is not also emitted on the session's error event, because one error reaches you through one mechanism and the caller is already awaiting the promise. Catch it at the call site:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Client-side.
try {
const run = await view.send({
kind: 'user-message',
message: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
});
// The SDK publishes; waking the agent is yours.
await fetch('/api/chat', { method: 'POST', body: JSON.stringify(run.toInvocation().toJSON()) });
} catch (error) {
// `InsufficientCapability` when the token lacks publish on the channel,
// `SessionSendFailed` otherwise.
showRetry(error.message);
}The SDK clears the optimistic node on that failure only when the publish never produced a server-assigned serial. A node that was acked is part of the canonical channel state, so it stays, and every observer already sees it.
If the publish succeeds but the agent-wake POST fails, the message stays in the tree, because it is a real published conversation entry. Retry the POST; the agent finds the trigger by paging history.
Reconciliation details
The session uses the codec-message-id header (under the extras.ai.transport tier) to match published messages to optimistic inserts. When the client publishes an input, the SDK generates a client-side codec-message-id and sets it on the published message. The domain message's own id is preserved verbatim from the source stream; correlation does not rely on it. When the client receives the published message through its subscription, the SDK matches on the codec-message-id and promotes the optimistic message to a confirmed one.
A published message that does not match any optimistic insert (for example, a message from another client) is added to the tree normally. The reconciliation logic only applies to messages the current client sent.
Edge cases and unhappy paths
- A failed publish rejects
send()and clears the optimistic node, but only if that node never received a server-assigned serial. A failed agent-wake POST leaves the published message in the tree because it is a real conversation entry, so retry the POST rather than resending the message. regenerate()inserts nothing optimistically. It is a wire-only signal, so its reply run appears only once the agent's run-start arrives. A spinner keyed on an optimistic insert never shows for a regenerate.- The session matches on the client-side
codec-message-idthe SDK generates, so a duplicatemessage.idcannot confuse reconciliation. Database hydration matches onmessage.id, so usecrypto.randomUUID()to generate IDs. - A published message that arrives before the server returns from POST still reconciles correctly. Reconciliation uses the
codec-message-idrather than request ordering. - Two clients sending at the same time each insert an optimistic message and publish it to the session. Each client only reconciles its own; the other side appears as a normal observer message.
- A user message published with a different
codec-message-idis added to the tree as a new message, and one published with nocodec-message-idis dropped. The SDK sets this header automatically; only custom codecs that publish raw messages need to set it themselves.
FAQ
Why do I still need a server when messages are optimistic?
The optimistic insert is a local UI optimisation. The client publishes the canonical message to the session itself, so every other client sees it. The server runs the LLM and publishes the reply.
Does optimistic insertion work for assistant messages?
Streamed assistant text arrives only from the agent. The one client-side insertion is a client tool result under the Vercel codec, which the client publishes as an optimistic reply run that the tree reconciles onto the agent's ai-run-start.
What if the server publishes the message but the response never arrives?
The optimistic message reconciles when the publish arrives. If the publish never arrives, through a server failure, your retention window expiring, or a network drop, the optimistic message stays unreconciled. Await run.started with a timeout of your own if you want to surface that state to the user.
Can I roll back an optimistic message?
Yes. Remove it from your application state when you decide the send has failed. The transport does not impose a rollback policy.
Does this work with branching?
Yes. The optimistic message carries the parent and forkOf headers for its branch from the moment the client inserts it.
Related features
- Token streaming: how the agent's response streams after the user's message.
- Conversation branching: edits use optimistic updates for the revised message.
- Multi-device and fan-out: optimistic messages reconcile across devices.