Page actions

Client Tools

Client tools are tools you declare in the browser that the model calls and the browser executes, with no server-side implementation. The Angular app declares them, the @threadplane/langgraph adapter attaches the catalog to every run it starts under the client_tools key, and the graph binds those declarations onto the model and ends its turn when one is called. The browser then produces the result and hands it back, either as a new run or as a direct write to the thread. This guide walks the running example, from the graph to the components the model fills.

HelperKindWhat it does
action()functionRuns a handler in the browser; its resolved return value becomes the tool result.
view()render-only componentThe model fills the component's inputs from the schema; the card renders inline and the call is acknowledged once it mounts.
ask()interactive componentThe model fills the component's inputs; the value the component emits back becomes the tool result.
Note: Adapter-neutral declarations

tools, action, view, and ask come from @threadplane/chat, so the same registry works on @threadplane/langgraph and @threadplane/ag-ui. Only the provideAgent/injectAgent imports and the backend wiring change. The AG-UI twin of this guide walks the same three declarations over the AG-UI transport.

What the demo does

The Run tab shows the prebuilt <chat> composition in front of a LangGraph agent wired to five browser-executed tools. Ask "What is the weather in San Francisco?" and the model calls get_weather, whose handler runs in the page and returns a reading the model then summarizes in one sentence.

Ask it to show a weather card for Tokyo and the model calls weather_card instead, filling an Angular component's inputs with readings it invents, and the card renders inline in the transcript. Ask it to book a table and confirm_booking mounts a card with Confirm and Cancel buttons, and whichever you click becomes the tool result that resumes the run.

Two further prompts exercise the edges. A quiet weather snapshot calls weather_snapshot, which renders the same card and ends the turn with no spoken summary after it. A request to test stopping a slow browser tool calls slow_status_check, which waits three seconds and can be cancelled with the stop button.

How it is built

Seven files carry the feature: a graph that binds the browser's declarations and ends its turn, an application config, the typed agent reference the config and the component share, the schemas the registry and its components share, the file that declares the registry and passes it to <chat>, and the two components the model fills. Open the Code tab to read them in place.

The graph binds client stubs and ends the turn

The graph has one node. Its State declares a client_tools channel alongside messages, because that is the key the LangGraph adapter writes the catalog to, and bind_client_tools reads it there to bind the declarations onto the model for this invocation. There are no server tools, so route returns END unconditionally. This graph compiles without a checkpointer, because it runs under the LangGraph server, which provides one.

