Page actions

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.

Flow diagramA tool call inside a conversation turn
  1. 1. Request a toolName + arguments + call ID
  2. 2. Validate and approveApplication rules determine what may execute
  3. 3. Execute in the owning environmentA server tool runs on the server; a client tool runs in the application
  4. 4. Return the resultAssociate the result with the original call ID
  5. 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.

The model requests an action. Your code validates, executes and returns its result.
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.

tools.ts
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();
    },
  });
}
app.tsx
// 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.

application.ts
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,
  });
}
projection.ts
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.

weather-card.tsx
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.

confirm-booking.tsx
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.

connection.ts
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;
    },
  });
}
graph.py
"""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()
server.py
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.md
# 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-react

The 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.

project.json
{
  "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
      }
    }
  }
}

Looking for something specific?