Page actions

React client-tools preview

Request weather, run the browser tool, and return its result to the conversation.

Recorded in the real example with fixture responses paced for readability. No live model requests or narration. Actual-speed footage with reading pauses. Capture details

Read the walkthrough
  1. Request weather in the conversation.
  2. The agent requests the registered browser weather tool.
  3. The browser returns its fixed demonstration result and the conversation continues. This is sample weather, not a live forecast.
Download the text walkthrough

Give the agent access to a capability in the browser, such as reading a selected itinerary day or showing a weather card. The model asks for a named tool; application code handles its finalized arguments and returns a result.

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.

Declare the function you can execute

The native catalog uses FunctionTool<Arguments, Result>. Its JSON Schema describes arguments to the model; TypeScript types do not validate received JSON at runtime. Validate inside the handler, and use its abort signal for cancellable work. This small tool reads a caller-owned selection. The running example below adds weather results and booking decisions.

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.

import type { FunctionTool } from '@threadplane/core/tools';
 
export function selectedDayTool(readDay: () => string): FunctionTool<Record<string, never>, { day: string }> {
  return {
    description: 'Read the itinerary day selected in the application.',
    parameters: { type: 'object', properties: {}, additionalProperties: false },
    handler(args, { signal }) {
      if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length) {
        throw new Error('This tool accepts no arguments.');
      }
      signal.throwIfAborted();
      return { day: readDay() };
    },
  };
}

The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.

Try browser tools

Choose Run and ask for weather in a city, a slow status check, a weather card, a weather snapshot, or confirmation of a fictional booking. The graph receives the authored catalog and requests a function. The browser handles its finalized arguments after the model run finishes.

Normal tools return results to another model run in the same confirmed conversation. Weather cards publish literal finalized details and return a shown acknowledgement. A standalone weather snapshot publishes its panel and persists that acknowledgement into the same thread without another model run. The composer remains busy until persistence completes. In a mixed batch, any normal tool makes the whole batch follow up.

tools.ts
import type { PlainValue } from '@threadplane/core';
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 BookingArguments { summary: string }
export interface StatusArguments { label?: string }
export interface WeatherPanel extends Readonly<WeatherArguments> {
  readonly id: string;
}
export interface BookingPanel {
  readonly id: string;
  readonly summary: string;
  readonly status: 'pending' | 'confirmed' | 'cancelled' | 'aborted';
}
export interface ClientToolsSnapshot {
  readonly weather: readonly WeatherPanel[];
  readonly bookings: readonly BookingPanel[];
}
 
function argumentsRecord(value: unknown, keys: readonly string[]) {
  if (!value || typeof value !== 'object' || Array.isArray(value))
    throw new Error('Invalid tool arguments.');
  const prototype = Object.getPrototypeOf(value);
  if (prototype !== Object.prototype && prototype !== null)
    throw new Error('Invalid tool arguments.');
  if (Object.keys(value).some((key) => !keys.includes(key)))
    throw new Error('Invalid tool arguments.');
  return value;
}
function field(value: object, key: string): unknown {
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
  if (!descriptor || !Object.hasOwn(descriptor, 'value'))
    throw new Error('Invalid tool arguments.');
  return descriptor.value;
}
function text(value: object, key: string): string {
  const entry = field(value, key);
  if (typeof entry !== 'string' || !entry.trim())
    throw new Error('Invalid tool arguments.');
  return entry;
}
function number(value: object, key: string): number {
  const entry = field(value, key);
  if (typeof entry !== 'number' || !Number.isFinite(entry))
    throw new Error('Invalid tool arguments.');
  return entry;
}
const weatherKeys = ['location', 'temperatureF', 'conditions', 'humidity', 'windMph'];
function weatherArguments(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;
}
function parameters(properties: Record<string, PlainValue>, required: readonly string[]): PlainValue {
  return { type: 'object', properties, required, additionalProperties: false };
}
const weatherParameters = parameters({
  location: { type: 'string', minLength: 1 },
  temperatureF: { type: 'number' },
  conditions: { type: 'string', minLength: 1 },
  humidity: { type: 'number', minimum: 0, maximum: 100 },
  windMph: { type: 'number', minimum: 0 },
}, weatherKeys);
const aborted = () => new DOMException('Client tool cancelled.', 'AbortError');
 
