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:
| Helper | Kind | What it does |
|---|---|---|
action() | function | Runs an async handler in the browser; its resolved return value becomes the tool result sent back to the model. |
view() | render-only component | The model fills the component's props from the schema; the card renders inline and the call is auto-acknowledged once it mounts. |
ask() | interactive component | The 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.
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.
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.
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.
Reading client-tool results on the server
When a client tool resolves, its result travels back to your graph as a tool message keyed by tool-call id, with no tool name on it. Both adapters send the same minimal shape:
{ "id": "client-tool-result-call_abc", "role": "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
]For AG-UI this is fixed by the protocol: ToolMessageSchema in @ag-ui/core
defines exactly id, role, content, toolCallId, and optional error /
encryptedValue, and parses in strip mode — 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() / 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';| Export | Purpose |
|---|---|
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 / ClientToolRegistry | The 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 / ClientToolExecutionGuard | Durable claim store for [clientToolExecutionGuard] |
Component inputs on <chat>:
| Input | Purpose |
|---|---|
[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 |
The settle / flush / resolve contract behind these features is documented in Writing an Adapter › Client Tools.