graph.py
"""LangGraph client-tools graph (LangGraph-direct path).
 
The browser declares the tools (get_weather/weather_card/confirm_booking) and
the `@threadplane/langgraph` adapter ships the catalog as `input.client_tools`.
This graph declares a `client_tools` channel so the catalog is retained across
the turn, 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 typing_extensions import Annotated, TypedDict
 
from threadplane.middleware.langgraph import bind_client_tools
 
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
 
 
class State(TypedDict):
    messages: Annotated[list, add_messages]
    # The @threadplane/langgraph adapter ships the client tool catalog here.
    client_tools: list
 
 
_base_llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
 
def build_client_tools_graph():
    async def agent(state: State) -> dict:
        # bind_client_tools reads state['tools'] then falls back to state['client_tools'].
        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]}
 
    def route(state: State) -> str:
        return END  # no server tools: a client tool call ends the run; the browser executes it
 
    graph = StateGraph(State)
    graph.add_node("agent", agent)
    graph.set_entry_point("agent")
    graph.add_conditional_edges("agent", route, {END: END})
    return graph.compile()
 
 
# The graph instance — referenced by langgraph.json. For `langgraph dev` the
# platform runtime provides the checkpointer, so we compile without one (mirrors
# cockpit/langgraph/streaming/python/src/graph.py).
graph = build_client_tools_graph()

bind_client_tools(llm, [], state) converts each catalog entry into an OpenAI function-tool dictionary and calls llm.bind_tools with it; the empty list is where your own server tools would go.

Note: Why the turn ends on a tool call

The browser, not the graph, produces the result. Ending the run is the signal the adapter waits for: a client tool call counts as pending only once the run is no longer loading. A graph that looped back to a tools node would leave the browser nothing to do.

Warning: Read the catalog from the key your adapter writes

bind_client_tools reads state["tools"] first and falls back to state["client_tools"], so one graph serves both adapters. The LangGraph adapter writes client_tools; declare that channel or the merged value is dropped, the model is bound with nothing, and the demo goes quiet with no error.

The AG-UI twin's sibling graph is a separate file that compiles with MemorySaver(), because ag-ui-langgraph reads the thread's state through a checkpointer.

Providing the agent

provideAgent() from @threadplane/langgraph registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its apiUrl and assistantId at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them; your own application passes them directly. The CLIENT_TOOLS_AGENT_REF argument is a typed reference, covered under typed agent state below.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
import { CLIENT_TOOLS_AGENT_REF } from './agent-ref';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(CLIENT_TOOLS_AGENT_REF, () => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'langgraph') {
        throw new Error('incompatible runtime');
      }
      return {
        apiUrl: connection.apiUrl,
        assistantId: connection.assistantId,
        clientOptions: connection.clientOptions,
      };
    }),
  ],
};

Declaring the tool registry

tools() builds a name-keyed registry, and each entry is one of three declarations from @threadplane/chat. action() takes a handler that runs in the browser and may return a promise, view() takes a component the model fills, and ask() takes a component that reports a value back. Every entry carries a description the model reads and a schema authored with zod/v4.

import { z } from 'zod/v4';
 
/** Schema for the `weather_card` view tool. */
export const weatherCardSchema = z.object({
  location: z.string(),
  temperatureF: z.number(),
  conditions: z.string(),
  humidity: z.number(),
  windMph: z.number(),
});
 
/** Schema for the `confirm_booking` ask tool. */
export const confirmBookingSchema = z.object({ summary: z.string() });

The weather_card, weather_snapshot, and confirm_booking entries take their schemas from schemas.ts, the file the registry and the two components it points at all import from. The object keys are the tool names the model sees, and weather_snapshot shows the fourth argument: options.

client-tools.component.ts — the registry
const clientTools = tools({
  get_weather: action(
    'Look up the current weather for a location.',
    z.object({ location: z.string() }),
    async ({ location }) => ({ location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }),
  ),
  slow_status_check: action(
    'Run a cancellable local status check. Use when the user asks to test stopping a slow browser tool.',
    z.object({ label: z.string().default('status check') }),
    async ({ label }, { signal }) => {
      await waitForAbortableDelay(3000, signal);
      return { label, status: 'complete' };
    },
  ),
  weather_card: view(
    'Display a weather card for a location with the given readings.',
    weatherCardSchema,
    WeatherCardComponent,
  ),
  weather_snapshot: view(
    'Display a weather card as a terminal snapshot without asking the assistant to summarize afterwards.',
    weatherCardSchema,
    WeatherCardComponent,
    { followUp: false },
  ),
  confirm_booking: ask(
    'Ask the user to confirm a booking before finalizing it.',
    confirmBookingSchema,
    ConfirmBookingComponent,
  ),
});

It reuses the weather card with { followUp: false }, which is what makes it terminal.

Warning: Author schemas with zod/v4

The catalog the model sees is derived by deriveJsonSchema, which calls toJSONSchema from zod/v4. A validator it cannot convert throws with the tool name in the message.

Passing the registry to chat

The registry reaches the UI through one input. <chat [clientTools]="clientTools"> builds a coordinator for that registry, which ships the catalog to the agent, runs function tools, mounts view and ask components, and settles each call.

client-tools.component.ts — the chat wiring
@Component({
  selector: 'app-client-tools',
  standalone: true,
  imports: [ChatComponent, ExampleChatLayoutComponent],
  template: `
    <example-chat-layout>
      <chat main [agent]="agent" [clientTools]="clientTools" class="flex-1 min-w-0" />
    </example-chat-layout>
  `,
})
export class ClientToolsComponent {
  /** Typed agent: state() and value() are ClientToolsState. */
  protected readonly agent = injectAgent(CLIENT_TOOLS_AGENT_REF);
  protected readonly clientTools = clientTools;
 
  /**
   * Typed state read — proves the typed DI path compiles under strict: true.
   * `messages` and `client_tools` are read from the strongly-typed
   * `ClientToolsState` shape; the compiler errors if the field does not exist.
   */
  protected readonly messageCount = computed((): number => {
    const s: ClientToolsState = this.agent.value();
    return s.messages.length;
  });
}

The messageCount computed is there to prove the typed dependency-injection path compiles: agent.value() is a ClientToolsState, so a field that does not exist on that shape is a build error.

The view component the model fills

weather_card is a view: the model supplies the props, the card renders inline, and the coordinator acknowledges the call itself with { shown: true }. The component types its inputs through ClientToolViewProps<typeof weatherCardSchema>, which is the schema's output plus two framework-supplied props, status and clientTool.

weather-card.component.ts
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { ClientToolViewProps } from '@threadplane/chat';
import { weatherCardSchema } from './schemas';
 
/**
 * A frontend-owned view rendered for the `weather_card` tool call. Receives
 * the tool call's arguments while it streams (`location`), the merged result
 * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a
 * `status` of 'running' | 'complete'. Renders a loading affordance until the
 * result arrives.
 *
 * Input types are derived from {@link weatherCardSchema} via `ClientToolViewProps` so
 * that a schema change is a compile error here under `strict: true`.
 */
 
/** Props this component receives from the `weather_card` schema. */
type WeatherCardProps = ClientToolViewProps<typeof weatherCardSchema>;
 
@Component({
  selector: 'app-weather-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="wc">
      <div class="wc__head">
        <span class="wc__loc">{{ location() ?? 'Weather' }}</span>
        @if (pending()) { <span class="wc__badge">Loading…</span> }
      </div>
      @if (!pending()) {
        <div class="wc__temp">{{ temperatureF() }}°F</div>
        <div class="wc__cond">{{ conditions() }}</div>
        <dl class="wc__meta">
          <div><dt>Humidity</dt><dd>{{ humidity() }}%</dd></div>
          <div><dt>Wind</dt><dd>{{ windMph() }} mph</dd></div>
        </dl>
      }
    </div>
  `,
  styles: [`
    .wc { border: 1px solid var(--ds-border, #e5e7eb); border-radius: 12px; padding: 16px; max-width: 320px; background: var(--ds-surface); color: var(--ds-text-primary); }
    .wc__head { display: flex; align-items: center; justify-content: space-between; }
    .wc__loc { font-weight: 600; }
    .wc__badge { font-size: 12px; color: var(--ds-text-muted); }
    .wc__temp { font-size: 32px; font-weight: 700; margin-top: 8px; }
    .wc__cond { color: var(--ds-text-secondary); }
    .wc__meta { display: flex; gap: 24px; margin: 12px 0 0; }
    .wc__meta dt { font-size: 11px; text-transform: uppercase; color: var(--ds-text-muted); }
    .wc__meta dd { margin: 0; font-weight: 600; }
  `],
})
export class WeatherCardComponent {
  // Schema-derived inputs — types anchored to WeatherCardProps so a schema
  // change is a compile error. Optional because the framework sends partial
  // props during streaming (args arrive before the tool result).
  readonly location    = input<WeatherCardProps['location']>();
  readonly temperatureF = input<WeatherCardProps['temperatureF']>();
  readonly conditions  = input<WeatherCardProps['conditions']>();
  readonly humidity    = input<WeatherCardProps['humidity']>();
  readonly windMph     = input<WeatherCardProps['windMph']>();
  /** Extra input not in the schema: injected by the framework for rendering state. */
  readonly status = input<WeatherCardProps['status']>();
  readonly clientTool = input<WeatherCardProps['clientTool']>();
 
  readonly pending = computed(() => this.clientTool()?.phase !== 'complete' || this.temperatureF() === undefined);
}

Inputs are optional because props arrive while the call streams, so the card reads clientTool()?.phase and shows a loading badge until the phase is complete and the readings have arrived.

The ask component that resumes the run

confirm_booking is an ask: the model fills summary, and the run waits for a person. The component injects the render host and calls result({ confirmed }), which the coordinator matches to the pending call by tool name and settles as that call's result.

confirm-booking.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import type { ClientToolViewProps } from '@threadplane/chat';
import { injectRenderHost } from '@threadplane/render';
import { confirmBookingSchema } from './schemas';
 
/**
 * The interactive component for the `confirm_booking` client tool (an `ask`).
 * The model fills `summary`; the user confirms or cancels; the chosen value is
 * announced via `injectRenderHost().result(...)` and becomes the tool result
 * that resumes the run.
 *
 * Once the ask resolves, the adapter writes the emitted `{ confirmed }` back
 * onto the local tool call, so this component re-renders with `confirmed` as a
 * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When
 * `confirmed()` is defined we render a FROZEN line with no buttons; the live
 * interactive card only shows while `confirmed()` is still undefined.
 *
 * Input types for schema-derived props are anchored to `ClientToolViewProps<typeof
 * confirmBookingSchema>` — a schema change is a compile error here.
 */
 
/** Props this component receives from the `confirm_booking` schema. */
type ConfirmBookingProps = ClientToolViewProps<typeof confirmBookingSchema>;
 
@Component({
  selector: 'app-confirm-booking',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (confirmed() === undefined) {
      <div class="cb" [attr.data-phase]="clientTool()?.phase">
        <p class="cb__summary">{{ summary() }}</p>
        <div class="cb__actions">
          <button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
          <button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
        </div>
      </div>
    } @else if (confirmed() === true) {
      <div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
        <p class="cb__summary">Booking confirmed ✓</p>
      </div>
    } @else {
      <div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
        <p class="cb__summary">Booking cancelled</p>
      </div>
    }
  `,
  styles: [`
    .cb { border: 1px solid var(--ds-border, #e5e7eb); border-radius: 12px; padding: 16px; max-width: 360px; background: var(--ds-surface); color: var(--ds-text-primary); }
    .cb__summary { margin: 0 0 12px; }
    .cb--resolved .cb__summary { margin: 0; color: var(--ds-text-secondary); }
    .cb__actions { display: flex; gap: 8px; }
    .cb__btn { padding: 6px 14px; border-radius: 8px; border: 1px solid var(--ds-border, #e5e7eb); background: var(--ds-surface-dim); color: var(--ds-text-secondary); cursor: pointer; }
    .cb__btn--primary { background: var(--ds-accent, #64C3FD); color: #08243a; border-color: transparent; font-weight: 600; }
  `],
})
export class ConfirmBookingComponent {
  // Schema-derived input — type anchored to ConfirmBookingProps.
  readonly summary = input<ConfirmBookingProps['summary']>();
  /** Spread back onto props after the ask resolves (undefined while interactive). */
  readonly confirmed = input<boolean | undefined>(undefined);
  readonly clientTool = input<ConfirmBookingProps['clientTool']>();
  private readonly host = injectRenderHost();
  protected respond(confirmed: boolean): void {
    this.host.result({ confirmed });
  }
}
Tip: Why the card freezes after the answer

Settling writes the emitted value onto the local tool call, and the mounted component is re-rendered with the merged props. That is why confirmed is declared with a default of undefined: while it is undefined the buttons are live, and once the user answers the template branches to a frozen line with no buttons.

Stopping a tool that is still running

A function handler receives a context object as its second argument, carrying an AbortSignal. slow_status_check waits three seconds through a helper that rejects the moment the signal aborts.

client-tools.component.ts — the abortable delay
function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(new DOMException('Aborted', 'AbortError'));
      return;
    }
    const timeout = window.setTimeout(resolve, ms);
    signal.addEventListener(
      'abort',
      () => {
        window.clearTimeout(timeout);
        reject(new DOMException('Aborted', 'AbortError'));
      },
      { once: true },
    );
  });
}

The executor wraps the agent's stop, so pressing stop aborts every in-flight handler, and a cancelled call is recorded without starting a new run.

The round trip in one pass

1
The catalog ships with the run

The coordinator converts the registry into specs, each with a name, a description, and a JSON Schema, and the adapter attaches them to every run it starts as client_tools.

2
The model calls a tool and the graph ends the turn

Because the tool has no server implementation, the run finishes with a tool call that carries no result.

3
The browser sees the call as pending

Pending calls are those whose name is in the catalog, whose result is undefined, and which this client has not resolved yet, and only while the agent is not loading.

4
The browser produces the result

A function handler runs, a view is acknowledged on mount, and an ask waits for the user's value.

5
The result is recorded and the run usually continues

The result is written onto the local tool call and staged as a tool message addressed to the call id. A group that wants a follow-up starts the next run and carries the staged messages with it; a terminal group is written to the thread instead.

Settle, flush, and resolve on LangGraph

The adapter implements three operations for a produced result, and the coordinator picks between them.

settle(id, result) records the result: the value is written onto the local tool call so the transcript card freezes, and a tool message with the deterministic id client-tool-result-<callId> is staged in memory. resolve(id, result) settles and then issues a new run on the same thread with { messages: [<the staged tool messages>], client_tools: <the catalog> }, which is the normal path. flush() makes staged results durable without continuing: on @threadplane/langgraph it writes them into the thread with one threads.updateState call, so no run is started and the graph's resume point is untouched.

That is the sharpest difference from the AG-UI adapter, where flush() is a deliberate no-op because settle has already placed the tool message in the message list the next run carries.

The choice matters at the edges. A tool declared with followUp: false, like weather_snapshot in this example, is settled and flushed instead of resolved, so the rendered card is the final answer and no summary follows. A cancelled call takes the same path, because continuing the run is exactly what the user asked not to happen.

Follow-up is decided per tool-call group, not per tool. If the model calls three tools in one turn and any one of them wants a follow-up, the whole group continues in a single run once every result has settled. Only when every tool in the group is terminal does the group flush instead.

Warning: A custom transport needs updateState

flush() writes through the transport's updateState. The default transport implements it. If you supply your own and it does not, the adapter never installs a persist function, a non-empty flush() rejects with that reason, and the results stay staged. They still ride along on the next ordinary submit, which prepends them to that run's message list, but a page reload first loses them and leaves the server thread holding an unanswered tool call.

Note: Staged results belong to their thread

Each staged result is stamped with the thread it settled on. Switching threads discards the staging buffer, and a result settled for a thread you have left is dropped with a warning rather than written to the thread you moved to.

Stopping and continuation limits

Pressing stop while a client tool is running aborts the handler, records a cancelled result so the server never holds an unanswered tool call, and does not start a new run. The cancelled call is not re-executed.

Handlers receive an AbortSignal in their second argument. Forward it to fetch so in-flight work actually stops:

const search = action(
  'Search the catalog.',
  z.object({ query: z.string() }),
  async ({ query }, { signal }) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
    return res.json();
  },
);

Runaway loops are capped. A model that keeps calling client tools is stopped after ten continuation groups per user turn. Tune that with [clientToolContinuationPolicy]:

import { Component } from '@angular/core';
import type { ClientToolContinuationPolicy } from '@threadplane/chat';
 
@Component({
  template: `
    <chat
      [agent]="agent"
      [clientTools]="clientTools"
      [clientToolContinuationPolicy]="policy"
    />
  `,
})
export class ClientToolsComponent {
  protected readonly policy: ClientToolContinuationPolicy = {
    maxTurns: 5, // 0 disables the cap
    onLimit: (event) => console.warn('client tool loop stopped', event.toolNames),
  };
}

When the cap trips, tools that already produced a real result keep it; tools that never ran are settled with a limit error so the thread stays valid. The run does not continue. <chat> also emits the same event through its (clientToolContinuationLimit) output.

Re-running tools safely with idempotent

action() accepts an idempotent option. It matters only when you supply a [clientToolExecutionGuard], a durable store that claims each tool call before the browser executes it, so a handler with real side effects cannot run twice across a reload or a reconnect.

const clientTools = tools({
  charge_card: action(
    'Charge the saved payment method.',
    z.object({ amountCents: z.number() }),
    chargeCard,
    // default: claimed before execution, fail-closed if interrupted
  ),
  fetch_quote: action(
    'Fetch a shipping quote.',
    z.object({ zip: z.string() }),
    fetchQuote,
    { idempotent: true }, // safe to re-run; skips the durable claim
  ),
});

Tools are treated as non-idempotent by default. Mark a tool idempotent: true only when re-running it is genuinely harmless: reads, pure computations, lookups.

Note: Exactly-once has a limit

The guard gives you at-most-once dispatch, not exactly-once effects. If a handler completes its side effect and the browser dies before recording the result, the guard fails closed and reports the call as interrupted. For true end-to-end idempotency, have the handler pass its own idempotency key to the downstream service.

Typed props and typed handler arguments

The view and ask overloads are typed against the paired component: every field the schema produces must be a declared input() on that component with an assignable type, so a schema change is a compile error at the view(...) or ask(...) call site rather than a silent runtime gap. The component may declare extra inputs the schema does not fill.

Author the component from the schema so the two cannot drift. ClientToolViewProps<typeof schema> is the schema output plus the framework-supplied status and clientTool, which is what both components in this example use. ViewProps<typeof schema> is the schema output alone, for a component that does not read the lifecycle.

For action(), the handler argument type is inferred from the schema automatically. When you want to name that type, to write the handler separately, use ToolArgs<typeof schema>:

import { action, type ToolArgs } from '@threadplane/chat';
import { z } from 'zod/v4';
 
const moveSchema = z.object({ fromDay: z.number(), toDay: z.number() });
 
async function moveStop(args: ToolArgs<typeof moveSchema>) {
  // args is { fromDay: number; toDay: number }
  return reorder(args.fromDay, args.toDay);
}
 
const move = action('Move a stop to another day.', moveSchema, moveStop);

Reading client-tool results on the server

A client-tool result reaches your graph as a tool message keyed by tool-call id, with no tool name on it. The two adapters do not set identical fields, but the point holds regardless of whatever else the adapter sets: there is no name to filter on. Here is the LangGraph adapter's shape:

{ "id": "client-tool-result-call_abc", "role": "tool", "type": "tool", "tool_call_id": "call_abc", "content": "{\"saved\":1}" }

That matters the moment a node tries to find those results. The intuitive filter is by name, and it fails silently, matching nothing, forever, with no error:

# Wrong: client-tool results carry no name, so this is always empty.
saved = [m for m in state["messages"] if isinstance(m, ToolMessage) and m.name == "add_link"]

Resolve the name through the AI message that requested the call, then match on the id:

def results_for(messages: list, tool_name: str) -> list:
    call_ids = {
        call["id"]
        for m in messages
        for call in getattr(m, "tool_calls", None) or []
        if call.get("name") == tool_name
    }
    return [
        m for m in messages
        if isinstance(m, ToolMessage) and m.tool_call_id in call_ids
    ]
Note: Why not just send the name?

For AG-UI this is fixed by the protocol: ToolMessageSchema in @ag-ui/core defines exactly id, role, content, toolCallId, and optional error and encryptedValue, and parses in strip mode, so an extra name would be dropped on the wire. Matching on the call id is the portable approach across both adapters.

Typed agent state

Tool handlers and components often read agent state. Pair the registry with a typed AgentRef so agent.state() and agent.value() carry your state shape instead of Record<string, unknown>. The example declares one that mirrors the graph's State, passes it to provideAgent in app.config.ts, and reads it back through injectAgent:

import { createAgentRef } from '@threadplane/chat';
import type { BaseMessage } from '@langchain/core/messages';
 
export interface ClientToolsState {
  messages: BaseMessage[];
  client_tools: unknown[];
}
 
export const CLIENT_TOOLS_AGENT_REF = createAgentRef<ClientToolsState>('client-tools');
 
// component: injectAgent(CLIENT_TOOLS_AGENT_REF) is a LangGraphAgent<ClientToolsState>

Typed state via AgentRef covers the rest of the pattern.

Mixing server tools and client tools

Nothing about this design is exclusive. Pass your server tools as the second argument of bind_client_tools(llm, server_tools, state), and route a server tool call to your tools node while a client tool call still ends the turn. The route_after_agent helper in the same middleware module makes that split: it returns the tools node when the last message calls a server tool, and the end node when the last message calls only client tools or no tools at all.

from langgraph.graph import END
from threadplane.middleware.langgraph import bind_client_tools, route_after_agent
 
graph.add_conditional_edges(
    "agent",
    lambda s: route_after_agent(s, ["search"]),
    {"tools": "tools", "__end__": END},
)

Keep the tool names in the registry aligned with the names in the system prompt. The prompt in this example tells the model which tool to call for each kind of request, and a rename on one side alone is the most common reason a demo goes quiet.

API reference

import {
  tools, action, view, ask,
  type ClientToolViewProps, type ViewProps, type ToolArgs,
  type ClientToolDef, type ClientToolRegistry,
  type ClientToolExecutionOptions, type ClientToolContinuationOptions,
  type ClientToolContinuationPolicy, type ClientToolContinuationLimitEvent,
  type ClientToolExecutionStore, type ClientToolExecutionGuard,
} from '@threadplane/chat';
ExportPurpose
action(description, schema, handler, options?)Declare a function tool (handler return becomes the result)
view(description, schema, component, options?)Declare a render-only component tool (acknowledged on mount)
ask(description, schema, component, options?)Declare an interactive component tool (emitted value becomes the result)
tools(map)Freeze a name-keyed registry for [clientTools]
ClientToolViewProps<S>Schema output plus the framework-supplied status and clientTool
ViewProps<S>Component input prop bag inferred from a schema
ToolArgs<S>Handler argument type inferred from a schema
ClientToolDef / ClientToolRegistryThe tool-definition union and frozen-registry types
ClientToolContinuationOptions{ followUp? }, accepted by view() and ask()
ClientToolExecutionOptions{ followUp?, idempotent? }, accepted by action()
ClientToolContinuationPolicy{ maxTurns?, onLimit? } for [clientToolContinuationPolicy]
ClientToolExecutionStore / ClientToolExecutionGuardDurable claim store for [clientToolExecutionGuard]

Component inputs on <chat>:

InputPurpose
[clientTools]The frozen registry from tools({...})
[clientToolContinuationPolicy]Cap runaway continuation loops (default ten groups per turn)
[clientToolExecutionGuard]Durable claim-before-execute for non-idempotent tools
Note: Writing your own adapter?

The settle / flush / resolve contract behind these features is documented in Writing an Adapter › Client Tools.

What's Next

Looking for something specific?