/** App-owned presentation and decision authority; no runtime call IDs are inferred. */
export function createClientToolsController() {
  let snapshot: ClientToolsSnapshot = Object.freeze({
    weather: Object.freeze([]), bookings: Object.freeze([]),
  });
  const listeners = new Set<() => void>();
  const cancellations = new Set<() => void>();
  const pending = new Map<string, {
    readonly row: BookingPanel;
    readonly signal: AbortSignal;
    readonly finish: (confirmed: boolean) => void;
  }>();
  let sequence = 0;
  let disposed = false;
  function active(signal: AbortSignal) {
    if (disposed || signal.aborted) throw aborted();
  }
  function publish(update: Partial<ClientToolsSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) notify();
  }
  function replaceBooking(row: BookingPanel, status: BookingPanel['status']) {
    publish({ bookings: Object.freeze(snapshot.bookings.map((entry) =>
      entry === row ? Object.freeze({ ...row, status }) : entry
    )) });
  }
  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;
        finished = true;
        cleanup();
        resolve();
      }, 3000);
      cancellations.add(cancel);
      signal.addEventListener('abort', cancel, { once: true });
      if (signal.aborted || disposed) cancel();
    });
  }
  async function showWeather(input: WeatherArguments, signal: AbortSignal) {
    active(signal);
    const fields = weatherArguments(input);
    active(signal);
    const row = Object.freeze({ id: `weather-${++sequence}`, ...fields });
    publish({ weather: Object.freeze([...snapshot.weather, row]) });
    active(signal);
    return { shown: true };
  }
  const tools = Object.freeze({
    get_weather: {
      description: 'Look up simulated weather for a location.',
      parameters: parameters({ location: { type: 'string', minLength: 1 } }, ['location']),
      async handler(input: LocationArguments, { signal }) {
        active(signal);
        const location = text(argumentsRecord(input, ['location']), 'location');
        active(signal);
        return { location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 };
      },
    } satisfies FunctionTool<LocationArguments, WeatherArguments>,
    slow_status_check: {
      description: 'Run a cancellable local status check. Use to test stopping a slow browser tool.',
      parameters: parameters({ label: { type: 'string', minLength: 1, default: 'status check' } }, []),
      async handler(input: StatusArguments, { signal }) {
        active(signal);
        const record = argumentsRecord(input, ['label']);
        const label = Object.hasOwn(record, 'label') ? text(record, 'label') : 'status check';
        await delay(signal);
        active(signal);
        return { label, status: 'complete' };
      },
    } satisfies FunctionTool<StatusArguments, { label: string; status: string }>,
    weather_card: {
      description: 'Display a simulated weather card with these finalized readings, then briefly confirm.',
      parameters: weatherParameters,
      handler: (input: WeatherArguments, { signal }) => showWeather(input, signal),
    } satisfies FunctionTool<WeatherArguments, { shown: boolean }>,
    weather_snapshot: {
      description: 'Display a simulated weather card as a terminal snapshot without an assistant summary.',
      parameters: weatherParameters,
      followUp: false,
      handler: (input: WeatherArguments, { signal }) => showWeather(input, signal),
    } satisfies FunctionTool<WeatherArguments, { shown: boolean }>,
    confirm_booking: {
      description: 'Ask the user to confirm a fictional booking. No real reservation is made.',
      parameters: parameters({ summary: { type: 'string', minLength: 1 } }, ['summary']),
      async handler(input: BookingArguments, { signal }) {
        active(signal);
        const summary = text(argumentsRecord(input, ['summary']), 'summary');
        active(signal);
        return new Promise<{ confirmed: boolean }>((resolve, reject) => {
          const row: BookingPanel = Object.freeze({ id: `booking-${++sequence}`, summary, status: 'pending' });
          let finished = false;
          const cleanup = () => {
            pending.delete(row.id);
            cancellations.delete(cancel);
            signal.removeEventListener('abort', cancel);
          };
          const cancel = () => {
            if (finished) return;
            finished = true;
            cleanup();
            replaceBooking(row, 'aborted');
            reject(aborted());
          };
          pending.set(row.id, {
            row, signal,
            finish(confirmed) {
              if (finished) return;
              finished = true;
              cleanup();
              replaceBooking(row, confirmed ? 'confirmed' : 'cancelled');
              resolve({ confirmed });
            },
          });
          cancellations.add(cancel);
          signal.addEventListener('abort', cancel, { once: true });
          publish({ bookings: Object.freeze([...snapshot.bookings, row]) });
          if (signal.aborted || disposed) cancel();
        });
      },
    } 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); };
    },
    decide(row: BookingPanel, confirmed: boolean): boolean {
      const request = pending.get(row.id);
      if (disposed || typeof confirmed !== 'boolean' || !request ||
        request.row !== row || request.signal.aborted) return false;
      request.finish(confirmed);
      return true;
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      listeners.clear();
      for (const cancel of [...cancellations]) cancel();
      pending.clear();
    },
  });
}

