Chat ยท Guides

Client Tools

Client tools are tools you declare in the browser that the model calls and the browser executes โ€” no server-side implementation. There are three kinds:

HelperKindWhat it does
action()functionRuns an async handler in the browser; its resolved return value becomes the tool result sent back to the model.
view()render-only componentThe model fills the component's props from the schema; the card renders inline and the call is auto-acknowledged once it mounts.
ask()interactive componentThe model fills the component's props; the value the component emits back becomes the tool result (human-in-the-loop).

Tools are arguments-typed by a Standard Schema (e.g. a Zod object). The catalog is shipped to the model by the adapter; the backend graph binds the client stubs and ends its turn so the browser executes them.

Adapter-neutral

The same declarations work with @threadplane/langgraph and @threadplane/ag-ui โ€” only the provideAgent/injectAgent imports change.

#Declaring a registry

tools({...}) collects named tools into a frozen registry. Pass it to <chat> via [clientTools]:

import { Component } from '@angular/core';
import { ChatComponent, tools, action, view, ask } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
import { z } from 'zod/v4';
import { WeatherCardComponent } from './weather-card.component';
import { ConfirmBookingComponent } from './confirm-booking.component';
 
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' }),
  ),
  weather_card: view(
    'Display a weather card for a location.',
    z.object({ location: z.string(), temperatureF: z.number(), conditions: z.string() }),
    WeatherCardComponent,
  ),
  confirm_booking: ask(
    'Ask the user to confirm a booking before finalizing it.',
    z.object({ summary: z.string() }),
    ConfirmBookingComponent,
  ),
});
 
@Component({
  selector: 'app-client-tools',
  standalone: true,
  imports: [ChatComponent],
  template: `<chat [agent]="agent" [clientTools]="clientTools" />`,
})
export class ClientToolsComponent {
  protected readonly agent = injectAgent();
  protected readonly clientTools = clientTools;
}

The object keys (get_weather, weather_card, confirm_booking) are the tool names the model sees. tools() preserves each tool's precise generic type, so downstream lookups stay typed.

#Typed component props with ViewProps

For view() and ask(), the component's signal inputs are checked against the schema output at compile time โ€” every field the schema produces must be a declared input() with an assignable type (the component may declare extra inputs the schema doesn't fill). Derive the input types directly from the schema with ViewProps<typeof schema> so the two never drift:

import { Component, input } from '@angular/core';
import type { ViewProps } from '@threadplane/chat';
import { z } from 'zod/v4';
 
export const weatherCardSchema = z.object({
  location: z.string(),
  temperatureF: z.number(),
  conditions: z.string(),
});
 
// { location: string; temperatureF: number; conditions: string }
type Inputs = ViewProps<typeof weatherCardSchema>;
 
@Component({
  selector: 'app-weather-card',
  standalone: true,
  template: `<div>{{ location() }}: {{ temperatureF() }}ยฐF, {{ conditions() }}</div>`,
})
export class WeatherCardComponent {
  location = input.required<string>();
  temperatureF = input.required<number>();
  conditions = input.required<string>();
}

Under strict: true, the typed view/ask overloads report a compile error at the view(...)/ask(...) call site if the component's inputs diverge from the schema โ€” mismatches become build errors, not silent runtime failures.

#Typed handler args with ToolArgs

For action(), the handler argument type is inferred from the schema automatically. When you want to name that type โ€” e.g. to write the handler separately โ€” use ToolArgs<typeof schema> (an alias of the schema's inferred output):

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);

#Terminal tools with followUp: false

By default, resolving a client tool starts a new run so the model can react to the result. Pass followUp: false when a tool ends the turn โ€” a summary card, a confirmation receipt, anything the model has nothing further to say about. The result is still recorded on the server; the model simply is not asked to respond to it.

const clientTools = tools({
  trip_summary: view(
    'Show a final trip summary card. Call this last โ€” it ends the turn.',
    z.object({ title: z.string(), days: z.array(z.string()) }),
    TripSummaryCardComponent,
    { followUp: false },
  ),
});

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 turn end.

Your transport must support durable writes

A terminal group has no follow-up run to carry its results, so the adapter writes them to the server directly. On @threadplane/langgraph that uses the transport's updateState. If a custom transport does not implement updateState, flush() rejects when terminal results are staged. The results stay buffered and an ordinary next message can still carry them, but a browser page reload first loses that in-memory fallback and leaves the server thread with an unanswered tool call. If you supply your own transport and use terminal tools, implement updateState.

#Re-running tools safely with idempotent

action() also accepts idempotent. 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.

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.

#Stopping and continuation limits

Stop cancels cleanly. 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 will not re-execute.

Handlers receive an AbortSignal โ€” 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=${query}`, { signal });
    return res.json();
  },
);

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

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

When the cap trips, tools that already produced a real result keep it; tools that never ran are recorded with a limit error so the thread stays valid. The run does not continue.

#Typed agent state

Tool handlers and components often read agent state. Pair the registry with a typed AgentRef so agent.state() / agent.value() carry your state shape instead of Record<string, unknown> โ€” see Typed state via AgentRef:

import { createAgentRef } from '@threadplane/chat';
import { injectAgent } from '@threadplane/langgraph';
 
interface ClientToolsState { messages: unknown[]; client_tools: unknown[]; }
export const CLIENT_TOOLS = createAgentRef<ClientToolsState>('client-tools');
 
// component
protected readonly agent = injectAgent(CLIENT_TOOLS); // LangGraphAgent<ClientToolsState>

#API reference

import {
  tools, action, view, ask,
  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 โ†’ result)
view(description, schema, component, options?)Declare a render-only component tool (auto-acknowledged)
ask(description, schema, component, options?)Declare an interactive component tool (emitted value โ†’ result)
tools(map)Freeze a name-keyed registry for [clientTools]
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 10 groups per turn)
[clientToolExecutionGuard]Durable claim-before-execute for non-idempotent tools
Writing your own adapter?

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

#What's next