React AG-UI client tools preview
Allow an AG-UI agent to request browser capabilities, including simulated weather and a person’s booking decision. The browser supplies results that the backend can use in the same user turn.
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.
A client tool is a request and a result
The native session owns the declared function catalog and continuation requests. The application owns the handler implementation and any decision UI. A weather result can continue the model; a terminal snapshot can finish locally and travel with the next explicit user request. A booking card resolves a browser function promise here, which is different from an AG-UI native interrupt resume.
- 1. Request a toolName + arguments + call ID
- 2. Validate and approveApplication rules determine what may execute
- 3. Execute in the owning environmentA server tool runs on the server; a client tool runs in the application
- 4. Return the resultAssociate the result with the original call ID
- 5. Continue when neededRequest a model follow-up, or finish with a terminal result
↳ Another tool call returns to step 1 within the same turn.
Read diagram description
A model proposes a named tool call with arguments and a call ID. The owning runtime or client handler validates those arguments and, if the application requires approval, waits for it. The handler executes the action and returns a result for that call ID. When a follow-up is enabled, the model can use the result to answer or request another tool. A terminal tool can finish without another model call. These steps belong to the same user turn. Displaying a tool card does not itself execute a tool.
The conversation walkthrough (Angular guide) connects this capability to requests, tools, UI state, and saved history.
Execute a typed browser catalog
Choose Run. The five presets only fill the draft; Send submits your question. get_weather returns a fixed simulated reading. slow_status_check waits three seconds and supports Stop. weather_card renders a local reading and follows up. weather_snapshot renders the same reading without an automatic follow-up. confirm_booking waits for an explicit Confirm or Cancel booking choice. No tool fetches real weather or makes a real reservation.
import type { FunctionTool } from '@threadplane/core/tools';
export interface LocationArguments { location: string }
export interface WeatherArguments extends LocationArguments {
temperatureF: number;
conditions: string;
humidity: number;
windMph: number;
}
export interface StatusArguments { label?: string }
export interface BookingArguments { summary: string }
export interface BookingInvocation {
readonly ownerGeneration: number;
readonly callId: string;
readonly assistantMessageId: string;
readonly sourceRunId: string;
readonly summary: string;
}
export type BookingPanel = BookingInvocation & { readonly token: string } & (
| { readonly status: 'pending' }
| { readonly status: 'resolved'; readonly confirmed: boolean }
| { readonly status: 'aborted' }
);
export interface ClientToolsSnapshot { readonly bookings: readonly BookingPanel[] }
function argumentsRecord(input: unknown, keys: readonly string[]): object {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid tool arguments');
const prototype = Object.getPrototypeOf(input);
if ((prototype !== Object.prototype && prototype !== null) ||
Reflect.ownKeys(input).some((key) => typeof key !== 'string' || !keys.includes(key))) {
throw new Error('Invalid tool arguments');
}
return input;
}
function field(input: object, key: string): unknown {
const descriptor = Object.getOwnPropertyDescriptor(input, key);
if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw new Error('Invalid tool arguments');
return descriptor.value;
}
function text(input: object, key: string): string {
const value = field(input, key);
if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid tool arguments');
return value;
}
function number(input: object, key: string): number {
const value = field(input, key);
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Invalid tool arguments');
return value;
}
const weatherKeys = ['location', 'temperatureF', 'conditions', 'humidity', 'windMph'];
/** Business validation also lets native projection admit only complete local readings. */
export function validateWeatherArguments(input: unknown): WeatherArguments {
const record = argumentsRecord(input, weatherKeys);
const value = {
location: text(record, 'location'), temperatureF: number(record, 'temperatureF'),
conditions: text(record, 'conditions'), humidity: number(record, 'humidity'), windMph: number(record, 'windMph'),
};
if (value.humidity < 0 || value.humidity > 100 || value.windMph < 0) throw new Error('Invalid tool arguments');
return value;
}
export function validateBookingArguments(input: unknown): BookingArguments {
return { summary: text(argumentsRecord(input, ['summary']), 'summary') };
}
const weatherParameters = Object.freeze({
type: 'object', additionalProperties: false, required: Object.freeze(weatherKeys),
properties: Object.freeze({
location: Object.freeze({ type: 'string', minLength: 1 }),
temperatureF: Object.freeze({ type: 'number' }),
conditions: Object.freeze({ type: 'string', minLength: 1 }),
humidity: Object.freeze({ type: 'number', minimum: 0, maximum: 100 }),
windMph: Object.freeze({ type: 'number', minimum: 0 }),
}),
});
const aborted = () => new DOMException('Client tool cancelled', 'AbortError');
function sameInvocation(left: BookingInvocation, right: BookingInvocation): boolean {
return left.ownerGeneration === right.ownerGeneration && left.callId === right.callId &&
left.assistantMessageId === right.assistantMessageId && left.sourceRunId === right.sourceRunId && left.summary === right.summary;
}
/** Owns business promises only. The native session supplies every protocol identity. */
export function createClientToolsController(readCurrentBooking: () => BookingInvocation | undefined) {
let disposed = false;
let snapshot: ClientToolsSnapshot = Object.freeze({ bookings: Object.freeze([]) });
const listeners = new Set<() => void>();
const cancellations = new Set<() => void>();
const pending = new Map<string, {
readonly signal: AbortSignal;
readonly identity: BookingInvocation;
readonly cancel: () => void;
readonly finish: (decision: boolean) => boolean;
}>();
function active(signal: AbortSignal) {
if (disposed || signal.aborted) throw aborted();
}
function publish(row: BookingPanel) {
const existing = snapshot.bookings.some((entry) => entry.token === row.token);
snapshot = Object.freeze({ bookings: Object.freeze(existing
? snapshot.bookings.map((entry) => entry.token === row.token ? row : entry)
: [...snapshot.bookings, row]) });
const publication = snapshot;
for (const notify of [...listeners]) {
if (disposed || snapshot !== publication) break;
// An observer cannot strand a tool promise or prevent other owned cleanup.
try { notify(); } catch { /* Consumers own their notification errors. */ }
}
}
function current(signal: AbortSignal): BookingInvocation | undefined {
active(signal);
const value = readCurrentBooking();
active(signal);
if (!value) return undefined;
const record = argumentsRecord(value, ['ownerGeneration', 'callId', 'assistantMessageId', 'sourceRunId', 'summary']);
const ownerGeneration = number(record, 'ownerGeneration');
if (!Number.isSafeInteger(ownerGeneration) || ownerGeneration < 0) return undefined;
const identity = Object.freeze({ ownerGeneration, callId: text(record, 'callId'),
assistantMessageId: text(record, 'assistantMessageId'), sourceRunId: text(record, 'sourceRunId'), summary: text(record, 'summary') });
active(signal);
return identity;
}
function delay(signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
active(signal);
let finished = false;
const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); cancellations.delete(cancel); };
const cancel = () => { if (finished) return; finished = true; cleanup(); reject(aborted()); };
const timer = setTimeout(() => {
if (finished) return;
if (disposed || signal.aborted) { cancel(); return; }
finished = true; cleanup(); resolve();
}, 3000);
cancellations.add(cancel);
signal.addEventListener('abort', cancel, { once: true });
if (disposed || signal.aborted) cancel();
});
}
async function showWeather(input: WeatherArguments, signal: AbortSignal): Promise<{ displayed: true }> {
active(signal); validateWeatherArguments(input); active(signal);
return { displayed: true };
}
const tools = Object.freeze({
get_weather: Object.freeze({
description: 'Return simulated local weather readings for a location.', followUp: true,
parameters: Object.freeze({ type: 'object', additionalProperties: false,
required: Object.freeze(['location']), properties: Object.freeze({ location: Object.freeze({ type: 'string', minLength: 1 }) }) }),
async handler(input: LocationArguments, { signal }) {
active(signal); const location = text(argumentsRecord(input, ['location']), 'location');
await Promise.resolve(); active(signal);
return { location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 };
},
} satisfies FunctionTool<LocationArguments, WeatherArguments>),
slow_status_check: Object.freeze({
description: 'Run a cancellable local status check lasting three seconds.', followUp: true,
parameters: Object.freeze({ type: 'object', additionalProperties: false, required: Object.freeze([]),
properties: Object.freeze({ label: Object.freeze({ type: 'string', minLength: 1, default: 'status check' }) }) }),
async handler(input: StatusArguments, { signal }): Promise<{ label: string; status: 'complete' }> {
active(signal); const record = argumentsRecord(input, ['label']);
const label = Object.hasOwn(record, 'label') ? text(record, 'label') : 'status check';
active(signal); await delay(signal); active(signal);
return { label, status: 'complete' };
},
} satisfies FunctionTool<StatusArguments, { label: string; status: 'complete' }>),
weather_card: Object.freeze({
description: 'Display complete simulated weather readings and allow an assistant follow-up.',
parameters: weatherParameters, followUp: true,
handler: (input: WeatherArguments, { signal }) => showWeather(input, signal),
} satisfies FunctionTool<WeatherArguments, { displayed: true }>),
weather_snapshot: Object.freeze({
description: 'Display a terminal simulated weather snapshot without an assistant follow-up.',
parameters: weatherParameters, followUp: false,
handler: (input: WeatherArguments, { signal }) => showWeather(input, signal),
} satisfies FunctionTool<WeatherArguments, { displayed: true }>),
confirm_booking: Object.freeze({
description: 'Ask for an explicit fictional booking decision. No real reservation is made.', followUp: true,
parameters: Object.freeze({ type: 'object', additionalProperties: false, required: Object.freeze(['summary']),
properties: Object.freeze({ summary: Object.freeze({ type: 'string', minLength: 1 }) }) }),
async handler(input: BookingArguments, { signal }) {
active(signal); const { summary } = validateBookingArguments(input); active(signal);
let identity: BookingInvocation | undefined;
try { identity = current(signal); } catch (error) { if (signal.aborted || disposed) throw aborted(); throw new Error('Booking invocation unavailable', { cause: error }); }
if (!identity || identity.summary !== summary || snapshot.bookings.some((entry) => sameInvocation(entry, identity))) {
throw new Error('Booking invocation unavailable');
}
const captured = identity;
// This UUID is app control authority, never a native call/message/run identity.
const token = crypto.randomUUID();
const row: BookingPanel = Object.freeze({ ...captured, token, status: 'pending' });
const result = new Promise<{ confirmed: boolean }>((resolve, reject) => {
let finished = false;
const cleanup = () => { pending.delete(token); cancellations.delete(cancel); signal.removeEventListener('abort', cancel); };
const cancel = () => {
if (finished) return;
finished = true; cleanup();
publish(Object.freeze({ ...captured, token, status: 'aborted' })); reject(aborted());
};
pending.set(token, { identity: captured, signal, cancel, finish(decision) {
if (finished || disposed || signal.aborted) return false;
// Consume authority before notification; reentrant controls cannot win twice.
pending.delete(token);
publish(Object.freeze({ ...captured, token, status: 'resolved', confirmed: decision }));
if (finished || disposed || signal.aborted) { cancel(); return false; }
// Notifications may revoke the native call without aborting its signal yet.
let latest: BookingInvocation | undefined;
try { latest = current(signal); } catch { cancel(); return false; }
if (!latest || !sameInvocation(captured, latest)) { cancel(); return false; }
finished = true; cleanup(); resolve({ confirmed: decision }); return true;
} });
cancellations.add(cancel); signal.addEventListener('abort', cancel, { once: true });
publish(row);
if (disposed || signal.aborted) cancel();
if (!finished) {
let latest: BookingInvocation | undefined;
try { latest = current(signal); } catch { cancel(); }
if (!latest || !sameInvocation(captured, latest)) cancel();
}
});
// Observe rejection immediately even when native consumers attach after publication.
void result.catch(() => undefined);
return result;
},
} satisfies FunctionTool<BookingArguments, { confirmed: boolean }>),
});
return Object.freeze({
tools,
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify); return () => { listeners.delete(notify); };
},
respond(token: string, decision: boolean): boolean {
const request = pending.get(token);
if (!request || disposed || request.signal.aborted || typeof decision !== 'boolean') return false;
let latest: BookingInvocation | undefined;
try { latest = current(request.signal); } catch { request.cancel(); return false; }
if (!latest || !sameInvocation(request.identity, latest) || disposed || request.signal.aborted) {
request.cancel(); return false;
}
return request.finish(decision);
},
dispose() {
if (disposed) return;
disposed = true; listeners.clear();
for (const cancel of [...cancellations]) cancel();
pending.clear();
},
});
}// 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, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
ChatInput,
TextTranscript,
ToolObservation,
} from '@threadplane/react/chat';
import {
createConnectedApplication,
type ClientToolsConnection,
} from './connection';
import { WeatherCard } from './weather-card';
import { ConfirmBooking } from './confirm-booking';
const prompts = Object.freeze([
{
label: 'Weather result',
text: 'Use get_weather for simulated weather in San Francisco.',
},
{
label: 'Slow status',
text: 'Run slow_status_check with the label demo check.',
},
{
label: 'Weather card',
text: 'Show simulated weather with weather_card for San Francisco.',
},
{
label: 'Terminal snapshot',
text: 'Show simulated weather with weather_snapshot without a follow-up.',
},
{
label: 'Fictional booking',
text: 'Ask me to confirm a fictional dinner booking with confirm_booking.',
},
]);
export function ClientToolsDemo({
connection,
onReady,
}: {
readonly connection: ClientToolsConnection;
readonly onReady: () => void;
}) {
const [application] = useState(() => createConnectedApplication(connection));
const [draft, setDraft] = useState('');
const snapshot = useAgent(application);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
useEffect(() => {
setDraft('');
}, [snapshot.viewGeneration]);
const status = snapshot.busy
? snapshot.activity === 'replacing'
? 'Starting a new conversation…'
: snapshot.bookings.some((row) => row.status === 'pending')
? 'Waiting for your fictional booking decision…'
: 'Running client tools…'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: snapshot.outcome === 'success'
? 'Command complete.'
: 'Ready.';
return (
<ExampleLayout className="client-tools-demo">
<header>
<div>
<h1>AG-UI client tools</h1>
</div>
<button
type="button"
disabled={snapshot.busy}
onClick={() => void application.newConversation()}
>
New conversation
</button>
</header>
<p>
Five browser tools demonstrate simulated weather, a cancellable status
check and an explicit fictional booking decision.
</p>
<section aria-label="Runtime connection">
<h2>Runtime connection</h2>
<p>{connection.label}</p>
</section>
<div role="group" aria-label="Example prompts" className="prompt-presets">
{prompts.map((prompt) => (
<button
key={prompt.label}
type="button"
disabled={snapshot.busy}
onClick={() => setDraft(prompt.text)}
>
{prompt.label}
</button>
))}
</div>
<section aria-label="Conversation" className="client-tools-conversation">
<ol>
{snapshot.views.map((message) => (
<li key={message.id} data-native-message={message.id}>
{message.text && (
<TextTranscript
messages={[message.text]}
label="Message text"
/>
)}
{message.tools.map((tool) => (
<div key={tool.id} data-native-call={tool.id}>
<ToolObservation
name={tool.name}
argumentsText={tool.argumentsText}
resultText={tool.resultText}
/>
{tool.local && (
<section aria-label="Local tool evidence">
<p>Local execution: {tool.local.status}</p>
<p>Delivery: {tool.local.delivery}</p>
</section>
)}
{tool.weather && <WeatherCard reading={tool.weather} />}
{snapshot.bookings
.filter(
(booking) =>
booking.assistantMessageId === message.id &&
booking.callId === tool.id &&
booking.sourceRunId === tool.sourceRunId &&
tool.local?.name === 'confirm_booking'
)
.map((booking) => (
<ConfirmBooking
key={booking.token}
booking={booking}
respond={(token, confirmed) => {
application.respond(token, confirmed);
}}
/>
))}
</div>
))}
</li>
))}
</ol>
</section>
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
label="Message"
placeholder="Try an example prompt…"
value={draft}
onValueChange={setDraft}
disabled={!snapshot.canSubmit}
busy={snapshot.busy}
onStop={snapshot.busy ? () => void application.stop() : undefined}
onSubmit={(text) => {
if (!application.getSnapshot().canSubmit) return false;
void application.submit(text);
return application.getSnapshot().busy;
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}The native session owns the catalog and physical continuations. The app owns a local tool store, typed executors, and authored panels. Native tool observations retain literal call arguments and results; local execution records separately report browser work. A local result is not proof of server receipt: unsent, sent-unconfirmed, and observed describe delivery. A physical run can finish while the overall command still awaits browser execution, a continuation, or your decision.
import type {
ClientToolSession,
ClientToolSessionSnapshot,
} from '@threadplane/ag-ui';
import type { CompleteOutcome } from '@threadplane/core';
import type { ToolContracts } from '@threadplane/core/tools';
import {
createClientToolsController,
validateBookingArguments,
type BookingInvocation,
type BookingPanel,
} from './tools';
import {
projectClientToolViews,
type ClientToolMessageView,
} from './projection';
export type ToolsController = ReturnType<typeof createClientToolsController>;
export type Tools = ToolsController['tools'];
export type NativeSession = ClientToolSession<ToolContracts<Tools>>;
export type NativeSnapshot = ClientToolSessionSnapshot<ToolContracts<Tools>>;
export interface ClientToolsApplicationSnapshot {
readonly threadId: string | null;
readonly native: NativeSnapshot | undefined;
readonly views: readonly ClientToolMessageView[];
readonly bookings: readonly BookingPanel[];
readonly busy: boolean;
readonly canSubmit: boolean;
readonly activity: 'idle' | 'running' | 'replacing';
readonly outcome: CompleteOutcome | null;
readonly error: string | null;
readonly viewGeneration: number;
}
/** App lifetime and explicit controls only. All protocol execution belongs to the native owner. */
export function createClientToolsApplication(options: {
readonly sessionFactory: (threadId: string, tools: Tools) => NativeSession;
}) {
type Primary = {
readonly generation: number;
readonly threadId: string;
readonly tools: ToolsController;
session?: NativeSession;
closed: boolean;
unsafe: boolean;
release?: () => void;
releaseTools?: () => void;
};
type Operation = { readonly controller: AbortController; owner?: Primary };
const listeners = new Set<() => void>();
let primary: Primary | undefined, operation: Operation | undefined;
let generation = 0,
disposed = false;
let snapshot: ClientToolsApplicationSnapshot = Object.freeze({
threadId: null,
native: undefined,
views: Object.freeze([]),
bookings: Object.freeze([]),
busy: false,
canSubmit: true,
activity: 'idle',
outcome: null,
error: null,
viewGeneration: 0,
});
function publish(update: Partial<ClientToolsApplicationSnapshot>) {
if (disposed) return;
snapshot = Object.freeze({ ...snapshot, ...update });
const publication = snapshot;
for (const notify of [...listeners]) {
if (disposed || snapshot !== publication) break;
try {
notify();
} catch {
/* A view cannot interrupt execution or cleanup. */
}
}
}
function current(owner: Primary, admitted?: Operation) {
return (
!disposed &&
primary === owner &&
generation === owner.generation &&
!owner.closed &&
(!admitted ||
(operation === admitted &&
admitted.owner === owner &&
!admitted.controller.signal.aborted))
);
}
// Observe every cleanup rejection without waiting for arbitrary foreign promises.
function cleanup(action: () => unknown) {
try {
void Promise.resolve(action()).catch(() => undefined);
} catch {
/* Revoked candidates cannot publish. */
}
}
function close(owner: Primary) {
if (owner.closed) return;
owner.closed = true;
cleanup(() => owner.release?.());
cleanup(() => owner.releaseTools?.());
owner.tools.dispose();
const session = owner.session;
if (session) {
cleanup(() => session.stop());
cleanup(() => session.dispose());
}
}
function read(owner: Primary, valid: () => boolean) {
if (!valid() || !owner.session) return;
const session = owner.session;
const getSnapshot = session.getSnapshot;
if (!valid()) return;
const native = getSnapshot.call(session);
return valid() ? native : undefined;
}
function booking(owner: Primary): BookingInvocation | undefined {
const admitted = operation;
const valid = () => !!admitted && current(owner, admitted) && snapshot.busy;
if (!valid()) return;
const native = read(owner, valid);
if (!native || !valid() || native.clientTools.blocker || !native.run?.id)
return;
const calls = native.clientTools.calls.filter(
(call) =>
call.provenance === 'local' &&
call.name === 'confirm_booking' &&
call.status === 'running'
);
if (!valid() || calls.length !== 1) return;
const call = calls[0];
if (call.sourceRunId !== native.run.id) return;
const { summary } = validateBookingArguments(call.args);
if (!valid()) return;
const identity = Object.freeze({
ownerGeneration: owner.generation,
callId: call.callId,
assistantMessageId: call.assistantMessageId,
sourceRunId: call.sourceRunId,
summary,
});
return valid() ? identity : undefined;
}
function capture(owner: Primary, valid: () => boolean) {
const native = read(owner, valid);
if (!native || !valid()) return;
const views = projectClientToolViews(native, valid);
if (!valid()) return;
const bookings = owner.tools.getSnapshot().bookings;
return valid() ? { native, views, bookings } : undefined;
}
function observe(owner: Primary) {
const admitted = operation;
const valid = () =>
current(owner) && (!admitted || current(owner, admitted));
try {
const result = capture(owner, valid);
if (result && valid()) publish(result);
} catch {
if (valid()) {
owner.unsafe = true;
publish({
canSubmit: false,
error:
'The client tool session is unavailable. Start a new conversation.',
});
}
}
}
function install(admitted: Operation): Primary | undefined {
const valid = () =>
!disposed &&
operation === admitted &&
!admitted.controller.signal.aborted;
if (!valid()) return;
const threadId = crypto.randomUUID();
if (!valid()) return;
const tools = createClientToolsController(() => booking(owner));
const owner: Primary = {
threadId,
generation: ++generation,
tools,
closed: false,
unsafe: false,
};
if (!valid()) {
close(owner);
return;
}
primary = owner;
admitted.owner = owner;
try {
const factory = options.sessionFactory;
if (!current(owner, admitted)) return;
const session = factory.call(options, threadId, tools.tools);
owner.session = session;
if (!current(owner, admitted)) {
close(owner);
cleanup(() => session.dispose());
return;
}
const releaseTools = tools.subscribe(() => observe(owner));
owner.releaseTools = releaseTools;
if (!current(owner, admitted)) {
cleanup(releaseTools);
close(owner);
return;
}
const subscribe = session.subscribe;
if (!current(owner, admitted)) return;
const release = subscribe.call(session, () => observe(owner));
owner.release = release;
if (!current(owner, admitted)) {
cleanup(release);
close(owner);
return;
}
const initial = capture(owner, () => current(owner, admitted));
if (!initial || !current(owner, admitted)) {
close(owner);
return;
}
publish({ threadId, ...initial });
return current(owner, admitted) ? owner : undefined;
} catch (error) {
close(owner);
if (primary === owner) primary = undefined;
throw error;
}
}
function eligible(native: NativeSnapshot, valid: () => boolean) {
if (!valid()) return false;
const status = native.status;
if (!valid() || status !== 'idle') return false;
const tools = native.clientTools;
if (!valid()) return false;
const outcome = tools.commandOutcome;
if (!valid() || outcome !== 'success') return false;
const blocker = tools.blocker;
if (!valid() || blocker) return false;
const decision = native.decision;
if (!valid() || decision) return false;
const failed = tools.calls.some(
(call) => call.status === 'error' || call.status === 'cancelled'
);
if (!valid() || failed) return false;
const child = native.transcript.some(
(message) =>
message.role === 'assistant' &&
typeof message.subagentRunId === 'string' &&
!!message.toolCalls?.length
);
return valid() && !child;
}
async function submit(text: string) {
if (
disposed ||
snapshot.busy ||
!snapshot.canSubmit ||
typeof text !== 'string' ||
!text.trim()
)
return;
const before = primary;
const initialValid = () =>
!disposed &&
!snapshot.busy &&
snapshot.canSubmit &&
primary === before &&
!before?.closed;
try {
if (before) {
const native = read(before, initialValid);
if (!native || !initialValid()) return;
const safe = !before.unsafe && eligible(native, initialValid);
if (!initialValid()) return;
if (!safe) {
publish({
canSubmit: false,
error: 'Start a new conversation to continue.',
});
return;
}
}
} catch {
if (initialValid())
publish({
canSubmit: false,
error:
'The client tool session is unavailable. Start a new conversation.',
});
return;
}
if (!initialValid()) return;
const admitted: Operation = {
controller: new AbortController(),
owner: before,
};
operation = admitted;
const valid = () =>
!disposed &&
operation === admitted &&
!admitted.controller.signal.aborted;
publish({
busy: true,
canSubmit: false,
activity: 'running',
outcome: null,
error: null,
});
try {
if (!valid()) return;
const owner = before ?? install(admitted);
if (!owner || !current(owner, admitted) || !owner.session) return;
const session = owner.session;
const execute = session.submit;
if (!current(owner, admitted)) return;
const pending = execute.call(session, text, {
signal: admitted.controller.signal,
});
// Adoption observes late rejection even if callbacks detach the owner immediately.
void Promise.resolve(pending).catch(() => undefined);
if (!current(owner, admitted)) return;
const outcome = await pending;
if (!current(owner, admitted)) return;
const result = capture(owner, () => current(owner, admitted));
if (!result || !current(owner, admitted)) return;
const safe =
outcome === 'success' &&
eligible(result.native, () => current(owner, admitted)) &&
!owner.unsafe;
if (!current(owner, admitted)) return;
owner.unsafe ||= !safe;
publish({
...result,
outcome,
canSubmit: safe,
error: safe
? null
: 'The client tool command did not complete safely. Start a new conversation.',
});
} catch {
if (valid()) {
if (admitted.owner) admitted.owner.unsafe = true;
publish({
canSubmit: false,
outcome: 'error',
error: 'The AG-UI request failed. Start a new conversation.',
});
}
} finally {
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
}
async function stop() {
if (disposed || !operation) return;
const admitted = operation,
owner = primary;
operation = undefined;
primary = undefined;
admitted.controller.abort();
if (owner) close(owner);
publish({
busy: false,
canSubmit: false,
activity: 'idle',
outcome: 'aborted',
...(owner ? { bookings: owner.tools.getSnapshot().bookings } : {}),
error: 'The operation stopped. Start a new conversation.',
});
}
async function newConversation() {
if (disposed || snapshot.busy) return;
const replacement: Operation = { controller: new AbortController() };
operation = replacement;
const valid = () =>
!disposed &&
operation === replacement &&
!replacement.controller.signal.aborted;
publish({ busy: true, canSubmit: false, activity: 'replacing' });
if (!valid()) return;
const owner = primary;
primary = undefined;
generation++;
if (owner) close(owner);
if (!valid()) return;
operation = undefined;
publish({
threadId: null,
native: undefined,
views: Object.freeze([]),
bookings: Object.freeze([]),
busy: false,
canSubmit: true,
activity: 'idle',
outcome: null,
error: null,
viewGeneration: snapshot.viewGeneration + 1,
});
}
function respond(token: string, decision: boolean) {
const owner = primary,
admitted = operation;
if (!owner || !admitted || !current(owner, admitted) || !snapshot.busy)
return false;
return owner.tools.respond(token, decision);
}
function dispose(): Promise<void> {
if (disposed) return Promise.resolve();
disposed = true;
const owner = primary;
primary = undefined;
const admitted = operation;
operation = undefined;
generation++;
admitted?.controller.abort();
if (owner) close(owner);
listeners.clear();
return Promise.resolve();
}
return Object.freeze({
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit,
stop,
newConversation,
respond,
dispose,
});
}import {
projectTextTranscript,
type TextTranscriptRow,
} from '@threadplane/ag-ui';
import type { NativeSnapshot } from './application';
import { validateWeatherArguments, type WeatherArguments } from './tools';
type LocalCall = NativeSnapshot['clientTools']['calls'][number];
export interface ClientToolView {
readonly id: string;
readonly name: string;
readonly argumentsText: string;
readonly resultText?: string;
readonly sourceRunId?: string;
readonly local?: LocalCall;
readonly weather?: Readonly<WeatherArguments>;
}
export interface ClientToolMessageView {
readonly id: string;
readonly text?: TextTranscriptRow;
readonly tools: readonly ClientToolView[];
}
/** Literal protocol rows plus separate matched local evidence. Never creates request history. */
export function projectClientToolViews(
native: NativeSnapshot,
valid: () => boolean = () => true
): readonly ClientToolMessageView[] {
const rows: ClientToolMessageView[] = [];
if (!valid()) return Object.freeze([]);
const text = new Map(
projectTextTranscript(native.transcript).map((row) => [row.id, row])
);
for (const message of native.transcript) {
if (!valid()) return Object.freeze([]);
if (message.role !== 'assistant' && message.role !== 'user') continue;
const tools: ClientToolView[] = [];
for (const call of message.role === 'assistant'
? message.toolCalls ?? []
: []) {
if (!valid()) return Object.freeze([]);
const candidates =
typeof message.subagentRunId === 'string'
? []
: native.clientTools.calls.filter(
(local) =>
local.provenance === 'local' &&
local.callId === call.id &&
local.assistantMessageId === message.id &&
local.name === call.function.name &&
local.rawArguments === call.function.arguments
);
const blocked = native.clientTools.blocker?.callIds.includes(call.id);
const local =
candidates.length === 1 && !blocked ? candidates[0] : undefined;
let weather: Readonly<WeatherArguments> | undefined;
if (local?.status === 'complete') {
try {
if (local.name === 'get_weather') {
const value = validateWeatherArguments(local.result);
if (value.location === local.args?.location)
weather = Object.freeze(value);
} else if (
(local.name === 'weather_card' ||
local.name === 'weather_snapshot') &&
local.result?.displayed === true
) {
weather = Object.freeze(validateWeatherArguments(local.args));
}
} catch {
/* Invalid business data remains literal native evidence. */
}
}
const results = native.transcript.filter(
(row) =>
row.role === 'tool' &&
row.toolCallId === call.id &&
(typeof message.subagentRunId === 'string'
? row.subagentRunId === message.subagentRunId
: typeof row.subagentRunId !== 'string')
);
const result = results.length === 1 ? results[0] : undefined;
tools.push(
Object.freeze({
id: call.id,
name: call.function.name,
argumentsText: call.function.arguments,
...(result && typeof result.content === 'string'
? { resultText: result.content }
: {}),
...(local ? { local, sourceRunId: local.sourceRunId } : {}),
...(weather ? { weather } : {}),
})
);
}
rows.push(
Object.freeze({
id: message.id,
...(text.has(message.id) ? { text: text.get(message.id) } : {}),
tools: Object.freeze(tools),
})
);
}
return valid() ? Object.freeze(rows) : Object.freeze([]);
}Render weather and decide a fictional booking
weather_card uses followUp: true. weather_snapshot uses followUp: false: its local result ends the command without an automatic final assistant response. The next explicit human question carries the result in native history without replaying the tool. Cards require a complete validated local reading and belong to the native assistant that requested them.
import type { WeatherArguments } from './tools';
export function WeatherCard({
reading,
}: {
readonly reading: Readonly<WeatherArguments>;
}) {
return (
<section
className="weather-card"
aria-label={`Simulated weather for ${reading.location}`}
>
<h3>Simulated weather</h3>
<p>{reading.location}</p>
<dl>
<dt>Temperature</dt>
<dd>{reading.temperatureF}°F</dd>
<dt>Conditions</dt>
<dd>{reading.conditions}</dd>
<dt>Humidity</dt>
<dd>{reading.humidity}%</dd>
<dt>Wind</dt>
<dd>{reading.windMph} mph</dd>
</dl>
</section>
);
}A fictional booking resolves a browser promise through your explicit decision. This is separate from a native agent pause/resume. Once resolved, stopped, replaced, or unmounted, its controls cannot make another decision.
import type { BookingPanel } from './tools';
export function ConfirmBooking({
booking,
respond,
}: {
readonly booking: BookingPanel;
readonly respond: (token: string, confirmed: boolean) => void;
}) {
return (
<section aria-label="Fictional booking" className="booking-panel">
<h3>Fictional booking</h3>
<p>{booking.summary}</p>
<p>No real reservation will be made.</p>
{booking.status === 'pending' ? (
<div>
<button type="button" onClick={() => respond(booking.token, true)}>
Confirm
</button>{' '}
<button type="button" onClick={() => respond(booking.token, false)}>
Cancel booking
</button>
</div>
) : (
<p>
{booking.status === 'aborted'
? 'Booking decision cancelled.'
: booking.confirmed
? 'You confirmed this fictional booking.'
: 'You cancelled this fictional booking.'}
</p>
)}
</section>
);
}Stop and start fresh
The first Send creates a UUID lazily. New conversation clears local state and draft; the next Send creates a fresh UUID. There is no automatic reconnect, replay, saved-history restoration, durable store, or promise of server persistence. Unknown calls, native pauses, errors, and uncertain outcomes conservatively require New. Validation failures may produce legitimate error ToolMessages and a native continuation; they are not manufactured cancellation results.
Stop revokes active local controls and execution. The view can retain the last captured native observations for inspection. A last record marked running is historical evidence, not authority to continue, and a local completion or cancellation does not confirm native delivery. Leaving Run or changing frontend disposes the old session; returning starts empty and lazy.
Use the shared native runtime
Shared runtime uses the existing /ag-ui/client-tools/agent route. Runtime settings can supply a Developer runtime; its configured address stays outside the visible preview. This React frontend uses /ag-ui/client-tools/react/ and the AG-UI adapter. It shares the canonical backend with Angular.
import { createSession } from '@threadplane/ag-ui';
import { createClientToolsApplication } from './application';
// eslint-disable-next-line @nx/enforce-module-boundaries -- Isolated build copies the neutral internal bridge, not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
export interface ClientToolsConnection {
readonly url: string;
readonly label: 'Shared runtime' | 'Developer runtime';
}
export function clientToolsConnection(
configuration: RuntimeBridgeConfiguration
): ClientToolsConnection {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'langsmith')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui'
? configuration.target
: undefined;
const url =
target?.endpoint ??
new URL('/ag-ui/client-tools/agent', window.location.origin).href;
try {
const parsed = new URL(url);
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password
)
throw new Error();
} catch {
throw new Error('Runtime configuration failed.');
}
return Object.freeze({
url,
label: target ? 'Developer runtime' : 'Shared runtime',
});
}
export function createConnectedApplication(connection: ClientToolsConnection) {
return createClientToolsApplication({
sessionFactory(threadId, tools) {
const session = createSession({ threadId, url: connection.url }, tools);
try {
const native = session.getSnapshot();
if (
!native ||
!Array.isArray(native.transcript) ||
!Array.isArray(native.subagents) ||
!['idle', 'running', 'error'].includes(native.status) ||
!native.clientTools ||
!Array.isArray(native.clientTools.calls) ||
![
session.getSnapshot,
session.subscribe,
session.submit,
session.resume,
session.stop,
session.dispose,
].every((method) => typeof method === 'function')
)
throw new Error();
} catch {
try {
void Promise.resolve(session.dispose()).catch(() => undefined);
} catch {
/* Rejected candidate has no authority. */
}
throw new Error('Runtime session is unavailable.');
}
return session;
},
});
}"""LangGraph client-tools graph.
The browser declares the tools (get_weather/weather_card/confirm_booking) and
ships them to the model as the AG-UI client tool catalog; ag-ui-langgraph
merges them into state['tools']. This graph binds those client stubs onto the
model (no server implementation) and ends the turn when the model calls one —
the browser executes it and re-runs with a ToolMessage, which the model then
summarizes.
"""
from pathlib import Path
from langchain_core.messages import SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import Annotated, TypedDict
from threadplane.middleware.langgraph import bind_client_tools
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
class State(TypedDict):
# `tools` holds the client tool catalog ag-ui-langgraph merges in from
# RunAgentInput.tools — declared as a channel so the graph retains it.
messages: Annotated[list, add_messages]
tools: list
_base_llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
def build_client_tools_graph():
async def agent(state: State) -> dict:
# Bind the client catalog stubs (from state['tools']) onto the model.
llm = bind_client_tools(_base_llm, [], state)
system = (PROMPTS_DIR / "client-tools.md").read_text()
response = await llm.ainvoke([SystemMessage(content=system)] + state["messages"])
return {"messages": [response]}
# No server tools: a client tool call ends the run (the browser executes it),
# and so does a final text turn. So the agent node always routes to END.
def route(state: State) -> str:
return END
graph = StateGraph(State)
graph.add_node("agent", agent)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", route, {END: END})
return graph.compile(checkpointer=MemorySaver())
graph = build_client_tools_graph()from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
agent = LangGraphAgent(name="client-tools", graph=graph)
app = FastAPI(title="cockpit-ag-ui-client-tools")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
@app.get("/ok")
def ok() -> dict:
return {"ok": True}# Client Tools Assistant
You are a demo assistant showing browser-executed tools over AG-UI. You have
five client tools — call the right one and do not answer in prose first:
- When the user asks about the weather for a place, call `get_weather` with the
location. After it returns, give a one-sentence summary using the data.
- When the user asks to *show* or *display* a weather card, call `weather_card`
with the location and plausible readings (temperatureF, conditions, humidity,
windMph). After it renders, briefly confirm.
- When the user asks for a quiet or terminal weather snapshot, call
`weather_snapshot` with the location and plausible readings. Do not summarize
afterwards; the rendered card is the final response.
- When the user asks to test stopping a slow browser tool, call
`slow_status_check` with a short label.
- When the user asks to book or reserve something, call `confirm_booking` with a
one-line `summary` of what they're booking. After the user responds, confirm
if they accepted or acknowledge if they cancelled.
Keep replies to one short sentence; the components carry the detail.Source build and offline verification
From a checkout, with Node/npm, uv, and Python 3.12 available:
npm ci
npx nx build cockpit-ag-ui-client-tools-react
npx nx e2e cockpit-ag-ui-client-tools-reactThe e2e target depends on frozen canonical fixture preparation. For explicit preparation, run npx nx run cockpit-ag-ui-client-tools-react:prepare-fixture. The build installs the real private package artifacts with strict types. Deterministic offline browser checks use the actual canonical graph and locked AG-UI adapter with scripted model responses over HTTP/SSE. They verify local execution, physical continuations, and owned cleanup; they do not prove a live model-provider request. Docs, Code, and Run select this same source implementation.
{
"name": "cockpit-ag-ui-client-tools-react",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "cockpit/ag-ui/client-tools/react/src",
"projectType": "application",
"tags": [
"scope:cockpit-examples",
"scope:cockpit-e2e",
"framework:react"
],
"targets": {
"test": {
"executor": "@nx/vitest:test",
"options": {
"configFile": "cockpit/ag-ui/client-tools/react/vite.config.mts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"build": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [
{
"target": "build",
"projects": [
"core",
"content",
"react"
]
},
{ "target": "react-ag-ui", "projects": ["website-runtime-preparation"] }
],
"outputs": [
"{workspaceRoot}/dist/cockpit/ag-ui/client-tools/react"
],
"options": {
"command": "node scripts/react-cockpit/build.mjs ag-ui-client-tools"
}
},
"prepare-fixture": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs"
}
},
"e2e": {
"executor": "@nx/playwright:playwright",
"cache": false,
"dependsOn": [
"prepare-fixture",
"build"
],
"options": {
"config": "cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts",
"skipInstall": true
}
}
}
}