Booking decisions and ownership

Each fictional booking gets a separate Confirm booking or Cancel booking card. These actions stay available while chat is busy. Parallel cards resolve independently, including identical summaries. Completed weather panels and booking outcomes remain visible until New conversation.

React borrows snapshots through useAgent and renders native MessageList, ChatInput, and ApprovalCard. Weather details and booking summaries render as literal text. This preview uses authored functions and example-owned presentation; it does not implement a general view-tool or ask-tool registry or infer schemas from TypeScript.

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, useRef, useState } from 'react';
import { useAgent } from '@threadplane/react';
import { ApprovalCard, ChatInput, MessageList } from '@threadplane/react/chat';
import {
  createConnectedApplication,
  type clientToolsConnection,
} from './connection';
 
export function ClientToolsDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof clientToolsConnection>;
  readonly onReady: () => void;
}) {
  const [application, setApplication] = useState(() =>
    createConnectedApplication(connection)
  );
  const [resetting, setResetting] = useState(false);
  const [generation, setGeneration] = useState(0);
  const live = useRef(true);
  const snapshot = useAgent(application);
  useEffect(() => {
    onReady();
  }, [onReady]);
  useEffect(
    () => () => {
      void application.dispose();
    },
    [application]
  );
  useEffect(
    () => () => {
      live.current = false;
    },
    []
  );
  async function reset() {
    if (application.getSnapshot().busy || resetting) return;
    setResetting(true);
    await application.dispose();
    if (!live.current) return;
    setApplication(createConnectedApplication(connection));
    setGeneration((value) => value + 1);
    setResetting(false);
  }
  const status = snapshot.busy
    ? snapshot.creation === 'pending'
      ? 'Creating conversation…'
      : 'Running simulated browser tools…'
    : snapshot.outcome === 'success' && snapshot.canSubmit
    ? 'Response complete.'
    : snapshot.outcome === 'aborted'
    ? 'Stopped. Start a new conversation to continue.'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : 'Ready.';
  return (
    <ExampleLayout className="client-tools-demo">
      <header>
        <div>
          <h1>LangGraph client tools</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy || resetting}
          onClick={() => void reset()}
        >
          New conversation
        </button>
      </header>
      <p>
        Try simulated weather, a slow status check, a weather panel or snapshot,
        and a fictional booking decision. These browser tools use authored data;
        no real weather service or booking is contacted.
      </p>
      <MessageList rows={snapshot.rows} label="Conversation" />
      <section aria-label="Weather panels">
        <h2>Weather panels</h2>
        {snapshot.weather.length ? snapshot.weather.map((row) => (
          <dl key={row.id}>
            <div><dt>Location</dt><dd>{row.location}</dd></div>
            <div><dt>Temperature</dt><dd>{row.temperatureF} °F</dd></div>
            <div><dt>Conditions</dt><dd>{row.conditions}</dd></div>
            <div><dt>Humidity</dt><dd>{row.humidity}%</dd></div>
            <div><dt>Wind</dt><dd>{row.windMph} mph</dd></div>
          </dl>
        )) : <p>No weather panels yet.</p>}
      </section>
      <section aria-label="Booking decisions">
        <h2>Booking decisions</h2>
        {snapshot.bookings.length ? snapshot.bookings.map((row) => row.status === 'pending' ? (
          <ApprovalCard key={row.id} title="Simulated booking" disabled={resetting || !snapshot.busy}
            actions={[
              { id: 'confirm', label: 'Confirm booking', onSelect: () => { application.decide(row, true); } },
              { id: 'cancel', label: 'Cancel booking', onSelect: () => { application.decide(row, false); } },
            ]}>
            <p>{row.summary}</p>
          </ApprovalCard>
        ) : <div key={row.id}><p>{row.summary}</p><p>{({ confirmed: 'Confirmed', cancelled: 'Cancelled', aborted: 'Aborted' })[row.status]}</p></div>) : <p>No booking decisions yet.</p>}
      </section>
      <ExampleFooter>
        {snapshot.error && <p role="alert">{snapshot.error}</p>}
        <ChatInput
          key={generation}
          label="Message"
          placeholder="Ask for weather or a fictional booking…"
          disabled={resetting || !snapshot.canSubmit}
          busy={snapshot.busy}
          onStop={snapshot.busy ? () => void application.stop() : undefined}
          onSubmit={(text) => {
            if (!application.getSnapshot().canSubmit) return false;
            void application.submit(text);
          }}
        />
        <p role="status">{status}</p>
      </ExampleFooter>
    </ExampleLayout>
  );
}

