Coveo Thermidor
@coveo/thermidor is an experimental, framework-agnostic client for a single, stateful, intent-routing unified converse endpoint. It exposes one factory — createSession(config) — that returns a Session: a plain observable list of Turns folded from a server-sent-event (SSE) stream, plus a single action-dispatch entry point wired straight to the renderer.
The package is private, experimental, and consumed only inside this monorepo (by samples/thermidor/demo-schema-react).
There is no engine, no interface layer, no Redux/RTK, and no per-endpoint facades. References to those layers in older revisions are obsolete.
The idea
The server streams schema-defined A2UI components; the client renders what the server sends. Thermidor's job is to transmit consumer input to the endpoint and fold the streamed result into an observable list of turns — it derives no use-case behavior beyond that fold. The contracts schema, the endpoint URL, and the ambient context are all consumer-injected, so a single package build serves public, internal (private-registry schema), and proxied deployments.
Component state travels inline through the A2-UI data model: the server sends updateDataModel operations under /state/<id>, and the frozen renderer resolves each prop through an A2-UI Data_Binding ({ path: <JSON Pointer> }) against that data model. Renderers are "dumb": they receive resolved state in their props and never hydrate a controller or join a snapshot themselves. A2-UI is the only supported state source. This inline model superseded the earlier remote-controller / AG-UI StateSnapshot design.
Public surface
import {createSession} from '@coveo/thermidor';
import {ComponentContractsSchema} from '@coveo/thermidor-schema';
const session = createSession({
organizationId: 'my-org',
accessToken: 'xx-token',
agentConfigurationId: '00000000-0000-0000-0000-000000000000', // the configured agent that answers the session
trackingId: 'my-tracking-id',
contracts: ComponentContractsSchema, // injected Zod discriminated union (Zod 3 or 4)
navigatorContextProvider: () => getNavigatorContext(), // required; includes language, country, currency
commerceContextProvider: () => ({cart: getCart()}),
});
session.subscribe(() => render(session.getSnapshot()));
await session.dispatchAction({name: 'submitPrompt', payload: {prompt: 'show me running shoes'}});
createSession(config) returns a Session exposing exactly:
| Member | Purpose |
|---|---|
contracts | The injected contracts schema this session validates against (readonly). |
turns | Readonly observable list of Turns folded from the stream (empty initially). |
initialization | The run of the initialize action that opened the session, or undefined. See Opening the session. |
surfaces | Every live surface of the session, in creation order, whichever run created it. |
a2uiMessages | The session's whole renderer stream, in the A2-UI v0.9 shape. It only grows: feed each new message to one renderer once. |
state | The validated data model of every live surface, keyed by surface id. |
actionError | The last component action that failed (ActionError: message, component, name, surfaceId, sourceComponentId), or undefined. |
request | The request being sent, or undefined when idle. See One request at a time. |
subscribe(listener) | Registers a listener called once per change to the turn list, the initialization, actionError or request. |
getSnapshot() | turns, initialization, surfaces, a2uiMessages, state, request and actionError as one immutable SessionSnapshot, replaced on change. |
dispatchAction(message) | The single entry point for prompts (submitPrompt) and component actions, wired directly as the renderer's onAction. |
cancel() | Stops the active stream. |
retry(turnId) | Re-submits the last turn's input when it errored: its prompt, or the action that opened it. |
serialize() | Serializes the transcript into a versioned SerializedSession. |
subscribe and getSnapshot do not rely on this, so they can be handed by reference to any store that compares snapshots by reference, such as React's useSyncExternalStore(session.subscribe, session.getSnapshot). The package itself depends on no framework.
The concrete contracts type pinned at the createSession call site threads unbroken through Session, so component types, action names, and payloads stay fully typed at the call site (no string, no never, no unknown).
Opening the session
The session opens itself: createSession sends an initialize action a microtask after it returns, so a listener subscribed in the same task sees it start. It carries no surface or source component, since no component exists yet. While it is in flight, session.request is {kind: 'initialize'}. It goes through the one-request rule and the conflict retry, and adopts the session id and token from its run, like a prompt. The producer answers with the session's shell, such as its search box, as an ordinary surface.
Its run is recorded as session.initialization (activities, status, error), never as a turn. A failed initialize is not retried: initialization.status becomes 'error', and the first prompt still opens the session. cancel() stops it like any request. A restored session never sends it, and neither does a session created with initialize: false, for tests or for a server that creates a session while rendering.
Dispatching actions
session.dispatchAction is the single consumer-facing action entry point. It accepts two kinds of message.
Submitting a prompt
A prompt typed outside A2-UI composition, such as in the integrator's search box, is a SubmitPromptAction:
session.dispatchAction({name: 'submitPrompt', payload: {prompt: 'show me running shoes'}});
It opens a new streaming turn, records the prompt as the turn's input.prompt, and POSTs it to the converse endpoint. It needs no rendered component or active turn, so it also opens the first turn of a session. Sent while another request is in flight, it waits for it (see One request at a time), and retry(turnId) re-drives a turn it opened.
Component actions
A component action is the standard A2-UI client-to-server message (A2uiClientMessage) that the frozen renderer hands to its onAction handler, so it wires with no adapter:
<A2UIProvider catalog={catalog} onAction={session.dispatchAction}>{/* … */}</A2UIProvider>
Given a message, dispatchAction unwraps its userAction, recovers the dispatching component's discriminant from the session's live surfaces, whichever run created them, validates the action payload against the component's injected contract internally, and — on success — POSTs the action to the converse endpoint.
An action the producer announces as opening a turn, such as a follow-up chip or a search option, is answered as a new turn of the conversation, as a prompt is. The producer announces it with the surface, in the AG-UI metadata of its snapshot (metadata.turnOpeningActions, by surface id), so the consumer declares nothing and the turn opens as soon as the shopper picks the action. Such an action opens a new streaming turn whose input.action records it (component, action name, surface id, source node and payload), and that turn becomes the active turn. The surfaces of the turn it was sent from keep accepting actions. It needs no active turn, so a turn-opening action on the shell opens the first turn of a session. Any other action only updates the surfaces: its surface snapshots are recorded with the active turn, or with session.initialization before the first turn.
It is fire-and-forget: the returned Promise always resolves and never rejects, so the consumer needs no .catch. A message with no userAction, no sourceComponentId, a node that resolves to no component, or an internal validation failure is dropped with a dev-only warning and nothing is sent.
One request at a time
Thermidor sends one request at a time per session, initialize, prompts and component actions alike, except the concurrent actions the producer announces. A dispatch made while a request is in flight is withheld: nothing is sent, and its promise resolves at once with a dev-only warning. Each dispatchAction promise resolves once its own request ends.
session.requestis the request being sent ({kind: 'initialize'},{kind: 'prompt', turnId}or{kind: 'action', component, name, surfaceId, sourceComponentId, turnId?}, with noturnIdbefore the first turn), orundefinedwhen idle. An action's run counts, a follow-up included. Read it synchronously, or throughsubscribe, to lock or dim the interface, and to hold back a dispatch until the session is free.componentis the dispatching node's discriminant, so a consumer can key its own action lists, such as the actions that keep its surfaces usable, by component and action name, typed by the contracts withComponentActionNames.cancel()stops the request in flight, butsession.requeststays set until it has unwound, a few microtasks later, so a dispatch made right aftercancel()is withheld. Wait forrequestto becomeundefined, throughsubscribe, before sending the next one.
Concurrent and cached actions
The producer can answer some actions beside the turn, such as instant products for the highlighted suggestion. It announces them with the surface, in the AG-UI metadata of its snapshot, by surface id, as it announces turn-opening actions:
"metadata": {
"concurrentActions": {"<surfaceId>": {"fetchInstantProducts": {"cache": {"ttlMs": 60000}}}}
}
Each concurrent action can carry a cache policy, the lifetime of its answers; without one, its answers are not reused. The latest announcement for a surface wins, and an empty entry revokes it. A renderer only dispatches: it holds no cache, no retry and no wait for a free session.
- A concurrent action is sent at once, whatever request is in flight, and never sets
session.request. A prompt orcancel()does not stop it, and it is withheld until the session has its token. - Only the latest dispatch from a node is applied. An older request in flight is aborted, unless the action is cached: then it runs to the end, so its answer is cached, and a dispatch with the same payload joins it instead of sending another.
- A cached action dispatched again with the same payload within the announced lifetime is answered from the session's cache, and sends nothing. The cache holds the last 100 answers, lives in memory and is never serialized.
- Its answer may only update the data model of nodes the session already holds; anything else, such as a new surface or component, is dropped with a dev-only warning, and the answer's
metadatais not read. Its writes reachsession.a2uiMessagesandsession.state, but no run records them: a later recorded write to the same path replaces them,session.serialize()leaves them out, and a restored session shows the state the last recorded run left, so the renderer asks again. - Its failure, including a refused request, is a dev-only warning. It sets no
session.actionErrorand changes no run.
Optimistic values
To show a gesture's expected value until the session has answered it, show that value from the moment the gesture is sent until its dispatch promise resolves, then give way to the producer's value. A renderer that returns its onAction handler's promise from its own dispatch hands back the session.dispatchAction promise, so awaiting the renderer's dispatch is enough. Thermidor doesn't depend on a UI framework, so any state mechanism works. With React's useOptimistic, for example, a transition that awaits the dispatch lasts exactly that long:
const [values, addOptimisticValues] = useOptimistic(producerValues);
startTransition(async () => {
addOptimisticValues(values.map((v) => (v.value === 'Nike' ? {...v, state: 'selected'} : v)));
await renderer.dispatchAction({event: {name: 'toggleSelect', context: {value: 'Nike'}}});
});
The optimistic value gives way to the producer's once the gesture is over, whatever the outcome, so a failed or withheld gesture needs no rollback. Thermidor never computes the expected value: the integrator does.
Concurrency conflicts
When the Gateway is still busy with another request for the session, it rejects the new one (409, AGENT:ACTION_IN_FLIGHT). Thermidor retries automatically for up to 30 seconds, and a conflict that recovers shows nothing. If it gives up:
- an action that does not open a turn ends without changing any turn, which releases its optimistic hold, and
session.actionErrorreports it until the next action or prompt is sent; - an action that opens a turn sets
session.actionErrortoo, and its turn becomeserror, retryable withretry(turnId)while it is the last turn; - a prompt's turn becomes
error, retryable withretry(turnId)while it is the last turn.
The Turn / TurnResponse model
Each turn pairs one input with its streamed response:
interface Turn {
id: string;
input: {prompt?: string; action?: TurnAction}; // the prompt, or the action that opened the turn
response: TurnResponse;
status: 'streaming' | 'complete' | 'error';
error?: string; // present iff status === 'error'
}
interface TurnResponse {
activities: Activity[]; // raw snapshot log, latest snapshot per messageId, defaults to []
surfaces: DiscoveredSurface[]; // the live surfaces this turn created
agent?: {messages: AgentMessage[]; reasoningSteps: ReasoningStep[]}; // present only when the router invoked an agent
}
activities is routing-neutral and always present. agent appears only when the router invoked an agent for the turn. Consumers read turn.input.prompt, turn.response.surfaces (where to draw the turn's blocks) and turn.response.agent?.messages / turn.response.agent?.reasoningSteps.
Session-scoped surfaces
Surfaces live in the session, not in the turn that created them. The session derives them from one ordered activity log: the initialization's activities, then each turn's. A surface lives from its createSurface until its deleteSurface, whichever runs carry them, and a createSurface for a live id replaces that surface, so a producer mints a new id for each new surface.
session.surfaceslists every live surface in creation order; a surface the agent deletes leaves it, and one it re-creates moves to the end. Each turn'sresponse.surfaceslists the live surfaces that turn created; find one no turn created, such as the shell, by itsrootComponentType. A loading placeholder is listed only while the run that created it streams: while its turn or the initialization streams, or while the request in flight streams into that run and had not found it already left behind; one the agent left behind (a cancelled run, an error, a restored session) leaves the lists, so a consumer draws a skeleton for every listed surface whoseisLoadingholds.session.a2uiMessagesis the whole session's renderer stream, in arrival order. It only grows, so a single renderer is fed each new message once. Before acreateSurfacethat replaces a live surface, it carries adeleteSurfacefor it, and it leaves out an operation on a surface that is not live, since a renderer refuses both. It exists because Agent Gateway emits A2-UI v1.0 while every available renderer consumes v0.9: Thermidor downgrades the stream on the consumer's behalf (recorded interim debt, see below).session.stateis the validated data model of every live surface, keyed by surface id, whichever run wrote it, with the answers to concurrent actions on top.- An action resolves its source component across the session's live surfaces, so the surfaces of earlier runs keep accepting actions. Its run only updates the surfaces: its surface snapshots are recorded with the active turn, or with the initialization before the first turn, and it never changes the status or response of a turn or of the initialization. When it fails, whether the endpoint rejects it, it streams
RUN_ERROR, its stream ends without a terminal event or its request cannot be built,session.actionErroridentifies the action (message,component,name,surfaceId,sourceComponentId), so a consumer can show the failure next to the block it came from.
Each DiscoveredSurface carries surfaceId, rootComponentType, and isLoading, which is true while the surface's data model declares isLoading: true (a loading placeholder for rootComponentType).
Validation and displayed values
- Client-side validation is retained, transparently. The injected
contractsschema stays the sole validation source, but the consumer never runs Zod and writes no adapter glue. InboundupdateDataModelops are validated before they are applied tosession.state, against the components of every live surface; outbound action payloads are validated in the private dispatch path before the POST. Both boundaries live inside the core.session.a2uiMessagesis derived from the raw stream, so the renderer still receives an op that validation drops fromsession.state, and the component state seeded bycreateSurface.dataModel, which is never validated. - Displayed-value formatting is agent-produced. The renderer shows the state it receives through its
{ path }bindings; Thermidor does not reformat displayed values.
Injected schema, endpoint, and context
- Contracts schema — a consumer-injected Zod discriminated union (
contracts) is the sole source for component-state and action-payload validation. Thermidor accepts it through a structural typing seam (ContractsSchema/ComponentContractSchema/ParsableSchema) that reads only public Zod API, so it declares no dependency on any specific schema package. It declareszodas a peer dependency spanning both majors (3.25.76 || ^4), so validation runs through the single sharedzodinstance resolved by the injected schema. A consumer whose renderer is built on Zod 3 injects a Zod 3 schema, such as@coveo/thermidor-schema/zod3. - Endpoint — when
endpointis provided, the client POSTs to it verbatim. When absent, it resolveshttps://{orgId}.org.coveo.comand appends the fixed converse path/api/preview/organizations/{orgId}/agents/commerce/agui/converse. - Context providers —
navigatorContextProvider(required) andcommerceContextProviderare synchronous functions read fresh per request. The navigator context carries the request'slanguage,countryandcurrency. Context is never stored on the session and never serialized.
Serialization
session.serialize() produces a versioned SerializedSession (integer version >= 1), distinct from the runtime Turn type. Pass it back as sessionToRestore in createSession to continue a conversation. The initialization, the turns and the session's recorded state are persisted, without the answers to concurrent actions; the surfaces and the renderer stream are never persisted (they are re-derived from the initialization's and the turns' activities on restore); a turn or initialization persisted mid-stream is downgraded to error with 'Stream was interrupted', preserving what it holds.
Recorded interim debt and out-of-scope items
The following are intentionally not yet implemented and are recorded as debt or deferred work:
- Interim surface derivation —
surfacesis a typed projection derived fromactivities, annotated as interim; the core interprets norootComponentType; consumers read it. It is retired once the server sends typed routing. - The A2-UI v0.9 renderer projection —
session.a2uiMessagesdowngrades Agent Gateway's v1.0 stream for today's v0.9 renderers. Removed in a breaking change once a v1.0-capable renderer exists. - Renderer stream bypasses validation — inbound ops that in-transit validation drops from
session.statestill reach the renderer throughsession.a2uiMessages, and so does the component state seeded bycreateSurface.dataModel, which is never validated. A fix must cover both paths. - Enforced action-payload typing at the renderer boundary — the renderer hands
dispatchActiona loosely typed action message, so per-component action typing is applied by convention (the consumer opts into the generatedXxxActiontypes) rather than enforced at the boundary. Recoverable later if the package owns binding resolution. - Package rename — deferred as low-stakes; the package remains
@coveo/thermidor.
Development
pnpm --filter @coveo/thermidor build # build project
pnpm --filter @coveo/thermidor test # run unit + property tests
pnpm --filter @coveo/thermidor test:dts # validate public API surface (incl. DX type test)
Architecture
See the Architecture Guide for the internal structure, and the accepted decisions of record in docs/internal/adr.