React chat A2UI preview
Let the agent offer a flight-search form, then let the traveler edit it before submitting an action. A2UI keeps the surface description, its data model, and the user’s interaction distinct.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Render a supported surface profile
The application admits a specific set of component types and field domains, then preserves the received surface separately from local edits. Search, Select, and Modify are explicit round trips to the existing graph. A completed response alone does not make every historic form actionable: only the current confirmed surface accepts edits and actions in this example.
The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Start a conversation
Choose Run, type a request and choose Send. The suggestion only fills the composer. The first Send creates the conversation; New conversation clears the local view and waits for Send. Mounting, reading Docs or Code, and changing modes make no thread requests. Docs, Code and Run share the same native React implementation; mode navigation preserves the mounted frame and unsent draft, while reload clears local state.
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useRef, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
ChatInput,
MessageList,
type ChatInputHandle,
} from '@threadplane/react/chat';
import type { A2uiApplication } from './application';
// eslint-disable-next-line @nx/enforce-module-boundaries -- Same-layout private presentation source in installed consumers.
import { A2uiSurface } from '../../../../../libs/cockpit-a2ui-react/src/index';
function humanText(content: string) {
try {
const value = JSON.parse(content);
if (value.version === 'v0.9') {
const labels: Record<string, string> = {
bookingSubmit: 'Search flights',
flightSelect: 'Select demonstration flight',
modifySearch: 'Modify search',
};
if (Object.hasOwn(labels, value.action?.name))
return labels[value.action.name];
}
} catch {
/* Ordinary human text. */
}
return content;
}
function SavedText({ content }: { readonly content: string }) {
const at = content.indexOf('---a2ui_JSON---');
return at < 0 ? (
<p className="a2ui-prose">{content}</p>
) : (
<>
{content.slice(0, at).trim() && (
<p className="a2ui-prose">{content.slice(0, at).trim()}</p>
)}
<details className="a2ui-wire">
<summary>Response details</summary>
<pre>{content.slice(at)}</pre>
</details>
</>
);
}
/** Main owns the application. React only observes it and borrows its commands. */
export function A2uiDemo({
application,
onReady,
}: {
readonly application: A2uiApplication;
readonly onReady: () => void;
}) {
const snapshot = useAgent(application);
const [draft, setDraft] = useState('');
const composer = useRef<ChatInputHandle>(null);
useEffect(onReady, [onReady]);
const status = snapshot.busy
? snapshot.activity === 'creating'
? 'Creating conversation…'
: snapshot.activity === 'confirming'
? 'Confirming saved response…'
: snapshot.activity === 'replacing'
? 'Starting a new conversation…'
: 'Waiting for the saved response…'
: snapshot.error
? 'Start a new conversation to continue.'
: snapshot.messages.length
? 'Response saved.'
: 'Ready.';
return (
<ExampleLayout className="a2ui-demo">
<header>
<div>
<h1>Plan a flight</h1>
</div>
<button
type="button"
onClick={() => {
setDraft('');
void application.newConversation().catch(() => undefined);
}}
>
New conversation
</button>
</header>
<p>
Flight preview: request a flight form, edit your details, and select a
demonstration flight. This preview makes no booking or payment.
</p>
{snapshot.messages.length === 0 && (
<button
className="a2ui-suggestion"
type="button"
disabled={snapshot.busy || !snapshot.canSubmit}
onClick={() => {
setDraft('I want to fly LAX to JFK');
composer.current?.focus();
}}
>
Try a flight request
</button>
)}
<MessageList
rows={[]}
label="Conversation"
className="a2ui-conversation"
key={`messages:${snapshot.viewGeneration}`}
>
{!snapshot.messages.length && (
<p className="a2ui-empty">
Your saved flight previews will appear here.
</p>
)}
{snapshot.messages.map((message) => {
const surface = snapshot.surfaces.find(
(s) => s.messageId === message.id
);
return (
<article
key={message.id}
className={`tp-chat-message tp-chat-message--${message.role} a2ui-message a2ui-message--${message.role}`}
aria-label={message.role === 'user' ? 'You' : 'Assistant'}
>
<p className="tp-chat-message__role a2ui-message-role">
{message.role === 'user' ? 'You' : 'Assistant'}
</p>
{surface ? (
<A2uiSurface
surface={surface}
active={
snapshot.activeSurface === surface.id &&
!snapshot.busy &&
snapshot.canSubmit
}
/>
) : message.role === 'user' ? (
<p className="a2ui-prose">{humanText(message.content)}</p>
) : (
<SavedText content={message.content} />
)}
</article>
);
})}
</MessageList>
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
ref={composer}
key={snapshot.viewGeneration}
label="Message"
value={draft}
onValueChange={setDraft}
placeholder="Ask for a flight form…"
hint="Enter to send. Shift+Enter adds a new line."
busy={snapshot.busy}
disabled={!snapshot.canSubmit && !snapshot.busy}
onStop={
snapshot.busy
? () => {
void application.stop().catch(() => undefined);
}
: undefined
}
onSubmit={(text) => {
const state = application.getSnapshot();
if (state.busy || !state.canSubmit) return false;
// ChatInput clears this admitted draft synchronously, independently of settlement.
void application.submit(text).catch(() => undefined);
return true;
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this private bridge.
import {
GENERATED_RUNTIME_PARENT_ORIGINS,
installRuntimeBridge,
type InstalledRuntimeBridge,
} from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { A2uiDemo } from './app';
import {
a2uiConnection,
createConnectedApplication,
type A2uiConnection,
} from './connection';
import type { A2uiApplication } from './application';
import '@threadplane/react/chat/styles.css';
import './styles.css';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import '../../../../shared/react-example.css';
/** Own bootstrap and teardown outside React's replayable presentation lifetime. */
export function startA2uiDemo(
element: HTMLElement,
dependencies: {
readonly bridge?: InstalledRuntimeBridge;
readonly root?: Pick<Root, 'render' | 'unmount'>;
readonly createApplication?: (
connection: A2uiConnection
) => A2uiApplication;
} = {}
) {
const bridge =
dependencies.bridge ??
installRuntimeBridge(undefined, {
allowedParentOrigins: GENERATED_RUNTIME_PARENT_ORIGINS,
});
const root = dependencies.root ?? createRoot(element);
const createApplication =
dependencies.createApplication ?? createConnectedApplication;
let active = true,
markedReady = false,
application: A2uiApplication | undefined,
cleanup: Promise<void> | undefined;
function dispose(): Promise<void> {
if (!active) return cleanup ?? Promise.resolve();
active = false;
window.removeEventListener('pagehide', pagehide);
// Revoke work/stop first, then remove borrowed views, then dispose owned data.
application?.revoke();
root.unmount();
cleanup = Promise.resolve(application?.dispose())
.catch(() => undefined)
.then(() => {
bridge.dispose();
});
return cleanup;
}
function pagehide() {
void dispose();
}
window.addEventListener('pagehide', pagehide);
const ready = bridge
.awaitConfiguration()
.then(async (configuration) => {
if (!active) return;
const connection = a2uiConnection(configuration);
application = createApplication(connection);
if (!active) {
application.revoke();
await application.dispose();
return;
}
root.render(
<StrictMode>
<A2uiDemo
application={application}
onReady={() => {
if (active && !markedReady) {
markedReady = true;
bridge.markReady();
}
}}
/>
</StrictMode>
);
})
.catch(() => {
if (!active) return;
bridge.markError('bootstrap_failed');
root.render(<p role="alert">The flight preview could not start.</p>);
});
return Object.freeze({ ready, dispose });
}
const element = document.getElementById('root');
if (element) startA2uiDemo(element);import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
import {
createA2uiApplication,
type A2uiClient,
type A2uiSession,
} from './application';
// eslint-disable-next-line @nx/enforce-module-boundaries -- Same-layout private data source in installed consumers.
import { copyData } from '../../../../../libs/cockpit-a2ui-react/src/data';
import { checkpointSource, type Checkpoint } from './terminal';
// eslint-disable-next-line @nx/enforce-module-boundaries -- Internal bridge is copied by the isolated build.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
export interface A2uiConnection {
readonly apiUrl: string;
readonly headers: Record<string, string>;
}
/** Resolve the shared proxy or the explicitly authored developer target. */
export function a2uiConnection(configuration: RuntimeBridgeConfiguration): {
apiUrl: string;
headers: Record<string, string>;
} {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'langsmith'
? configuration.target
: null;
return {
apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
headers: target ? { 'x-api-key': target.apiKey } : {},
};
}
/** Connect only this page's confirmed IDs, with retries disabled for owned requests. */
export function createA2uiClient(connection: A2uiConnection): A2uiClient {
connection = Object.freeze({
apiUrl: connection.apiUrl,
headers: Object.freeze({ ...connection.headers }),
});
const known = new Set<string>();
const client = new Client({
apiUrl: connection.apiUrl,
apiKey: null,
defaultHeaders: connection.headers,
callerOptions: { maxRetries: 0 },
});
return {
async createThread(signal) {
try {
if (signal.aborted) throw new Error('Aborted');
const threadId = crypto.randomUUID();
const created = await client.threads.create({
threadId,
ifExists: 'raise',
signal,
});
if (signal.aborted || created.thread_id !== threadId)
throw new Error('Unconfirmed');
known.add(threadId);
return threadId;
} catch {
throw new Error('Conversation creation was not confirmed.');
}
},
sessionFactory(threadId) {
if (!known.has(threadId)) throw new Error('Unknown conversation.');
let session: ReturnType<typeof createSession> | undefined;
try {
session = createSession({
assistantId: 'c-a2ui',
threadId,
apiUrl: connection.apiUrl,
clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
});
const selected = session;
const state = selected.getSnapshot();
if (
!state ||
!Array.isArray(state.messages) ||
!Array.isArray(state.toolCalls) ||
!Array.isArray(state.interrupts) ||
!Array.isArray(state.subgraphs) ||
![
'getSnapshot',
'subscribe',
'submit',
'load',
'stop',
'dispose',
].every(
(method) =>
typeof selected[method as keyof typeof selected] === 'function'
)
)
throw new Error('Rejected session');
return session as A2uiSession;
} catch {
try {
void Promise.resolve(session?.dispose()).catch(() => undefined);
} catch {
/* Rejected sessions cannot be installed. */
}
throw new Error('Runtime session is unavailable.');
}
},
async readCheckpoint(requested, signal) {
try {
const captured = copyData(requested) as unknown as Checkpoint;
const source = checkpointSource(captured, captured.thread_id);
if (!source || !known.has(source.thread_id) || signal.aborted)
throw new Error('Unavailable');
// SDK declares checkpoint_map required-but-possibly-undefined. Preserve its absence on the wire.
const state = await client.threads.getState(
source.thread_id,
source as Parameters<typeof client.threads.getState>[1],
{ signal }
);
if (signal.aborted) throw new Error('Aborted');
return state;
} catch {
throw new Error('Checkpoint preview is unavailable.');
}
},
};
}
export function createConnectedApplication(connection: A2uiConnection) {
return createA2uiApplication(createA2uiClient(connection));
}Edit the latest saved flight preview
Only the newest settled surface is interactive after a successful owned submission and confirmation against its exact saved root checkpoint and raw transcript. Older cards remain read-only and retain their last displayed edits. Streaming fragments, title metadata and completion flags do not enable controls. The application preserves immutable raw A2UI separately from editable form values. Invalid visible input stays invalid instead of falling back to an older valid value. Replaced cards cannot submit through stale callbacks.
import type { AgentSession, AgentSnapshot, Message } from '@threadplane/core';
import {
createObservation,
prepareTerminal,
type Checkpoint,
type Observation,
} from './terminal';
// Same-layout private source import is copied unchanged into installed consumers.
// eslint-disable-next-line @nx/enforce-module-boundaries -- Internal package-less aviation presentation.
import {
createFlightSurface,
type FlightSurface,
type SurfaceView,
type SurfaceKind,
} from '../../../../../libs/cockpit-a2ui-react/src/index';
export interface A2uiSession extends Omit<AgentSession, 'getSnapshot'> {
getSnapshot(): AgentSnapshot & {
readonly interrupts: readonly unknown[];
readonly subgraphs: readonly unknown[];
readonly history: readonly unknown[] | undefined;
};
load(options?: { readonly signal?: AbortSignal }): Promise<void>;
}
export interface A2uiClient {
createThread(signal: AbortSignal): Promise<string>;
sessionFactory(threadId: string): A2uiSession;
readCheckpoint(checkpoint: Checkpoint, signal: AbortSignal): Promise<unknown>;
}
export interface A2uiSnapshot {
readonly threadId: string | null;
readonly messages: readonly Message[];
readonly surfaces: readonly SurfaceView[];
readonly activeSurface: string | null;
readonly activity:
| 'idle'
| 'creating'
| 'running'
| 'confirming'
| 'replacing';
readonly busy: boolean;
readonly canSubmit: boolean;
readonly error: string | null;
readonly viewGeneration: number;
}
type SurfaceOwner = FlightSurface;
type Conversation = {
id: string;
session: A2uiSession;
release?: () => void;
observation?: Observation;
closed: boolean;
};
/** No streamed assistant row can create presentation or interactive authority. */
export function createA2uiApplication(client: A2uiClient) {
let conversation: Conversation | undefined,
operation: AbortController | undefined;
let active: SurfaceOwner | undefined,
sequence = 0,
revoked = false;
let disposal: Promise<void> | undefined;
const surfaces = new Set<SurfaceOwner>(),
listeners = new Set<{ notify: () => void }>();
const cleanups = new Set<Promise<void>>();
let snapshot: A2uiSnapshot = Object.freeze({
threadId: null,
messages: Object.freeze([]),
surfaces: Object.freeze([]),
activeSurface: null,
activity: 'idle',
busy: false,
canSubmit: true,
error: null,
viewGeneration: 0,
});
function publish(change: Partial<A2uiSnapshot>) {
if (revoked) return;
snapshot = Object.freeze({ ...snapshot, ...change });
for (const listener of [...listeners]) {
if (revoked) break;
if (listeners.has(listener))
try {
listener.notify();
} catch {
/* Observation cannot reject admission. */
}
}
}
function retire(owner: SurfaceOwner) {
owner.dispose();
}
function revokeActive() {
if (active) retire(active);
active = undefined;
}
function track(cleanup: Promise<void>) {
cleanups.add(cleanup);
void cleanup.then(() => cleanups.delete(cleanup));
return cleanup;
}
function detach(stop: boolean) {
const selected = conversation;
conversation = undefined;
if (!selected || selected.closed) return Promise.resolve();
selected.closed = true;
selected.observation = undefined;
selected.release?.();
let stopped: Promise<unknown>;
try {
stopped = stop
? Promise.resolve(selected.session.stop())
: Promise.resolve();
} catch {
stopped = Promise.resolve();
}
return track(
stopped
.catch(() => undefined)
.then(async () => {
try {
await selected.session.dispose();
} catch {
/* An uncertain operation is never replayed. */
}
})
);
}
function prepare(content: string, kind: SurfaceKind, messageId: string) {
const owner: SurfaceOwner = createFlightSurface({
content,
expected: kind,
messageId,
id: 'surface-' + ++sequence,
isCurrent: () =>
!revoked && active === owner && !snapshot.busy && snapshot.canSubmit,
onAction(payload) {
const next: SurfaceKind =
payload.action.name === 'bookingSubmit'
? 'results'
: payload.action.name === 'flightSelect'
? 'confirmation'
: 'booking';
// submit closes the epoch synchronously before transport or observer reentry.
void submit(JSON.stringify(payload), next).catch(() => undefined);
},
});
surfaces.add(owner);
return owner;
}
async function submit(
text: string,
expected: SurfaceKind = 'booking'
): Promise<boolean> {
if (
revoked ||
snapshot.busy ||
!snapshot.canSubmit ||
typeof text !== 'string' ||
!text.trim()
)
return false;
const admitted = new AbortController();
operation = admitted;
// Reentrant observers see the closed epoch and the same busy operation.
revokeActive();
const current = () =>
!revoked && operation === admitted && !admitted.signal.aborted;
publish({
busy: true,
canSubmit: false,
activeSurface: null,
activity: conversation ? 'running' : 'creating',
error: null,
});
try {
if (!current()) return false;
if (!conversation) {
const id = await client.createThread(admitted.signal);
if (!current()) return false;
if (typeof id !== 'string' || !id)
throw Error('Unconfirmed conversation');
const session = client.sessionFactory(id);
if (!current()) {
await track(
Promise.resolve()
.then(() => session.dispose())
.catch(() => undefined)
);
return false;
}
const selected: Conversation = { id, session, closed: false };
conversation = selected;
const release = session.subscribe(() => {
if (
!revoked &&
operation &&
!operation.signal.aborted &&
conversation === selected &&
!selected.closed &&
snapshot.activity === 'running'
)
selected.observation?.observe(session.getSnapshot());
});
selected.release = release;
if (selected.closed) release();
if (!current()) return false;
publish({ threadId: id, activity: 'running' });
}
const selected = conversation;
if (!current() || !selected || selected.closed) return false;
const valid = () =>
current() && conversation === selected && !selected.closed;
const observation = createObservation({
threadId: selected.id,
input: text,
previous: snapshot.messages,
});
selected.observation = observation;
const outcome = await selected.session.submit(text, {
signal: admitted.signal,
});
if (!valid()) return false;
publish({ activity: 'confirming' });
const terminal = await prepareTerminal({
operation: admitted,
observation,
outcome,
getOperation: () => (valid() ? operation : undefined),
getSnapshot: () => selected.session.getSnapshot(),
load: () => selected.session.load({ signal: admitted.signal }),
read: (cp) => client.readCheckpoint(cp, admitted.signal),
});
if (!valid()) return false;
if (terminal.kind !== 'prepared') throw Error('Unconfirmed response');
const messages = terminal.confirmation.messages;
const last = messages.at(-1)!;
let prepared: SurfaceOwner;
try {
prepared = prepare(last.content, expected, last.id);
} catch {
// The saved response remains inspectable as text, with no partial executable tree.
publish({
messages,
error: 'This response is unavailable. Start a new conversation.',
canSubmit: false,
});
return false;
}
if (!valid()) {
retire(prepared);
return false;
}
active = prepared;
selected.observation = undefined;
operation = undefined;
// Transcript, view and authority become observable in one immutable publication.
publish({
messages,
surfaces: Object.freeze([...snapshot.surfaces, prepared.view]),
activeSurface: prepared.view.id,
busy: false,
activity: 'idle',
canSubmit: true,
error: null,
});
return true;
} catch {
if (current())
publish({
canSubmit: false,
error:
'The response could not be confirmed. Start a new conversation.',
});
return false;
} finally {
if (current()) {
operation = undefined;
if (conversation) conversation.observation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
}
async function stop() {
if (revoked || !operation) return;
operation.abort();
operation = undefined;
revokeActive();
const cleanup = detach(true);
publish({
busy: false,
activity: 'idle',
activeSurface: null,
canSubmit: false,
error: 'Stopped. Start a new conversation to continue.',
});
await cleanup;
}
async function newConversation() {
if (revoked) return;
const running = !!operation;
operation?.abort();
revokeActive();
const admitted = new AbortController();
operation = admitted;
const cleanup = detach(running);
for (const owner of surfaces) retire(owner);
surfaces.clear();
publish({
threadId: null,
messages: Object.freeze([]),
surfaces: Object.freeze([]),
activeSurface: null,
busy: true,
activity: 'replacing',
canSubmit: false,
error: null,
viewGeneration: snapshot.viewGeneration + 1,
});
await cleanup;
if (revoked || operation !== admitted || admitted.signal.aborted) return;
operation = undefined;
publish({ busy: false, activity: 'idle', canSubmit: true });
}
/** Main calls this before unmount; owner disposal occurs afterward. */
function revoke() {
if (revoked) return;
revoked = true;
const running = !!operation;
operation?.abort();
operation = undefined;
for (const owner of surfaces) owner.revoke();
conversation?.release?.();
if (conversation) {
conversation.release = undefined;
conversation.observation = undefined;
}
if (running && conversation) {
try {
track(
Promise.resolve(conversation.session.stop()).catch(() => undefined)
);
} catch {
/* Cleanup continues. */
}
}
listeners.clear();
}
function dispose(): Promise<void> {
if (disposal) return disposal;
revoke();
for (const owner of surfaces) retire(owner);
surfaces.clear();
active = undefined;
void detach(false);
disposal = Promise.all([...cleanups]).then(() => undefined);
return disposal;
}
return Object.freeze({
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (revoked) return () => undefined;
const registration = { notify };
listeners.add(registration);
return () => {
listeners.delete(registration);
};
},
submit: (text: string) => submit(text),
stop,
newConversation,
revoke,
dispose,
});
}
export type A2uiApplication = ReturnType<typeof createA2uiApplication>;import type { Message } from '@threadplane/core';
// Keep the terminal proof transport-only: the private leaf avoids the React view barrel.
// eslint-disable-next-line @nx/enforce-module-boundaries -- Same-layout copied private data leaf.
import { copyData, equalData, plainRecord, closedFields } from '../../../../../libs/cockpit-a2ui-react/src/data.js';
export interface Checkpoint {
readonly thread_id: string;
readonly checkpoint_ns: '';
readonly checkpoint_id: string;
readonly checkpoint_map?: Readonly<Record<string, string>>;
}
interface Semantic {
readonly id: string;
readonly role: 'user' | 'assistant';
readonly content: string;
readonly name: string | undefined;
readonly reasoning: string | undefined;
}
export interface Owned {
readonly threadId: string;
readonly previous: readonly Semantic[];
readonly human: Semantic;
readonly generation: string;
}
export interface Confirmation {
readonly checkpoint: Checkpoint;
readonly messages: readonly Message[];
}
const empty = (value: unknown) =>
value === undefined ||
value === null ||
(Array.isArray(value) && value.length === 0);
const text = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
function clean(value: Record<string, unknown>): boolean {
return (
(value.error === undefined || value.error === null) &&
(value.pause === undefined || value.pause === null) &&
(value.paused === undefined || value.paused === false) &&
['interrupts', '__interrupt__', 'subgraphs', 'tasks', 'next'].every((k) =>
empty(value[k])
)
);
}
function semantic(input: unknown): Semantic | undefined {
if (
!plainRecord(input) ||
!closedFields(input, [
'id',
'role',
'content',
'delivery',
'name',
'reasoning',
'citations',
'toolCallId',
'toolCallIds',
]) ||
!text(input.id) ||
!['user', 'assistant'].includes(input.role as string) ||
typeof input.content !== 'string' ||
!empty(input.toolCallId) ||
!empty(input.toolCallIds) ||
!empty(input.citations) ||
(input.name !== undefined && typeof input.name !== 'string') ||
(input.reasoning !== undefined && typeof input.reasoning !== 'string')
)
return;
return Object.freeze({
id: input.id,
role: input.role as 'user' | 'assistant',
content: input.content,
name: input.name as string | undefined,
reasoning: input.reasoning as string | undefined,
});
}
function canonical(input: unknown): readonly Message[] | undefined {
if (!Array.isArray(input)) return;
const seen = new Set<string>();
for (const m of input) {
const s = semantic(m);
if (
!s ||
seen.has(s.id) ||
!plainRecord(m.delivery) ||
!closedFields(m.delivery, ['generation', 'phase', 'outcome']) ||
!text(m.delivery.generation) ||
m.delivery.phase !== 'complete' ||
m.delivery.outcome !== 'success'
)
return;
seen.add(s.id);
}
return input as readonly Message[];
}
export function checkpointSource(
input: unknown,
threadId: string
): Checkpoint | undefined {
try {
const cp = copyData(input);
if (
!plainRecord(cp) ||
!closedFields(cp, [
'thread_id',
'checkpoint_ns',
'checkpoint_id',
'checkpoint_map',
]) ||
cp.thread_id !== threadId ||
cp.checkpoint_ns !== '' ||
!text(cp.checkpoint_id)
)
return;
if (
cp.checkpoint_map !== undefined &&
(!plainRecord(cp.checkpoint_map) ||
!Object.hasOwn(cp.checkpoint_map, '') ||
Object.entries(cp.checkpoint_map).some(
([key, value]) => key !== '' || value !== cp.checkpoint_id
))
)
return;
return cp as unknown as Checkpoint;
} catch {
return;
}
}
export interface Observation {
observe(snapshot: unknown): void;
capture(outcome: unknown, snapshot: unknown): Owned | undefined;
}
/** Created before submit. Only matching human rows observed during that run can be confirmed. */
export function createObservation(input: {
threadId: string;
input: string;
previous: unknown;
}): Observation {
const { threadId, input: humanInput } = input;
let previous: readonly Semantic[] = [],
priorGenerations: readonly string[] = [],
human: Semantic | undefined,
generation: string | undefined,
invalid = false;
try {
const rows = canonical(copyData(input.previous));
if (!rows || !text(threadId) || typeof humanInput !== 'string')
invalid = true;
else {
previous = Object.freeze(rows.map((m) => semantic(m)!));
priorGenerations = rows.map((m) => m.delivery.generation);
}
} catch {
invalid = true;
}
function matching(snapshot: unknown, mayCapture: boolean) {
const state = copyData(snapshot);
if (!plainRecord(state) || !Array.isArray(state.messages)) return false;
const rows = state.messages;
if (
rows.length < previous.length ||
!equalData(rows.slice(0, previous.length).map(semantic), previous)
)
return false;
const suffix = rows.slice(previous.length);
// Before the admitted human appears, ordinary subscription updates retain just the prefix.
if (!suffix.length) return mayCapture && !human;
const first = suffix[0],
candidate = semantic(first);
if (
!candidate ||
candidate.role !== 'user' ||
candidate.content !== humanInput ||
previous.some((m) => m.id === candidate.id) ||
suffix.slice(1).some((m) => plainRecord(m) && m.role === 'user') ||
!plainRecord(first) ||
!plainRecord(first.delivery) ||
!text(first.delivery.generation) ||
priorGenerations.includes(first.delivery.generation)
)
return false;
if (human)
return (
equalData(human, candidate) && generation === first.delivery.generation
);
if (!mayCapture) return false;
human = candidate;
generation = first.delivery.generation;
return true;
}
return Object.freeze({
observe(snapshot: unknown) {
if (!invalid)
try {
if (!matching(snapshot, true)) invalid = true;
} catch {
invalid = true;
}
},
capture(outcome: unknown, snapshot: unknown): Owned | undefined {
try {
const state = copyData(snapshot);
if (
invalid ||
!human ||
!generation ||
outcome !== 'success' ||
!plainRecord(state) ||
state.status !== 'idle' ||
!clean(state) ||
!empty(state.toolCalls) ||
!matching(state, false) ||
!Array.isArray(state.messages) ||
!canonical([state.messages[previous.length]])
)
return;
return Object.freeze({ threadId, previous, human, generation });
} catch {
return;
}
},
});
}
export function captureOwned(input: {
observation: Observation;
outcome: unknown;
snapshot: unknown;
}): Owned | undefined {
return input.observation.capture(input.outcome, input.snapshot);
}
function rawMatches(wire: unknown, message: Message): boolean {
if (
!plainRecord(wire) ||
!clean(wire) ||
wire.id !== message.id ||
wire.content !== message.content ||
wire.type !== (message.role === 'user' ? 'human' : 'ai') ||
!empty(wire.tool_calls) ||
!empty(wire.invalid_tool_calls) ||
!empty(wire.tool_call_chunks) ||
!empty(wire.tool_call_id)
)
return false;
const extra = wire.additional_kwargs ?? {};
if (
!plainRecord(extra) ||
!empty(extra.function_call) ||
!empty(extra.tool_calls) ||
!empty(extra.tool_call_chunks) ||
!empty(extra.citations) ||
!empty(extra.sources)
)
return false;
const name = wire.name === null ? undefined : wire.name;
const reasoning = wire.reasoning ?? extra.reasoning_content;
return name === message.name && reasoning === message.reasoning;
}
/** Raw graph messages and the exact saved root tuple are the only canonical authority. */
export function captureTerminal(
owned: Owned | undefined,
input: unknown,
checkpoint: unknown
): Confirmation | undefined {
try {
if (!owned) return;
const state = copyData(input),
raw = copyData(checkpoint);
if (
!plainRecord(state) ||
!plainRecord(raw) ||
state.status !== 'idle' ||
!clean(state) ||
!empty(state.toolCalls) ||
!Array.isArray(state.interrupts) ||
!Array.isArray(state.subgraphs) ||
!Array.isArray(raw.next) ||
raw.next.length ||
!Array.isArray(raw.tasks) ||
raw.tasks.length ||
!clean(raw) ||
!Array.isArray(state.history) ||
!plainRecord(state.history[0]) ||
!clean(state.history[0]) ||
!Array.isArray(state.history[0].next)
)
return;
const cp = checkpointSource(state.history[0].checkpoint, owned.threadId);
if (!cp || !equalData(cp, checkpointSource(raw.checkpoint, owned.threadId)))
return;
const messages = canonical(state.messages),
values = raw.values;
if (
!messages ||
messages.length !== owned.previous.length + 2 ||
!plainRecord(values) ||
!clean(values) ||
!Array.isArray(values.messages) ||
values.messages.length !== messages.length ||
!equalData(
messages.slice(0, owned.previous.length).map(semantic),
owned.previous
) ||
!equalData(semantic(messages.at(-2)), owned.human) ||
messages.at(-1)?.role !== 'assistant' ||
!messages.at(-1)?.content.trim()
)
return;
const wireMessages = values.messages;
if (!messages.every((m, i) => rawMatches(wireMessages[i], m))) return;
return Object.freeze({ checkpoint: cp, messages });
} catch {
return;
}
}
const unavailable = Object.freeze({ kind: 'unavailable' as const });
export type Prepared =
| typeof unavailable
| { readonly kind: 'prepared'; readonly confirmation: Confirmation };
/** App owner fencing: never load after failed submit, never rescue a failed exact read. */
export async function prepareTerminal(input: {
readonly operation: object;
readonly observation: Observation;
readonly outcome: unknown;
readonly getOperation: () => object | undefined;
readonly getSnapshot: () => unknown;
readonly load: () => Promise<unknown>;
readonly read: (checkpoint: Checkpoint) => Promise<unknown>;
}): Promise<Prepared> {
const operation = input.operation;
try {
if (input.getOperation() !== operation) return unavailable;
const owned = captureOwned({
observation: input.observation,
outcome: input.outcome,
snapshot: input.getSnapshot(),
});
if (!owned) return unavailable;
await input.load();
if (input.getOperation() !== operation) return unavailable;
const reference = input.getSnapshot(),
snapshot = copyData(reference);
if (
!plainRecord(snapshot) ||
!Array.isArray(snapshot.history) ||
!plainRecord(snapshot.history[0])
)
return unavailable;
const cp = checkpointSource(snapshot.history[0].checkpoint, owned.threadId);
if (!cp) return unavailable;
const exact = await input.read(cp);
if (
input.getOperation() !== operation ||
input.getSnapshot() !== reference ||
!equalData(input.getSnapshot(), snapshot)
)
return unavailable;
const confirmation = captureTerminal(owned, snapshot, exact);
return confirmation
? Object.freeze({ kind: 'prepared', confirmation })
: unavailable;
} catch {
return unavailable;
}
}/** Application-private bounded, descriptor-only immutable data capture. */
export type Data =
| undefined
| null
| boolean
| number
| string
| readonly Data[]
| { readonly [key: string]: Data };
const unsafeKeys = new Set(['__proto__', 'prototype', 'constructor']);
export function plainRecord(value: unknown): value is Record<string, Data> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
if (prototype !== null && prototype !== Object.prototype) return false;
return Reflect.ownKeys(value).every((key) => {
if (typeof key !== 'string' || unsafeKeys.has(key)) return false;
const d = Object.getOwnPropertyDescriptor(value, key);
return !!d && 'value' in d && d.enumerable === true;
});
}
export function closedFields(value: unknown, keys: readonly string[]): boolean {
return (
plainRecord(value) && Object.keys(value).every((key) => keys.includes(key))
);
}
export function copyData(value: unknown): Data {
let bytes = 0,
nodes = 0;
const parents = new Set<object>();
function text(value: string) {
if (value.length > 1048576) throw Error('Data byte bound');
bytes += new TextEncoder().encode(value).length;
if (bytes > 1048576) throw Error('Data byte bound');
return value;
}
function visit(value: unknown, depth: number): Data {
if (depth > 64 || ++nodes > 65536) throw Error('Data structure bound');
if (value === null || value === undefined || typeof value === 'boolean')
return value;
if (typeof value === 'string') return text(value);
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'object' || !value) throw Error('Unsupported data');
if (parents.has(value)) throw Error('Cyclic data');
parents.add(value);
try {
if (Array.isArray(value)) {
if (
Object.getPrototypeOf(value) !== Array.prototype ||
value.length > 65536 ||
Reflect.ownKeys(value).length !== value.length + 1
)
throw Error('Invalid array');
const result: Data[] = [];
for (let i = 0; i < value.length; i++) {
const d = Object.getOwnPropertyDescriptor(value, String(i));
if (!d || !('value' in d) || !d.enumerable)
throw Error('Invalid array member');
result.push(visit(d.value, depth + 1));
}
return Object.freeze(result);
}
if (!plainRecord(value)) throw Error('Invalid plain record');
const result: Record<string, Data> = {};
for (const key of Object.keys(value)) {
text(key);
result[key] = visit(
Object.getOwnPropertyDescriptor(value, key)!.value,
depth + 1
);
}
return Object.freeze(result);
} finally {
parents.delete(value);
}
}
return visit(value, 0);
}
/** Structural equality only for capturable finite plain data; key order is immaterial. */
export function equalData(left: unknown, right: unknown): boolean {
try {
const a = copyData(left),
b = copyData(right);
function same(a: Data, b: Data): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) || Array.isArray(b))
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((v, i) => same(v, b[i]))
);
if (!plainRecord(a) || !plainRecord(b)) return false;
return (
Object.keys(a).length === Object.keys(b).length &&
Object.keys(a).every((k) => Object.hasOwn(b, k) && same(a[k], b[k]))
);
}
return same(a, b);
} catch {
return false;
}
}A bounded booking profile
The ten supported views are Column, Row, List, Card, Text, Divider, ChoicePicker, DateTimeInput, TextField and Button. This closed flight example supports origin, destination, departure date, passenger count and fare class. Airports are LAX, JFK, SFO, ORD, BOS, ATL, DFW, SEA, MIA and DEN; fares are Economy, Premium, Business and First. Generic A2UI support for all 18 views remains pending; this preview does not claim full catalog parity or SSR support.
import {
A2UI_BASIC_CATALOG_ID,
createA2uiMessageParser,
} from '@threadplane/a2ui';
import { createA2uiSurfaces } from '@threadplane/content/a2ui';
import type { A2uiSurfacesSnapshot } from '@threadplane/content/a2ui';
import type { RenderSpecData } from '@threadplane/react/render';
import { closedFields, copyData, plainRecord, type Data } from './data.js';
export const AIRPORTS = Object.freeze([
'LAX',
'JFK',
'SFO',
'ORD',
'BOS',
'ATL',
'DFW',
'SEA',
'MIA',
'DEN',
]);
export const FARES = Object.freeze(['Economy', 'Premium', 'Business', 'First']);
export const BOOKING_FIELDS = Object.freeze([
'origin',
'dest',
'date',
'passengers',
'fare_class',
] as const);
export type BookingField = (typeof BOOKING_FIELDS)[number];
export type SurfaceKind = 'booking' | 'results' | 'confirmation';
export type Component = Readonly<Record<string, Data>> & {
readonly id: string;
readonly component: string;
};
export interface PreparedSurface {
readonly identity: object;
readonly surfaceId: SurfaceKind;
readonly prose: string;
readonly rawSnapshot: A2uiSurfacesSnapshot;
readonly model: Readonly<Record<string, Data>>;
readonly components: Readonly<Record<string, Component>>;
readonly spec: RenderSpecData;
readonly sendDataModel: boolean;
token(id: string): object | undefined;
current(id: string, token: object | undefined): boolean;
retireComponent(id: string): void;
dispose(): void;
}
function requireThat(value: unknown): asserts value {
if (!value) throw Error('Unsupported A2UI response');
}
function fields(
value: unknown,
allowed: readonly string[]
): asserts value is Record<string, Data> {
requireThat(closedFields(value, allowed));
}
function id(value: unknown): asserts value is string {
requireThat(
typeof value === 'string' && value.length > 0 && value.length <= 128
);
}
function variant(value: Data, allowed: readonly string[]) {
requireThat(
value === undefined ||
(typeof value === 'string' && allowed.includes(value))
);
}
function path(value: Data): string {
fields(value, ['path']);
requireThat(typeof value.path === 'string');
return value.path;
}
function read(model: Readonly<Record<string, Data>>, p: string): Data {
requireThat(
/^\/[A-Za-z_][A-Za-z_0-9]*$/.test(p) && Object.hasOwn(model, p.slice(1))
);
return model[p.slice(1)];
}
function text(value: Data, model: Readonly<Record<string, Data>>) {
if (typeof value !== 'string')
requireThat(typeof read(model, path(value)) === 'string');
}
function choice(value: Data, allowed: readonly string[]) {
requireThat(
Array.isArray(value) &&
value.length <= 1 &&
value.every((v) => typeof v === 'string' && allowed.includes(v))
);
}
export function assertBookingModel(
model: unknown
): asserts model is Readonly<Record<string, Data>> {
fields(model, BOOKING_FIELDS);
requireThat(Object.keys(model).length === 5);
choice(model.origin, AIRPORTS);
choice(model.dest, AIRPORTS);
choice(model.fare_class, FARES);
requireThat(
typeof model.date === 'string' &&
Number.isSafeInteger(model.passengers) &&
(model.passengers as number) > 0
);
}
function contextValue(
value: Data,
key: string,
model: Readonly<Record<string, Data>>
) {
if (plainRecord(value)) {
const p = path(value);
requireThat(p === '/' + key);
return read(model, p);
}
return value;
}
/** Shared protocol action admission; never evaluates functions. */
export function validateAction(
action: Data,
kind: SurfaceKind,
model: Readonly<Record<string, Data>>
) {
fields(action, ['event']);
fields(action.event, ['name', 'context']);
const event = action.event;
fields(
event.context,
event.name === 'bookingSubmit'
? ['formId', ...BOOKING_FIELDS]
: event.name === 'flightSelect'
? ['flightId']
: ['formId']
);
const ctx = event.context;
if (event.name === 'bookingSubmit') {
requireThat(
kind === 'booking' &&
Object.keys(ctx).length === 6 &&
ctx.formId === 'booking'
);
const values: Record<string, Data> = {};
for (const key of BOOKING_FIELDS)
values[key] = contextValue(ctx[key], key, model);
assertBookingModel(values);
} else if (event.name === 'flightSelect') {
requireThat(kind === 'results' && Object.keys(ctx).length === 1);
id(contextValue(ctx.flightId, 'flightId', model));
} else
requireThat(
event.name === 'modifySearch' &&
kind !== 'booking' &&
Object.keys(ctx).length === 1 &&
ctx.formId === 'booking'
);
}
function validateComponent(
c: Component,
kind: SurfaceKind,
model: Readonly<Record<string, Data>>
) {
id(c.id);
requireThat(typeof c.component === 'string');
const props: Record<string, readonly string[]> = {
Column: ['children'],
Row: ['children'],
List: ['children'],
Card: ['child'],
Text: ['text', 'variant'],
Divider: ['axis'],
Button: ['child', 'variant', 'action', 'checks'],
ChoicePicker: ['label', 'options', 'value', 'variant', 'checks'],
DateTimeInput: ['label', 'value', 'enableDate', 'enableTime', 'checks'],
TextField: ['label', 'value', 'variant', 'checks'],
};
requireThat(Object.hasOwn(props, c.component));
fields(c, ['id', 'component', 'catalogId', ...props[c.component]]);
requireThat(
c.catalogId === undefined || c.catalogId === A2UI_BASIC_CATALOG_ID
);
if (c.checks !== undefined)
requireThat(Array.isArray(c.checks) && !c.checks.length);
switch (c.component) {
case 'Column':
case 'Row':
case 'List':
requireThat(Array.isArray(c.children));
for (const child of c.children) id(child);
break;
case 'Card':
id(c.child);
break;
case 'Text':
text(c.text, model);
variant(c.variant, ['h1', 'h2', 'h3', 'h4', 'h5', 'body', 'caption']);
break;
case 'Divider':
variant(c.axis, ['horizontal', 'vertical']);
break;
case 'Button':
id(c.child);
variant(c.variant, ['default', 'primary', 'borderless']);
validateAction(c.action, kind, model);
break;
case 'ChoicePicker': {
requireThat(kind === 'booking');
text(c.label, model);
variant(c.variant, ['mutuallyExclusive']);
const p = path(c.value);
requireThat(['/origin', '/dest', '/fare_class'].includes(p));
const allowed = p === '/fare_class' ? FARES : AIRPORTS;
choice(read(model, p), allowed);
requireThat(
Array.isArray(c.options) && c.options.length === allowed.length
);
const seen = new Set();
for (const o of c.options) {
fields(o, ['label', 'value']);
requireThat(
typeof o.label === 'string' &&
typeof o.value === 'string' &&
allowed.includes(o.value) &&
!seen.has(o.value)
);
seen.add(o.value);
}
break;
}
case 'DateTimeInput':
requireThat(
kind === 'booking' &&
path(c.value) === '/date' &&
c.enableDate === true &&
(c.enableTime === undefined || c.enableTime === false)
);
text(c.label, model);
break;
case 'TextField':
requireThat(
kind === 'booking' &&
path(c.value) === '/passengers' &&
c.variant === 'number'
);
text(c.label, model);
break;
}
}
function children(c: Component): readonly string[] {
return Array.isArray(c.children)
? (c.children as readonly string[])
: typeof c.child === 'string'
? [c.child]
: [];
}
function validateTree(
all: readonly Component[],
kind: SurfaceKind,
model: Readonly<Record<string, Data>>
) {
requireThat(all.length > 0 && all.length <= 512);
const map: Record<string, Component> = Object.create(null);
for (const c of all) {
fields(c, Object.keys(c));
validateComponent(c, kind, model);
requireThat(!Object.hasOwn(map, c.id));
map[c.id] = c;
}
requireThat(Object.hasOwn(map, 'root') && map.root.component === 'Column');
const visited = new Set<string>();
function visit(key: string, depth: number) {
requireThat(depth <= 64 && Object.hasOwn(map, key) && !visited.has(key));
visited.add(key);
const c = map[key];
if (c.component === 'Button')
requireThat(map[c.child as string]?.component === 'Text');
for (const child of children(c)) visit(child, depth + 1);
}
visit('root', 0);
requireThat(visited.size === all.length);
return Object.freeze(map);
}
function project(map: Readonly<Record<string, Component>>): RenderSpecData {
const elements: Record<string, unknown> = Object.create(null);
for (const c of Object.values(map)) {
const props: Record<string, Data> = Object.create(null);
for (const [key, value] of Object.entries(c)) {
if (
[
'id',
'component',
'catalogId',
'children',
'child',
'action',
'checks',
].includes(key)
)
continue;
props[key] =
plainRecord(value) && typeof value.path === 'string'
? { $bindState: value.path }
: value;
}
if (['Row', 'Column', 'List'].includes(c.component))
props.direction = c.component === 'Row' ? 'horizontal' : 'vertical';
if (c.component === 'Divider') props.axis = c.axis ?? 'horizontal';
if (c.component === 'ChoicePicker')
props.variant = c.variant ?? 'mutuallyExclusive';
if (c.component === 'Button') props.variant = c.variant ?? 'default';
if (c.component === 'Text') props.variant = c.variant ?? 'body';
elements[c.id] = { type: c.component, props, children: children(c) };
}
return copyData({ root: 'root', elements }) as unknown as RenderSpecData;
}
/** Complete canonical assistant payload only. Validate before parser and owner. */
export function prepareSurface(
content: string,
expected: SurfaceKind
): PreparedSurface {
requireThat(['booking', 'results', 'confirmation'].includes(expected));
const delimiter = '---a2ui_JSON---',
at = content.indexOf(delimiter);
requireThat(
at >= 0 &&
(at === 0 || content[at - 1] === '\n') &&
content[at + delimiter.length] === '\n' &&
content.indexOf(delimiter, at + delimiter.length) < 0
);
const payload = content.slice(at + delimiter.length + 1);
requireThat(
payload.endsWith('\n') &&
new TextEncoder().encode(payload).length <= 1048576
);
const messages = payload
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
requireThat(messages.length === 2 || messages.length === 3);
const commands = ['createSurface', 'updateComponents', 'updateDataModel'];
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
fields(m, ['version', commands[i]]);
requireThat(m.version === 'v0.9' && Object.keys(m).length === 2);
const command = m[commands[i]];
fields(
command,
i === 0
? ['surfaceId', 'catalogId', 'sendDataModel']
: i === 1
? ['surfaceId', 'components']
: ['surfaceId', 'path', 'value']
);
requireThat(command.surfaceId === expected);
id(command.surfaceId);
if (i === 0)
requireThat(
command.catalogId === A2UI_BASIC_CATALOG_ID &&
(command.sendDataModel === undefined ||
typeof command.sendDataModel === 'boolean')
);
if (i === 1) requireThat(Array.isArray(command.components));
if (i === 2)
requireThat(
(command.path === undefined || command.path === '/') &&
plainRecord(command.value)
);
}
const captured = copyData(messages) as unknown as typeof messages;
const model =
messages.length === 3
? captured[2].updateDataModel.value
: Object.freeze({});
if (expected === 'booking') assertBookingModel(model);
validateTree(captured[1].updateComponents.components, expected, model);
const owner = createA2uiSurfaces();
try {
const parsed = createA2uiMessageParser().push(payload);
requireThat(parsed.length === messages.length);
for (const message of parsed) owner.apply(message);
const rawSnapshot = owner.getSnapshot();
requireThat(rawSnapshot.surfaces.length === 1);
const raw = rawSnapshot.surfaces[0];
requireThat(raw.surfaceId === expected && plainRecord(raw.dataModel));
const components = validateTree(
raw.components as readonly Component[],
expected,
raw.dataModel
),
spec = project(components);
// Conservative aggregate capture includes duplicated retained representations.
copyData({ captured, rawSnapshot, spec });
let live = true;
const tokens = new Map(
Object.keys(components).map((id) => [id, Object.freeze({})])
);
return Object.freeze({
identity: Object.freeze({}),
surfaceId: expected,
prose: content.slice(0, at),
rawSnapshot,
model: raw.dataModel,
components,
spec,
sendDataModel: raw.sendDataModel === true,
token: (id: string) => tokens.get(id),
current: (id: string, token: object | undefined) =>
live && token !== undefined && tokens.get(id) === token,
retireComponent: (id: string) => {
tokens.delete(id);
},
dispose: () => {
if (live) {
live = false;
tokens.clear();
owner.dispose();
}
},
});
} catch (error) {
owner.dispose();
throw error;
}
}import type { RenderState } from '@threadplane/content/render-state';
import { equalData, plainRecord } from './data.js';
import {
AIRPORTS,
FARES,
assertBookingModel,
type BookingField,
type PreparedSurface,
} from './protocol.js';
export interface BookingData {
readonly origin: readonly string[];
readonly dest: readonly string[];
readonly fare_class: readonly string[];
readonly date: string;
readonly passengers: number;
}
export interface BookingSnapshot {
readonly model: BookingData;
readonly drafts: Readonly<Record<BookingField, string>>;
readonly errors: Readonly<Partial<Record<BookingField, string>>>;
readonly valid: boolean;
}
export interface BookingForm {
readonly identity: object;
getSnapshot(): BookingSnapshot;
subscribe(notify: () => void): () => void;
edit(componentId: string, draft: string): void;
captureEdit(componentId: string): (draft: string) => void;
canSubmit(): boolean;
dispose(): void;
}
/** Calendar validation uses only lexical integers, never local time or today's date. */
function calendar(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split('-').map(Number);
const days = [
31,
year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
return (
year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1]
);
}
function passenger(value: string) {
return (
/^\d+$/.test(value) &&
Number.isSafeInteger(Number(value)) &&
Number(value) > 0
);
}
function errorsFor(
model: BookingData,
drafts: Readonly<Record<BookingField, string>>
) {
const errors: Partial<Record<BookingField, string>> = {};
if (!AIRPORTS.includes(drafts.origin))
errors.origin = 'Choose an origin airport.';
if (!AIRPORTS.includes(drafts.dest))
errors.dest = 'Choose a destination airport.';
else if (drafts.origin === drafts.dest)
errors.dest = 'Choose different origin and destination airports.';
if (!FARES.includes(drafts.fare_class))
errors.fare_class = 'Choose a fare class.';
if (!calendar(drafts.date)) errors.date = 'Enter a real date as YYYY-MM-DD.';
if (!passenger(drafts.passengers))
errors.passengers = 'Enter a positive safe whole number.';
// Each valid draft must describe exactly the same typed authority.
if (!errors.passengers && Number(drafts.passengers) !== model.passengers)
errors.passengers = 'Passenger value changed outside this form.';
for (const field of ['origin', 'dest', 'fare_class'] as const)
if (
!errors[field] &&
(model[field].length !== 1 || model[field][0] !== drafts[field])
)
errors[field] = 'Selection changed outside this form.';
if (!errors.date && drafts.date !== model.date)
errors.date = 'Date changed outside this form.';
return Object.freeze(errors);
}
/** Borrow one application-owned render state. This facade alone publishes validity. */
export function createBookingForm(
surface: PreparedSurface,
state: RenderState
): BookingForm {
if (
surface.surfaceId !== 'booking' ||
!equalData(surface.model, state.getSnapshot())
)
throw Error('Booking owner mismatch');
assertBookingModel(state.getSnapshot());
const initial = state.getSnapshot() as unknown as BookingData;
const controls = new Map<string, { field: BookingField; token: object }>();
for (const c of Object.values(surface.components)) {
if (
!['ChoicePicker', 'DateTimeInput', 'TextField'].includes(c.component) ||
!plainRecord(c.value)
)
continue;
const field = String(c.value.path).slice(1) as BookingField;
controls.set(c.id, { field, token: surface.token(c.id)! });
}
const root = surface.token('root');
let live = true,
busy = false,
writing = false,
drift = false;
const queue: Array<{ id: string; token: object | undefined; draft: string }> =
[];
const registrations = new Set<{ notify: () => void }>();
function snapshot(
model: BookingData,
drafts: Readonly<Record<BookingField, string>>
): BookingSnapshot {
const errors = drift
? Object.freeze({
...errorsFor(model, drafts),
passengers:
'Form data changed outside this form. Start a new conversation.',
})
: errorsFor(model, drafts);
return Object.freeze({
model,
drafts: Object.freeze(drafts),
errors,
valid: !drift && Object.keys(errors).length === 0,
});
}
let current = snapshot(initial, {
origin: initial.origin[0] ?? '',
dest: initial.dest[0] ?? '',
fare_class: initial.fare_class[0] ?? '',
date: initial.date,
passengers: String(initial.passengers),
});
function eligible() {
return (
live &&
!drift &&
surface.current('root', root) &&
[...controls].every(([id, c]) => surface.current(id, c.token))
);
}
function publish() {
for (const r of [...registrations]) {
if (!live) break;
if (registrations.has(r))
try {
r.notify();
} catch {
/* An observer cannot reject a command. */
}
}
}
const unsubscribe = state.subscribe(() => {
if (!live || writing) return;
// An independent writer violates the borrowed form contract. Retire authority.
drift = true;
current = snapshot(current.model, current.drafts);
const wasBusy = busy;
busy = true;
try {
publish();
} finally {
busy = wasBusy;
}
});
function run(id: string, token: object | undefined, draft: string) {
if (!eligible() || typeof draft !== 'string') return;
queue.push({ id, token, draft });
if (busy) return;
busy = true;
try {
while (queue.length && eligible()) {
const command = queue.shift()!,
control = controls.get(command.id);
if (
!control ||
control.token !== command.token ||
!surface.current(command.id, command.token)
)
continue;
const { field } = control,
c = surface.components[command.id],
value = command.draft;
if (current.drafts[field] === value) continue;
let accepted = false,
typed: unknown;
if (field === 'passengers') {
accepted = passenger(value);
typed = Number(value);
} else if (field === 'date') {
accepted = calendar(value);
typed = value;
} else {
const allowed = field === 'fare_class' ? FARES : AIRPORTS;
const options = Array.isArray(c.options) ? c.options : [];
accepted =
value === '' ||
(allowed.includes(value) &&
options.some((o) => plainRecord(o) && o.value === value));
typed = value === '' ? [] : [value];
}
const drafts = Object.freeze({ ...current.drafts, [field]: value });
if (accepted) {
writing = true;
try {
state.set('/' + field, typed);
} finally {
writing = false;
}
if (
!equalData(state.getSnapshot(), {
...current.model,
[field]: typed,
})
) {
drift = true;
current = snapshot(current.model, drafts);
publish();
break;
}
}
if (!eligible()) break;
current = snapshot(
state.getSnapshot() as unknown as BookingData,
drafts
);
publish();
}
} finally {
queue.length = 0;
busy = false;
}
}
return Object.freeze({
identity: surface.identity,
getSnapshot: () => current,
subscribe(notify: () => void) {
if (!live) return () => undefined;
const registration = { notify };
registrations.add(registration);
return () => {
registrations.delete(registration);
};
},
edit: (id: string, draft: string) =>
run(id, controls.get(id)?.token, draft),
captureEdit(id: string) {
const token = controls.get(id)?.token;
return (draft: string) => run(id, token, draft);
},
canSubmit: () =>
!busy &&
eligible() &&
Object.is(state.getSnapshot(), current.model) &&
current.valid,
dispose() {
if (live) {
live = false;
queue.length = 0;
registrations.clear();
unsubscribe();
}
},
});
}import type { A2uiActionMessage } from '@threadplane/a2ui';
import { copyData, equalData, plainRecord, type Data } from './data.js';
import { validateAction, type PreparedSurface } from './protocol.js';
import type { BookingForm } from './form.js';
export interface ActionOptions {
readonly form?: BookingForm;
readonly timestamp: () => string;
readonly dispatch: (payload: Readonly<A2uiActionMessage>) => void;
}
export interface Actions {
capability(componentId: string): () => boolean;
dispose(): void;
}
/** Captures event data only; the application owns admission and transport. */
export function createActions(
surface: PreparedSurface,
options: ActionOptions
): Actions {
const { form, timestamp: clock, dispatch } = options;
if (form && form.identity !== surface.identity)
throw Error('Action form epoch mismatch');
if (surface.surfaceId === 'booking' && !form)
throw Error('Booking action needs its form facade');
let live = true,
activating = false;
return Object.freeze({
capability(id: string) {
const token = surface.token(id),
component = surface.components[id];
const labelId = String(component?.child),
labelToken = surface.token(labelId);
return () => {
if (
!live ||
activating ||
!surface.current(id, token) ||
!surface.current(labelId, labelToken) ||
component?.component !== 'Button'
)
return false;
if (form && !form.canSubmit()) return false;
const snapshot = form?.getSnapshot();
const model = (snapshot?.model ?? surface.model) as unknown as Readonly<
Record<string, Data>
>;
validateAction(component.action, surface.surfaceId, model);
if (
!plainRecord(component.action) ||
!plainRecord(component.action.event) ||
!plainRecord(component.action.event.context)
)
return false;
const event = component.action.event,
context: Record<string, Data> = {};
const authoredContext = event.context as Readonly<Record<string, Data>>;
for (const [key, value] of Object.entries(authoredContext))
context[key] = plainRecord(value)
? model[String(value.path).slice(1)]
: value;
// A literal booking value cannot override a later visible edit.
if (
event.name === 'bookingSubmit' &&
!equalData(
{ ...context, formId: undefined },
{ ...model, formId: undefined }
)
)
return false;
const child = surface.components[String(component.child)];
const authored = child?.component === 'Text' ? child.text : undefined;
const label = plainRecord(authored)
? model[String(authored.path).slice(1)]
: authored;
activating = true;
try {
const timestamp = clock();
if (
typeof timestamp !== 'string' ||
!timestamp ||
!Number.isFinite(Date.parse(timestamp))
)
throw Error('Invalid action timestamp');
const payload = copyData({
version: 'v0.9',
action: {
name: event.name,
surfaceId: surface.surfaceId,
sourceComponentId: id,
timestamp,
context,
...(typeof label === 'string' ? { label } : {}),
},
...(surface.sendDataModel
? {
metadata: {
a2uiClientDataModel: {
surfaces: { [surface.surfaceId]: model },
},
},
}
: {}),
}) as unknown as Readonly<A2uiActionMessage>;
// Clock/capture callbacks cannot resurrect retired or changed authority.
if (
!live ||
!surface.current(id, token) ||
!surface.current(labelId, labelToken) ||
(form && (!form.canSubmit() || form.getSnapshot() !== snapshot))
)
return false;
dispatch(payload);
return true;
} finally {
activating = false;
}
};
},
dispose() {
live = false;
},
});
}import {
Children,
createContext,
useContext,
useId,
useRef,
useState,
} from 'react';
import { useAgent } from '@threadplane/react';
import {
RenderSpec,
type ReactRenderRegistry,
type RenderValue,
type RenderViewProps,
} from '@threadplane/react/render';
import type { SurfaceView } from './surface';
import type { BookingField } from './protocol';
import type { BookingSnapshot } from './form';
const empty = Object.freeze({
model: Object.freeze({}),
drafts: Object.freeze({}),
errors: Object.freeze({}),
valid: true,
});
const absentForm = Object.freeze({
getSnapshot: () => empty,
subscribe: () => () => undefined,
});
interface Borrowed {
readonly surface: SurfaceView;
readonly form: BookingSnapshot | typeof empty;
readonly disabled: boolean;
readonly showErrors: boolean;
readonly focusInvalid: () => void;
}
const Context = createContext<Borrowed | null>(null);
function useSurface() {
const context = useContext(Context);
if (!context) throw Error('Missing surface presentation');
return context;
}
const text = (value: RenderValue) =>
typeof value === 'string' || typeof value === 'number' ? String(value) : '';
function Column({ children }: RenderViewProps) {
return <div className="a2ui-column">{children}</div>;
}
function Row({ children }: RenderViewProps) {
return <div className="a2ui-row">{children}</div>;
}
function List({ children }: RenderViewProps) {
return (
<ul className="a2ui-list">
{Children.map(children, (child) => (
<li>{child}</li>
))}
</ul>
);
}
function Card({ children }: RenderViewProps) {
return <section className="a2ui-card">{children}</section>;
}
function Text({ props }: RenderViewProps) {
const value = text(props.text);
switch (props.variant) {
case 'h1':
return <h1>{value}</h1>;
case 'h2':
return <h2>{value}</h2>;
case 'h3':
return <h3>{value}</h3>;
case 'h4':
return <h4>{value}</h4>;
case 'h5':
return <h5>{value}</h5>;
case 'caption':
return <small>{value}</small>;
default:
return <span>{value}</span>;
}
}
function Divider({ props }: RenderViewProps) {
return (
<div
role="separator"
aria-orientation={props.axis === 'vertical' ? 'vertical' : 'horizontal'}
className="a2ui-divider"
/>
);
}
function Control({
props,
bindings,
elementKey,
kind,
}: RenderViewProps & { readonly kind: 'choice' | 'date' | 'number' }) {
const { surface, form, disabled, showErrors } = useSurface();
const field = (bindings?.value ?? '').slice(1) as BookingField;
// Binding admission and field drafts belong to the application facade.
const drafts = form.drafts as Readonly<Partial<Record<BookingField, string>>>;
const errors = form.errors as Readonly<Partial<Record<BookingField, string>>>;
const [touchedOwner, setTouchedOwner] = useState<object | null>(null);
const owner = surface.form ?? surface;
const initial = surface.model[field];
const initialDraft = Array.isArray(initial)
? text(initial[0])
: text(initial);
const edited = touchedOwner === owner || drafts[field] !== initialDraft;
const error = showErrors || edited ? errors[field] : undefined;
const generated = useId();
const id = generated + '-control',
errorId = generated + '-error';
const shared = {
id,
disabled,
value: drafts[field] ?? '',
'aria-invalid': !!error,
'data-invalid': !!errors[field],
'aria-describedby': error ? errorId : undefined,
onChange: (event: { currentTarget: { value: string } }) => {
if (!disabled) {
setTouchedOwner(owner);
surface.edits[elementKey]?.(event.currentTarget.value);
}
},
onBlur: () => {
if (!disabled) setTouchedOwner(owner);
},
};
return (
<div className="a2ui-field">
<label htmlFor={id}>{text(props.label)}</label>
{kind === 'choice' ? (
<select {...shared}>
<option value="">Choose…</option>
{Array.isArray(props.options) &&
props.options.map((option) => {
const item = option as Readonly<Record<string, RenderValue>>;
return (
<option key={text(item.value)} value={text(item.value)}>
{text(item.label)}
</option>
);
})}
</select>
) : (
<input
{...shared}
type={kind}
{...(kind === 'number'
? { min: 1, step: 1, inputMode: 'numeric' as const }
: {})}
/>
)}
{error && (
<p id={errorId} role="alert" className="a2ui-error">
{error}
</p>
)}
</div>
);
}
function ChoicePicker(props: RenderViewProps) {
return <Control {...props} kind="choice" />;
}
function DateTimeInput(props: RenderViewProps) {
return <Control {...props} kind="date" />;
}
function TextField(props: RenderViewProps) {
return <Control {...props} kind="number" />;
}
function Button({ props, children, elementKey }: RenderViewProps) {
const { surface, disabled, focusInvalid } = useSurface();
return (
<button
type="button"
disabled={disabled}
className="a2ui-button"
data-variant={text(props.variant)}
onClick={() => {
if (!disabled && !surface.actions[elementKey]?.()) focusInvalid();
}}
>
{children}
</button>
);
}
/** Components stay stable across immutable model snapshots and React render replay. */
export const a2uiRegistry: ReactRenderRegistry = Object.freeze({
Column,
Row,
List,
Card,
Text,
Divider,
ChoicePicker,
DateTimeInput,
TextField,
Button,
});
/** A borrowed presentation lifetime never owns or disposes an application resource. */
export function A2uiSurface({
surface,
active,
}: {
readonly surface: SurfaceView;
readonly active: boolean;
}) {
const form = useAgent<BookingSnapshot | typeof empty>(
surface.form ?? absentForm
);
const container = useRef<HTMLElement>(null);
const [errorsOwner, setErrorsOwner] = useState<object | null>(null);
const owner = surface.form ?? surface;
return (
<section
ref={container}
className="a2ui-surface"
aria-label={active ? 'Current flight preview' : 'Earlier flight preview'}
>
{!active && <p className="a2ui-history">Earlier response · read only</p>}
{surface.prose && <p className="a2ui-prose">{surface.prose}</p>}
<Context.Provider
value={{
surface,
form,
disabled: !active,
showErrors: errorsOwner === owner,
focusInvalid: () => {
setErrorsOwner(owner);
container.current
?.querySelector<HTMLElement>('[data-invalid="true"]')
?.focus();
},
}}
>
<RenderSpec
spec={surface.spec}
registry={a2uiRegistry}
state={surface.form ? form.model : surface.model}
/>
</Context.Provider>
<details className="a2ui-wire">
<summary>Response details</summary>
<pre>{surface.wire}</pre>
</details>
</section>
);
}Stop and recover
Stop, a run or connection error, or an unconfirmed outcome requires New conversation before continuing. Ordinary form-validation errors can be corrected in the current form. The app never automatically resubmits an action or resumes an uncertain turn. The shared graph may internally retry structured model output. Modify restores the search values supplied by the server, including the last submitted route and party, rather than recovering browser-local edits.
"""
A2UI Aviation Booking Form Graph
LLM-authored A2UI surfaces:
- build_form: emits a flight-booking form via gpt-5 with structured output
- search_flights: post-submit, calls find_routes() and emits a results surface
Both surfaces are constrained by Pydantic schemas (A2uiComponent +
BookingFormSpec / FlightResultsSpec) and validated; on ValidationError,
the LLM is re-prompted with the error up to 2 retries. After 3 total
attempts, a hardcoded sentinel form is emitted so the demo doesn't 500.
This replaces the prior hardcoded contact-form implementation. The
prior file's claim that "LLMs cannot reliably emit A2UI JSONL" is
disproven by schema-constrained structured output — the LLM authors
the components list, code wraps it in the deterministic envelope keys.
"""
import json
import logging
import os
import re
from typing import Any, Literal
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.types import Command
from langgraph_sdk import get_client
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
# Inlined flight fixtures - standalone has no aviation_data module.
_FLIGHTS = [
{"flight_number": "UA123", "airline": "UA", "from": "LAX", "to": "JFK",
"depart_local": "08:00", "arrive_local": "16:30", "duration_min": 330,
"status": "on_time", "gate": "B14", "aircraft": "Boeing 787"},
{"flight_number": "AA456", "airline": "AA", "from": "JFK", "to": "LAX",
"depart_local": "10:00", "arrive_local": "13:30", "duration_min": 390,
"status": "on_time", "gate": "T5-22", "aircraft": "Boeing 777"},
{"flight_number": "DL789", "airline": "DL", "from": "ATL", "to": "ORD",
"depart_local": "07:15", "arrive_local": "08:45", "duration_min": 150,
"status": "delayed", "gate": "A12", "aircraft": "Airbus A320"},
{"flight_number": "B6101", "airline": "B6", "from": "BOS", "to": "MIA",
"depart_local": "06:30", "arrive_local": "10:15", "duration_min": 225,
"status": "on_time", "gate": "C8", "aircraft": "Airbus A321"},
{"flight_number": "UA204", "airline": "UA", "from": "SFO", "to": "SEA",
"depart_local": "09:00", "arrive_local": "11:00", "duration_min": 120,
"status": "on_time", "gate": "F11", "aircraft": "Boeing 737"},
]
class _AsyncFn:
def __init__(self, fn):
self._fn = fn
async def ainvoke(self, args):
return self._fn(**args)
def _find_routes_impl(from_code, to_code, date_offset_days=0):
return [f for f in _FLIGHTS if f["from"] == from_code and f["to"] == to_code]
find_routes = _AsyncFn(_find_routes_impl)
def _lookup_flight_impl(flight_number: str) -> dict | None:
"""Inline mirror of aviation_tools.lookup_flight for the standalone."""
return next((f for f in _FLIGHTS if f["flight_number"] == flight_number), None)
lookup_flight = _AsyncFn(_lookup_flight_impl)
_logger = logging.getLogger(__name__)
A2UI_PREFIX = "---a2ui_JSON---"
# 10 IATA airports from aviation_data.py
AIRPORT_CODES = ["LAX", "JFK", "SFO", "ORD", "BOS", "ATL", "DFW", "SEA", "MIA", "DEN"]
FARE_CLASSES = ["Economy", "Premium", "Business", "First"]
# Catalog component names — A2UI v0.9 basic catalog
# (https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json).
# `component` must be one of these names; an unknown name renders nothing
# visible — silent failure mode, hence the field_validator below.
ALLOWED_COMPONENTS = frozenset({
"AudioPlayer", "Button", "Card", "CheckBox", "ChoicePicker", "Column",
"DateTimeInput", "Divider", "Icon", "Image", "List", "Modal", "Row",
"Slider", "Tabs", "Text", "TextField", "Video",
})
# ── Pydantic schemas ────────────────────────────────────────────────────────
# region component-schema
class A2uiComponent(BaseModel):
"""Single A2UI v0.9 updateComponents entry.
Components are FLAT — the catalog component name is the `component`
string field and the per-component props sit at the top level of the
same object:
{id: "name_field", component: "TextField",
label: "Name", value: {path: "/name"}}
Dynamic values are bare literals or {"path": "/json/pointer"} bindings.
Children are plain id arrays (or {path, componentId} templates).
Actions are {"event": {"name": "...", "context": {...}}} where context
is a plain object.
Key per-component notes the LLM must respect:
Card({child: "<id>"}) — single child only
Button({child: "<text-id>", action: {...}}) — child is a Text id (label)
Column/Row/List({children: ["id1", "id2"]})
TextField({label, value: {path:"/p"}, variant: "shortText"|"number"|...})
ChoicePicker({label, options:[{label,value}], value:{path}, variant:"mutuallyExclusive"})
DateTimeInput({label, value:{path:"/p"}, enableDate: true})
Text({text: "literal or {path:'/p'}", variant?: "h1"|"h2"|"body"|...})
Divider({})
"""
model_config = {"extra": "allow"}
id: str
component: str = Field(
description=(
"Catalog component name. Must be one of: "
+ ", ".join(sorted(ALLOWED_COMPONENTS))
),
)
@field_validator("component")
@classmethod
def _known_component(cls, v: str) -> str:
if v not in ALLOWED_COMPONENTS:
raise ValueError(
f"component '{v}' not in catalog. Allowed: {sorted(ALLOWED_COMPONENTS)}"
)
return v
# endregion
# region surface-spec
class _SurfaceSpec(BaseModel):
"""Common shape — both booking and results surfaces produce the same
triple (surface_id, data_model, components)."""
surface_id: str = Field(description="Surface id. Use 'booking' for the form, 'results' for flights.")
data_model: dict[str, Any] = Field(description="Initial form/state values, e.g. prefills.")
components: list[A2uiComponent]
class BookingFormSpec(_SurfaceSpec):
@model_validator(mode="after")
def _booking_picker_domains(self) -> "BookingFormSpec":
for component_id, path, domain in (
("origin", "/origin", AIRPORT_CODES),
("dest", "/dest", AIRPORT_CODES),
("fare", "/fare_class", FARE_CLASSES),
):
# Count both the intended id and any picker claiming its binding.
# Inspect extras before comparing domains: malformed values may be
# dictionaries/lists, and must raise ValidationError, not TypeError.
matches = []
for component in self.components:
binding = getattr(component, "value", None)
if component.id == component_id or (
component.component == "ChoicePicker"
and isinstance(binding, dict)
and binding.get("path") == path
):
matches.append(component)
if len(matches) != 1:
raise ValueError(
f"Booking picker '{component_id}' must occur exactly once "
f"as ChoicePicker bound to {path}"
)
component = matches[0]
binding = getattr(component, "value", None)
if (
component.id != component_id
or component.component != "ChoicePicker"
or not isinstance(binding, dict)
or binding.get("path") != path
):
raise ValueError(
f"Booking picker '{component_id}' must be a ChoicePicker "
f"with value={{'path': '{path}'}}"
)
options = getattr(component, "options", None)
if not isinstance(options, list) or any(
not isinstance(option, dict)
or not isinstance(option.get("label"), str)
or not isinstance(option.get("value"), str)
for option in options
):
raise ValueError(
f"Booking picker '{component_id}' options must be a list "
"of objects with string labels and values"
)
values = [option["value"] for option in options]
if len(values) != len(domain) or set(values) != set(domain):
raise ValueError(
f"Booking picker '{component_id}' must contain each value "
f"exactly once from {domain}; got {values}"
)
return self
class FlightResultsSpec(_SurfaceSpec):
pass
class ConfirmationSpec(_SurfaceSpec):
"""Booking confirmation surface — selected flight + prior party context."""
pass
# endregion
# ── Envelope wrapping ───────────────────────────────────────────────────────
# region envelope-wrapping
# A2UI v0.9 wire format (a2ui.org server_to_client.json): every envelope
# carries "version": "v0.9". Order matters: createSurface first (surfaceId +
# catalogId), then updateComponents (flat components; exactly one has id
# "root", which is the root of the tree), then updateDataModel (path + value)
# as needed. There is no beginRendering envelope in v0.9.
CATALOG_ID = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
def _wrap_envelopes(spec: _SurfaceSpec) -> str:
"""Wrap a validated SurfaceSpec into A2UI v0.9 JSONL."""
lines = [
json.dumps({"version": "v0.9", "createSurface": {
"surfaceId": spec.surface_id,
"catalogId": CATALOG_ID,
}}),
json.dumps({"version": "v0.9", "updateComponents": {
"surfaceId": spec.surface_id,
"components": [c.model_dump(exclude_none=True) for c in spec.components],
}}),
]
if spec.data_model:
lines.append(json.dumps({"version": "v0.9", "updateDataModel": {
"surfaceId": spec.surface_id,
"path": "/",
"value": spec.data_model,
}}))
return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n"
# endregion
# ── LLM + retry ─────────────────────────────────────────────────────────────
# gpt-5 with low reasoning effort: PR #372 established gpt-5 follows
# directive precisely; "low" gives slightly more headroom than "minimal"
# for schema compliance.
_llm: ChatOpenAI | None = None
def _get_llm() -> ChatOpenAI:
"""Lazy-initialize the LLM so imports succeed without OPENAI_API_KEY set."""
global _llm
if _llm is None:
_llm = ChatOpenAI(
model="gpt-5",
streaming=True,
reasoning_effort="low",
)
return _llm
async def _emit_with_retry(
spec_cls: type[_SurfaceSpec],
base_messages: list[Any],
max_attempts: int = 3,
) -> _SurfaceSpec:
"""Call the LLM with structured output, retrying on validation failure.
Each retry re-injects the error message so the model has a chance
to correct its output. After max_attempts, raises RuntimeError.
"""
# method="function_calling" is required because our schema uses
# `dict[str, Any]` fields (value/selected/checked/checks/action) for
# A2UI binding payloads. OpenAI's default strict structured-output mode
# demands additionalProperties=false on every nested object and rejects
# open dicts. Function-calling mode is more flexible and the model
# still adheres to the rest of the schema (especially the Literal[...]
# on component type, which is the actual safety gate we need).
llm = _get_llm().with_structured_output(spec_cls, method="function_calling")
messages = list(base_messages)
last_err: Exception | None = None
for attempt in range(max_attempts):
try:
return await llm.ainvoke(messages)
except ValidationError as err:
last_err = err
_logger.warning(
"A2UI structured-output validation failed (attempt %d/%d): %s",
attempt + 1, max_attempts, err,
)
messages = list(base_messages) + [
AIMessage(content=(
f"Previous attempt failed schema validation: {err}. "
"Try again, strictly matching the schema. "
"Do not invent component types outside the Literal list."
)),
]
raise RuntimeError(
f"LLM failed structured output after {max_attempts} attempts: {last_err}"
)
# ── build_form node ─────────────────────────────────────────────────────────
_AIRPORT_OPTIONS = [{"label": c, "value": c} for c in AIRPORT_CODES]
_FARE_OPTIONS = [{"label": c, "value": c} for c in FARE_CLASSES]
# The form's data_model carries the field default values. On first turn this
# is blank; on a "Modify search" turn (after Select → confirmation surface →
# Modify search) the user expects to see their prior origin/dest/date/
# passengers/fare_class already populated. `build_form` walks message history
# via `_extract_prior_submit_context` and substitutes those values into
# {data_model_json} below.
# ChoicePicker values bind to string ARRAYS in the data model (v0.9
# DynamicStringList), so the picker-backed keys hold lists; the server
# scalarizes them back to plain strings on action receipt (_scalarize).
_BLANK_FORM_DEFAULTS: dict[str, Any] = {
"origin": [], "dest": [], "date": "", "passengers": 1, "fare_class": ["Economy"],
}
# Keys rendered by a ChoicePicker — their data_model values are string arrays.
_CHOICE_KEYS = ("origin", "dest", "fare_class")
# `__DATA_MODEL_DEFAULTS__` is a non-brace sentinel substituted at call-time
# by `build_form()` via `str.replace()` — using `.format()` would conflict
# with the many literal-brace JSON examples below.
_BUILD_FORM_SYSTEM_TMPL = f"""You are an aviation booking-form designer. Emit an A2UI v0.9 booking form using the structured output schema.
A2UI FORMAT (CRITICAL): each component is FLAT — `{{"id": "...", "component": "<ComponentName>", <props...>}}`. The component name is a plain string and the props sit at the top level of the same object. ComponentName must be one of:
Column, Row, Card, Text, TextField, ChoicePicker, DateTimeInput, CheckBox, Button, Divider, List, Image, Icon, Modal, Slider, Tabs
Dynamic values are bare literals ("LAX", 3, true) or data bindings `{{"path": "/json/pointer"}}`. Never wrap literals.
Per-component shapes:
Column / Row / List: {{"children": ["id1", "id2"]}}
Card: {{"child": "<id>"}} ← single child only
Button: {{"child": "<text-id>", "variant": "primary", "action": {{"event": {{"name": "<eventName>", "context": {{"formId": "booking"}}}}}}}}
Text: {{"text": "literal string", "variant": "h2"}} (h1/h2/h3/h4/h5/caption/body)
TextField: {{"label": "Field", "value": {{"path": "/p"}}, "variant": "shortText"}} (shortText/longText/number/obscured)
DateTimeInput: {{"label": "Date", "value": {{"path": "/p"}}, "enableDate": true}}
ChoicePicker: {{"label": "Origin", "options": [{{"label":"LAX","value":"LAX"}}], "value": {{"path":"/origin"}}, "variant": "mutuallyExclusive"}}
CheckBox: {{"label": "...", "value": {{"path":"/p"}}}}
Divider: {{}}
Exactly ONE component MUST have id "root" — it is the root of the tree.
Required form composition for THIS task:
surface_id MUST be "booking"
data_model MUST be __DATA_MODEL_DEFAULTS__ ← use these values verbatim as the field defaults
Build this component tree:
root (Column, children=["card"])
card (Card, child=card_col)
card_col (Column, children=[title, origin, dest, date, passengers, fare, submit])
title (Text, text="Book a flight", variant="h2")
origin (ChoicePicker, label="Origin", options={_AIRPORT_OPTIONS}, value={{"path":"/origin"}}, variant="mutuallyExclusive")
dest (ChoicePicker, label="Destination", options={_AIRPORT_OPTIONS}, value={{"path":"/dest"}}, variant="mutuallyExclusive")
date (DateTimeInput, label="Departure date (YYYY-MM-DD)", value={{"path":"/date"}}, enableDate=true)
passengers (TextField, label="Passengers", value={{"path":"/passengers"}}, variant="number")
fare (ChoicePicker, label="Fare class", options={_FARE_OPTIONS}, value={{"path":"/fare_class"}}, variant="mutuallyExclusive")
submit (Button, child=submit_label, variant="primary", action={{"event":{{"name":"bookingSubmit","context":{{
"formId":"booking",
"origin":{{"path":"/origin"}},
"dest":{{"path":"/dest"}},
"date":{{"path":"/date"}},
"passengers":{{"path":"/passengers"}},
"fare_class":{{"path":"/fare_class"}}
}}}}}})
submit_label (Text, text="Search flights")
Use these exact ids."""
def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent:
"""Tiny helper so the sentinels read naturally."""
return A2uiComponent(id=id_, component=name, **props)
def _form_defaults_from_prior(prior: dict[str, Any]) -> dict[str, Any]:
"""Project prior bookingSubmit context onto the form's data_model schema.
Falls back to blanks for any missing key so the returned dict always has
the full {origin, dest, date, passengers, fare_class} shape. Prior context
values are scalars; ChoicePicker-backed keys are re-wrapped into the
string-array shape their data-model binding expects."""
defaults = dict(_BLANK_FORM_DEFAULTS)
for key in defaults:
if key in prior and prior[key] not in (None, "", []):
value = prior[key]
if key in _CHOICE_KEYS and not isinstance(value, list):
value = [value]
defaults[key] = value
# Normalize passengers to an int (prior context may carry it as float).
p = defaults.get("passengers")
if isinstance(p, (int, float)):
defaults["passengers"] = int(p)
return defaults
def _build_sentinel_booking_form(defaults: dict[str, Any]) -> BookingFormSpec:
"""Hardcoded fallback form when LLM emit retry exhausts. Accepts the same
`defaults` dict that the LLM-prompt path uses, so the sentinel respects
Modify-search prefill too."""
return BookingFormSpec(
surface_id="booking",
data_model=defaults,
components=[
_comp("root", "Column", {"children": ["card"]}),
_comp("card", "Card", {"child": "card_col"}),
_comp("card_col", "Column", {"children": [
"title", "origin", "dest", "date", "passengers", "fare", "submit",
]}),
_comp("title", "Text", {"text": "Book a flight (fallback)", "variant": "h2"}),
_comp("origin", "ChoicePicker", {"label": "Origin", "options": _AIRPORT_OPTIONS,
"value": {"path": "/origin"}, "variant": "mutuallyExclusive"}),
_comp("dest", "ChoicePicker", {"label": "Destination", "options": _AIRPORT_OPTIONS,
"value": {"path": "/dest"}, "variant": "mutuallyExclusive"}),
_comp("date", "DateTimeInput", {"label": "Departure date (YYYY-MM-DD)",
"value": {"path": "/date"}, "enableDate": True}),
_comp("passengers", "TextField", {"label": "Passengers",
"value": {"path": "/passengers"}, "variant": "number"}),
_comp("fare", "ChoicePicker", {"label": "Fare class", "options": _FARE_OPTIONS,
"value": {"path": "/fare_class"}, "variant": "mutuallyExclusive"}),
_comp("submit", "Button", {"child": "submit_label", "variant": "primary",
"action": {"event": {"name": "bookingSubmit", "context": {
"formId": "booking",
"origin": {"path": "/origin"},
"dest": {"path": "/dest"},
"date": {"path": "/date"},
"passengers": {"path": "/passengers"},
"fare_class": {"path": "/fare_class"},
}}}}),
_comp("submit_label", "Text", {"text": "Search flights"}),
],
)
# region build-form-node
async def build_form(state: MessagesState) -> dict:
"""First-turn AND Modify-search node: LLM authors the booking form.
On a Modify-search turn (last action.name == 'modifySearch'), walks
message history to recover the user's prior bookingSubmit context and
pre-fills the form's data_model with those values. On a true first turn
(no prior submit in history), uses blank defaults.
"""
prior = _extract_prior_submit_context(state["messages"])
defaults = _form_defaults_from_prior(prior)
# If there's no prior bookingSubmit (true first turn), try to seed
# origin/dest from the most recent human prompt — e.g. the welcome chip
# "I want to fly LAX to JFK" should land on a form where Origin=LAX and
# Destination=JFK are already selected. Without this seed, the LLM is
# told to use the blank `data_model` verbatim and the user lands on a
# form whose default values find no flights (Origin=Destination=LAX is
# a common failure we've seen). Prior-context path skips this seed
# because it already carries the user's last-known values.
if not prior:
seed = _seed_airports_from_messages(state["messages"])
defaults.update(seed)
system_prompt = _BUILD_FORM_SYSTEM_TMPL.replace(
"__DATA_MODEL_DEFAULTS__", json.dumps(defaults)
)
base_messages = [SystemMessage(content=system_prompt)] + state["messages"]
try:
spec = await _emit_with_retry(BookingFormSpec, base_messages)
except RuntimeError as err:
_logger.error("Falling back to sentinel booking form: %s", err)
spec = _build_sentinel_booking_form(defaults)
return {"messages": [AIMessage(content=_wrap_envelopes(spec))]}
# endregion
# ── search_flights node ─────────────────────────────────────────────────────
_SEARCH_FLIGHTS_SYSTEM = """You just received a booking submission. The find_routes() tool returned the following flights:
{flights_json}
Form data (for context): {form_json}
Emit an A2UI v0.9 results surface using the FlightResultsSpec schema.
A2UI format (CRITICAL): every component is FLAT — `{{"id": "...", "component": "<ComponentName>", <props...>}}`. The component name is a plain string; props sit at the top level of the same object. Dynamic values are bare literals or `{{"path": "/ptr"}}`.
Allowed component names: Column, Row, Card, Text, TextField, Button, Divider, List.
Per-component shapes you'll need:
Column / List: {{"children": ["id1", "id2"]}}
Card: {{"child": "<single-id>"}}
Text: {{"text": "literal", "variant": "h2"}} (or h1/h3/body/caption)
Button: {{"child": "<text-id>", "variant": "primary", "action": {{"event": {{"name": "<event>", "context": {{"flightId": "<num>"}}}}}}}}
Divider: {{}}
Surface constraints:
surface_id MUST be "results"
data_model can be {{}}
Exactly ONE component MUST have id "root": a Column whose children lists every flight Card id (or just ["no_flights"] when empty)
Build pattern (one per flight):
card_<n> (Card, child=col_<n>)
col_<n> (Column, children = [title_<n>, route_<n>, time_<n>, btn_<n>])
title_<n> (Text, text="<airline> flight <flight_number>", variant="h3")
route_<n> (Text, text="<from> → <to> • <duration_min> min • <aircraft>", variant="body")
time_<n> (Text, text="Depart <depart_local> • Arrive <arrive_local> • Gate <gate>", variant="caption")
btn_<n> (Button, child=btn_label_<n>, variant="primary",
action={{"event":{{"name":"flightSelect","context":{{"flightId":"<flight_number>"}}}}}})
btn_label_<n> (Text, text="Select")
Empty case: components = [
{{"id":"root", "component":"Column", "children":["no_flights"]}},
{{"id":"no_flights", "component":"Card", "child":"empty_col"}},
{{"id":"empty_col", "component":"Column", "children":["empty_msg","modify_btn"]}},
{{"id":"empty_msg", "component":"Text", "text":"No flights found", "variant":"h3"}},
{{"id":"modify_btn", "component":"Button", "child":"modify_label", "action":{{"event":{{"name":"modifySearch","context":{{"formId":"booking"}}}}}}}},
{{"id":"modify_label", "component":"Text", "text":"Modify search"}}
]
Use unique ids for every component."""
_SENTINEL_RESULTS = FlightResultsSpec(
surface_id="results",
data_model={},
components=[
_comp("root", "Column", {"children": ["msg"]}),
_comp("msg", "Card", {"child": "msg_col"}),
_comp("msg_col", "Column", {"children": ["msg_text", "modify"]}),
_comp("msg_text", "Text", {"text": "Results unavailable", "variant": "h3"}),
_comp("modify", "Button", {"child": "modify_label",
"action": {"event": {"name": "modifySearch",
"context": {"formId": "booking"}}}}),
_comp("modify_label", "Text", {"text": "Modify search"}),
],
)
# ── confirm_booking node ────────────────────────────────────────────────────
_CONFIRM_BOOKING_SYSTEM = """You just received a Select event from the flight results surface. The user picked a flight; here are its details from lookup_flight():
{flight_json}
The user's prior search context (party_text, derived from passengers + fare_class on the original booking submission): {party_text}
The selected flight number (from the Select button's action context, used in the fallback title if flight_json is null): {flight_id}
Emit an A2UI v0.9 confirmation surface using the ConfirmationSpec schema.
A2UI format (CRITICAL): every component is FLAT — `{{"id": "...", "component": "<ComponentName>", <props...>}}`. The component name is a plain string; props sit at the top level of the same object. Dynamic values are bare literals or `{{"path": "/ptr"}}`. Exactly ONE component MUST have id "root".
Allowed component names: Column, Card, Text, Button, Divider.
Per-component shapes you'll need:
Column: {{"children": ["id1","id2"]}}
Card: {{"child": "<single-id>"}}
Text: {{"text": "literal", "variant": "h2"}} (h1/h2/h3/body/caption)
Button: {{"child": "<text-id>", "variant": "primary", "action": {{"event": {{"name": "<event>", "context": {{"formId": "booking"}}}}}}}}
Divider: {{}}
Surface constraints:
surface_id MUST be "confirmation"
data_model = {{}}
Build this component tree exactly (use these ids):
root (Column, children = [card])
card (Card, child = card_col)
card_col (Column, children = [title, route_text, time_text, gate_text, divider, party_text, modify])
title (Text, "<airline> flight <flight_number>", variant="h2")
route_text (Text, "<from> → <to> • <duration_min> min • <aircraft>", variant="body")
time_text (Text, "Depart <depart_local> • Arrive <arrive_local>", variant="caption")
gate_text (Text, "Gate <gate>", variant="caption")
divider (Divider, {{}})
party_text (Text, "{party_text}", variant="body") ← use the supplied string verbatim
modify (Button, child=modify_label, variant="primary",
action={{"event":{{"name":"modifySearch","context":{{"formId":"booking"}}}}}})
modify_label (Text, "Modify search")
If flight_json is null, omit route_text, time_text, gate_text from card_col's children and set the title to "Flight {flight_id} selected" (or "Booking selected" if {flight_id} is empty). Always include party_text + modify."""
def _build_sentinel_confirmation(flight_id: str, party_text: str) -> ConfirmationSpec:
"""Hardcoded fallback used when retry exhausts."""
title = f"Flight {flight_id} selected" if flight_id else "Booking selected"
return ConfirmationSpec(
surface_id="confirmation",
data_model={},
components=[
_comp("root", "Column", {"children": ["card"]}),
_comp("card", "Card", {"child": "card_col"}),
_comp("card_col", "Column", {"children": ["title", "party", "modify"]}),
_comp("title", "Text", {"text": title, "variant": "h2"}),
_comp("party", "Text", {"text": party_text, "variant": "body"}),
_comp("modify", "Button", {"child": "modify_label",
"action": {"event": {"name": "modifySearch",
"context": {"formId": "booking"}}}}),
_comp("modify_label", "Text", {"text": "Modify search"}),
],
)
async def confirm_booking(state: MessagesState) -> dict:
"""Post-Select node: look up the chosen flight, recover party context from
prior submit, emit confirmation surface."""
last = state["messages"][-1]
select_data = _parse_submit_payload(getattr(last, "content", "")) or {}
flight_id_raw = select_data.get("flightId") or select_data.get("flight_id") or ""
flight_id = flight_id_raw.upper() if isinstance(flight_id_raw, str) else ""
flight: dict[str, Any] | None = None
if flight_id:
try:
flight = await lookup_flight.ainvoke({"flight_number": flight_id})
except Exception as err: # noqa: BLE001 — demo robustness
_logger.warning("lookup_flight failed for %s: %s", flight_id, err)
prior = _extract_prior_submit_context(state["messages"])
party_text = _format_party(prior)
base_messages = [
SystemMessage(content=_CONFIRM_BOOKING_SYSTEM.format(
flight_json=json.dumps(flight, indent=2) if flight else "null",
party_text=party_text,
flight_id=flight_id,
)),
HumanMessage(content=f"Emit the confirmation surface for flight {flight_id or '(unknown)'}."),
]
try:
spec = await _emit_with_retry(ConfirmationSpec, base_messages)
except RuntimeError as err:
_logger.error("Falling back to sentinel confirmation surface: %s", err)
spec = _build_sentinel_confirmation(flight_id, party_text)
return {"messages": [AIMessage(content=_wrap_envelopes(spec))]}
def _unwrap_literal(v: Any) -> Any:
"""Unwrap a legacy literal wrapper ({literalString|literalNumber|literalBoolean: <v>}).
v0.9 context values arrive as bare literals, which pass through unchanged."""
if isinstance(v, dict):
for k in ("literalString", "literalNumber", "literalBoolean"):
if k in v:
return v[k]
return v
def _scalarize(v: Any) -> Any:
"""Collapse a ChoicePicker string-array value ("['LAX']") to its first
element. v0.9 pickers bind to string arrays in the data model, so
path-bound action context values may arrive as single-element lists."""
if isinstance(v, list):
return v[0] if v else ""
return v
def _parse_submit_payload(content: str) -> dict[str, Any] | None:
"""Extract the form-data dict from an A2UI action-message content.
Chat-lib sends:
{"version":"v0.9","action":{"name":"...","surfaceId":"...",
"context":{"formId":"booking","origin":["LAX"], ...}}}
Context values are plain literals (path bindings are resolved
client-side); picker values arrive as string arrays and are
scalarized. Legacy literal wrappers are still unwrapped tolerantly.
"""
try:
payload = json.loads(content)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(payload, dict):
return None
action = payload.get("action")
if not isinstance(action, dict):
return None
ctx = action.get("context", {})
if not isinstance(ctx, dict):
return None
return {k: _scalarize(_unwrap_literal(v)) for k, v in ctx.items()}
async def search_flights(state: MessagesState) -> dict:
"""Post-submit node: call find_routes, emit results A2UI surface."""
last = state["messages"][-1]
form_data = _parse_submit_payload(getattr(last, "content", "")) or {}
origin = (form_data.get("origin") or "").upper()
dest = (form_data.get("dest") or "").upper()
flights: list[dict[str, Any]] = []
if origin and dest and origin != dest:
try:
flights = await find_routes.ainvoke({"from_code": origin, "to_code": dest})
except Exception as err: # noqa: BLE001 — demo robustness
_logger.warning("find_routes failed for %s→%s: %s", origin, dest, err)
base_messages = [
SystemMessage(content=_SEARCH_FLIGHTS_SYSTEM.format(
flights_json=json.dumps(flights, indent=2),
form_json=json.dumps(form_data, indent=2),
)),
HumanMessage(content=f"Emit the results surface for {origin}→{dest}."),
]
try:
spec = await _emit_with_retry(FlightResultsSpec, base_messages)
except RuntimeError as err:
_logger.error("Falling back to sentinel results surface: %s", err)
spec = _SENTINEL_RESULTS
return {"messages": [AIMessage(content=_wrap_envelopes(spec))]}
# ── Routing + compile ───────────────────────────────────────────────────────
def _is_submit_event(content: str) -> bool:
"""True iff the content is an A2UI action message named bookingSubmit."""
try:
payload = json.loads(content)
except (json.JSONDecodeError, TypeError):
return False
if not isinstance(payload, dict):
return False
action = payload.get("action")
return (
isinstance(action, dict)
and action.get("name") == "bookingSubmit"
)
def _is_flight_select_event(content: str) -> bool:
"""True iff the content is an A2UI action message named flightSelect."""
try:
payload = json.loads(content)
except (json.JSONDecodeError, TypeError):
return False
if not isinstance(payload, dict):
return False
action = payload.get("action")
return (
isinstance(action, dict)
and action.get("name") == "flightSelect"
)
# Match "<ORIGIN> to <DEST>" / "<ORIGIN> -> <DEST>" / "<ORIGIN> → <DEST>"
# where both are 3-letter IATA codes from AIRPORT_CODES. Anchored to word
# boundaries so we don't false-match "BOSTON" or arbitrary capitals.
_AIRPORT_CODES_RE = "|".join(re.escape(code) for code in AIRPORT_CODES)
_AIRPORT_PAIR_PATTERN = re.compile(
rf"\b({_AIRPORT_CODES_RE})\b\s*(?:to|->|→|-)\s*\b({_AIRPORT_CODES_RE})\b",
re.IGNORECASE,
)
def _seed_airports_from_messages(messages: list[Any]) -> dict[str, str]:
"""Extract an (origin, dest) airport pair from the most recent human
message. Used by build_form on a fresh first turn to pre-fill the form
when the user's prompt explicitly mentions a route (e.g. the welcome
chip "I want to fly LAX to JFK").
Returns {"origin": [<CODE>], "dest": [<CODE>]} on a hit (string arrays,
matching the ChoicePicker data-model shape); {} when no recognized pair
appears. Both codes must be in AIRPORT_CODES; we never seed an airport
the form's dropdown can't render."""
for msg in reversed(messages):
# Only inspect human messages — AI surfaces and action JSON shouldn't
# be parsed for seed values.
if getattr(msg, "type", None) != "human":
continue
content = getattr(msg, "content", None)
if not isinstance(content, str):
continue
# Action messages also flow through as human-role; their content
# is JSON. Cheap filter: real prompts don't start with '{'.
if content.lstrip().startswith("{"):
continue
match = _AIRPORT_PAIR_PATTERN.search(content)
if match:
origin = match.group(1).upper()
dest = match.group(2).upper()
if origin == dest:
# Same-airport "route" can't possibly find flights; skip.
continue
return {"origin": [origin], "dest": [dest]}
return {}
def _extract_prior_submit_context(messages: list[Any]) -> dict[str, Any]:
"""Walk back, find the most recent bookingSubmit A2UI action message;
return its unwrapped context dict (origin/dest/date/passengers/fare_class).
Returns {} if not found."""
for msg in reversed(messages):
content = getattr(msg, "content", None)
if not isinstance(content, str):
continue
try:
payload = json.loads(content)
except (json.JSONDecodeError, TypeError):
continue
if (
isinstance(payload, dict)
and isinstance(payload.get("action"), dict)
and payload["action"].get("name") == "bookingSubmit"
):
ctx = payload["action"].get("context", {})
if not isinstance(ctx, dict):
return {}
return {k: _scalarize(_unwrap_literal(v)) for k, v in ctx.items()}
return {}
def _format_party(prior: dict[str, Any]) -> str:
"""Pretty-print passenger count + fare class for the confirmation text.
Tolerant of missing fields."""
parts: list[str] = []
n = prior.get("passengers")
if isinstance(n, (int, float)) and n > 0:
parts.append(f"{int(n)} passenger" + ("" if int(n) == 1 else "s"))
fare = prior.get("fare_class")
if isinstance(fare, str) and fare:
parts.append(fare)
return " • ".join(parts) if parts else "(party details unavailable)"
# region route
def route(state: MessagesState) -> Command[Literal["build_form", "search_flights", "confirm_booking"]]:
"""Inspect the last message — submit event → search_flights, flight-select
event → confirm_booking, else build_form."""
last_content = getattr(state["messages"][-1], "content", "") if state["messages"] else ""
if _is_submit_event(last_content):
return Command(goto="search_flights")
if _is_flight_select_event(last_content):
return Command(goto="confirm_booking")
return Command(goto="build_form")
# endregion
# ── generate_title node (inline; matches Pattern D from spec
# 2026-05-19-llm-generated-labels-design.md) ──────────────────────────────
_TITLE_PROMPT = (
"In 3-5 words, summarize what the user is asking about. "
"Output ONLY the title — no quotes, no period, no prefix."
)
_TITLE_MODEL = "gpt-5-mini"
async def generate_title(state: MessagesState, config) -> dict:
"""Background title generation: on the first turn, summarize the user's
intent into 3-5 words and persist to LangGraph thread metadata so the
sidenav shows something meaningful instead of a UUID slice.
Idempotent — skips when metadata.title already exists. Errors are
swallowed (title is a UX nicety, never a blocker). Runs after the
user-visible terminal node so it never blocks the response. See spec
2026-05-19-llm-generated-labels-design.md (originally `thread_title`,
converged to `title` for parity with the canonical demo + adapter).
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
return {}
# url=None lets the SDK use its in-process ASGI transport when the
# call originates from inside a LangGraph server graph (always the
# case here). The old fallback to localhost:2024 forced an HTTP
# round-trip that fails on the prod runtime container. See PR #493.
sdk_url = os.environ.get("LANGGRAPH_API_URL")
try:
client = get_client(url=sdk_url)
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
None,
)
if not first_user or not isinstance(first_user.content, str):
return {}
# Skip action-message JSON (those flow as human-role too)
if first_user.content.lstrip().startswith("{"):
return {}
llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0)
response = await llm.ainvoke([
SystemMessage(content=_TITLE_PROMPT),
HumanMessage(content=first_user.content),
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"title": title})
except Exception as err: # noqa: BLE001 — title is a UX nicety; never block
_logger.warning("Thread title generation failed: %s", err)
return {}
# region graph-wiring
_builder = StateGraph(MessagesState)
_builder.add_node("route", route)
_builder.add_node("build_form", build_form)
_builder.add_node("search_flights", search_flights)
_builder.add_node("confirm_booking", confirm_booking)
_builder.add_node("generate_title", generate_title)
_builder.set_entry_point("route")
_builder.add_edge("build_form", "generate_title")
_builder.add_edge("search_flights", "generate_title")
_builder.add_edge("confirm_booking", "generate_title")
_builder.add_edge("generate_title", END)
graph = _builder.compile()
# endregionBuild from source
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-chat-a2ui-react
npx nx e2e cockpit-chat-a2ui-reactLocal verification uses the actual compiled Python graph, installed native packages and saved checkpoints with deterministic providers. It does not prove live-provider behavior. Fixtures and verification controls are excluded from public Code and output.