The application confirms one conversation before dispatch and blocks duplicate commands synchronously. Stop cancels active work and revokes pending decisions. Failures, uncertain requests, unexpected pauses, and unresolved or unsupported tool requests require New conversation. Leaving the example releases its session and prevents late handlers from continuing the model.

application.ts
import type { AgentSession, AgentSnapshot, CompleteOutcome } from '@threadplane/core';
import { createMessageContent, type MessageRow } from '@threadplane/content/messages';
import { createClientToolsController, type ClientToolsSnapshot, type BookingPanel } from './tools';
 
export interface ClientToolsSession extends Omit<AgentSession, 'getSnapshot'> {
  getSnapshot(): AgentSnapshot & {
    readonly interrupts: readonly { readonly value?: unknown }[];
  };
}
export type ClientToolCatalog = ReturnType<typeof createClientToolsController>['tools'];
export interface ClientToolsApplicationSnapshot extends ClientToolsSnapshot {
  readonly creation: 'idle' | 'pending' | 'confirmed' | 'unconfirmed';
  readonly rows: readonly MessageRow[];
  readonly busy: boolean;
  readonly canSubmit: boolean;
  readonly outcome: CompleteOutcome | null;
  readonly error: string | null;
}
 
/** Owns one thread and the browser decisions admitted by its active command. */
export function createClientToolsApplication(options: {
  readonly createThread: (signal: AbortSignal) => Promise<string>;
  readonly sessionFactory: (threadId: string, tools: ClientToolCatalog) => ClientToolsSession;
}) {
  const controller = createClientToolsController();
  const content = createMessageContent();
  const listeners = new Set<() => void>();
  let snapshot: ClientToolsApplicationSnapshot = Object.freeze({
    creation: 'idle', rows: Object.freeze([]), ...controller.getSnapshot(),
    busy: false, canSubmit: true, outcome: null, error: null,
  });
  let session: ClientToolsSession | undefined;
  let release: (() => void) | undefined;
  let operation: AbortController | undefined;
  let disposed = false;
  let disposal: Promise<void> | undefined;
  function blocked() {
    const state = session?.getSnapshot();
    return !!state?.interrupts.length || !!state?.toolCalls.some((tool) =>
      !Object.hasOwn(controller.tools, tool.name) || tool.status === 'pending' || tool.status === 'running'
    // Typed sessions omit unknown tools, but retain their server call references.
    ) || !!state?.messages.some((message) => message.role === 'assistant' &&
      message.toolCallIds?.some((id) => !state.toolCalls.some((call) => call.id === id))
    ) || controller.getSnapshot().bookings.some((row) => row.status === 'pending');
  }
  function publish(update: Partial<ClientToolsApplicationSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) notify();
  }
  function projection() {
    return {
      ...controller.getSnapshot(),
      ...(session ? { rows: content.project(session.getSnapshot()) } : {}),
    };
  }
  function observe() {
    if (disposed) return;
    publish({ ...projection(), ...(blocked() ? { canSubmit: false } : {}) });
  }
  const releaseTools = controller.subscribe(observe);
  async function submit(text: string) {
    if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim()) return;
    if (blocked()) { observe(); return; }
    const admitted = new AbortController();
    operation = admitted;
    const current = () => !disposed && operation === admitted && !admitted.signal.aborted;
    publish({ busy: true, canSubmit: false, outcome: null, error: null });
    try {
      if (!current()) return;
      if (!session) {
        publish({ creation: 'pending' });
        if (!current()) return;
        const threadId = await options.createThread(admitted.signal);
        if (!current()) return;
        session = options.sessionFactory(threadId, controller.tools);
        release = session.subscribe(observe);
        publish({ creation: 'confirmed', ...projection() });
      }
      if (!current()) return;
      if (blocked()) {
        publish({ ...projection(), outcome: 'paused', canSubmit: false });
        return;
      }
      const outcome = await session.submit(text, { signal: admitted.signal });
      if (!current()) return;
      publish({
        ...projection(), outcome, canSubmit: outcome === 'success' && !blocked(),
        error: outcome === 'error' ? 'The LangGraph request failed.' : null,
      });
    } catch {
      if (!current()) return;
      publish({ creation: session ? 'confirmed' : 'unconfirmed', outcome: 'error',
        canSubmit: false, error: 'The LangGraph request failed.' });
    } finally {
      if (!disposed && operation === admitted) {
        operation = undefined;
        publish({ busy: false });
      }
    }
  }
  async function stop() {
    if (disposed || !operation) return;
    operation.abort();
    operation = undefined;
    publish({ ...projection(), busy: false, canSubmit: false, outcome: 'aborted',
      creation: session ? 'confirmed' : 'unconfirmed' });
    try { await session?.stop(); } catch { /* Reset is required. */ }
  }
  function dispose(): Promise<void> {
    if (disposal) return disposal;
    disposed = true;
    operation?.abort();
    operation = undefined;
    release?.();
    releaseTools();
    controller.dispose();
    content.dispose();
    listeners.clear();
    disposal = Promise.resolve().then(() => session?.dispose()).then(() => undefined);
    return disposal;
  }
  return {
    getSnapshot: () => snapshot,
    subscribe(notify: () => void) {
      if (disposed) return () => undefined;
      listeners.add(notify);
      return () => { listeners.delete(notify); };
    },
    submit, stop, dispose,
    decide(row: BookingPanel, confirmed: boolean) {
      if (disposed || !operation || operation.signal.aborted || !snapshot.busy) return false;
      return controller.decide(row, confirmed);
    },
  };
}

The existing shared backend is used by default. A developer LangGraph target can be configured through runtime settings; credentials travel in SDK headers. Keep credentials out of messages and source code.

connection.ts
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { createClientToolsApplication } from './application';
 
export function clientToolsConnection(configuration: RuntimeBridgeConfiguration): {
  apiUrl: string;
  headers: Record<string, string>;
} {
  if (
    configuration.status === 'error' ||
    (configuration.status === 'configured' &&
      configuration.target.kind === 'ag-ui')
  )
    throw new Error('Runtime configuration failed.');
  const target =
    configuration.status === 'configured' &&
    configuration.target.kind === 'langsmith'
      ? configuration.target
      : null;
  return {
    apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
    headers: target ? { 'x-api-key': target.apiKey } : {},
  };
}
export function createConnectedApplication(
  connection: ReturnType<typeof clientToolsConnection>
) {
  const client = new Client({
    apiUrl: connection.apiUrl,
    apiKey: null,
    defaultHeaders: connection.headers,
    callerOptions: { maxRetries: 0 },
  });
  return createClientToolsApplication({
    async createThread(signal) {
      const threadId = crypto.randomUUID();
      const created = await client.threads.create({
        threadId,
        ifExists: 'raise',
        signal,
      });
      if (created.thread_id !== threadId)
        throw new Error('Conversation creation was not confirmed.');
      return threadId;
    },
    sessionFactory: (threadId, tools) =>
      createSession({
        assistantId: 'client-tools',
        tools,
        threadId,
        apiUrl: connection.apiUrl,
        clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
      }),
  });
}

Build from source

npx nx build cockpit-langgraph-client-tools-react
npx nx e2e cockpit-langgraph-client-tools-react

The isolated build installs local candidate packages and checks strict declarations and emitted browser modules. Browser tests use real SDK HTTP and SSE against a local fixture. Docs, Code, and Run select this same React implementation.

Looking for something specific?