Client Tools
An AG-UI agent can call tools that live in the browser. The Angular app declares the tool, the backend binds the declaration onto the model without implementing it, and the run ends when the model calls it. The browser executes the tool and submits the result back as a tool message, which starts the next turn. This guide walks the seven files of the running example, from the graph to the components the model fills.
What the demo does
The Run tab shows the prebuilt <chat> composition wired to five browser-executed tools. Ask "What is the weather in Denver?" 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 and the model calls weather_card instead, filling an Angular component's inputs with the readings it invents; the card renders inline in the transcript. Ask it to book something 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 terminal snapshot that ends the turn without a spoken summary, and a slow status check you can interrupt with the stop button.
How it is built
Seven files carry the feature: a graph that binds the browser's tool declarations and ends its turn, the server that exposes it over AG-UI, an application config, 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 tools channel alongside messages, because that is where the client tool catalog arrives, 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.
"""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()bind_client_tools(llm, [], state) converts each catalog entry into an OpenAI function-tool dict and calls llm.bind_tools with it; the empty list is where your own server tools would go.
The browser, not the graph, produces the result. Ending the run is the signal the adapter waits for: it treats a tool call with no result 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.
Serving the graph over AG-UI
The server is a FastAPI application. LangGraphAgent wraps the compiled graph and add_langgraph_fastapi_endpoint mounts it at /agent as an AG-UI event stream, and /ok is a plain health check.
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}Providing the agent
provideAgent() from @threadplane/ag-ui registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its URL at runtime because the host that serves the demo decides which runtime is attached; your own application passes a url directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'ag-ui') {
throw new Error('incompatible runtime');
}
return {
url: connection.url,
};
}),
],
};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. The weather_card and confirm_booking entries take their schemas from schemas.ts, the file the registry and the two components it points at all import from.
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() });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,
),
});The view and ask overloads are typed against the paired component: every field the schema produces must be a declared input() on that component, so a schema change is a compile error rather than a silent runtime gap.
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.
@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 {
protected readonly agent = injectAgent();
protected readonly clientTools = clientTools;
}The catalog is attached to every run the adapter starts, as the tools field of the AG-UI run input, so the model sees the browser's tools on the first turn and on every continuation.
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.
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 { max-width: 320px; padding: 16px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-card); background: var(--tplane-chat-surface-alt); color: var(--tplane-chat-text); }
.wc__head { display: flex; align-items: center; justify-content: space-between; }
.wc__loc { color: var(--tplane-chat-text); font-weight: 600; }
.wc__badge { padding: 2px 8px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-button); background: color-mix(in srgb, var(--tplane-chat-primary) 12%, var(--tplane-chat-surface-alt)); color: var(--tplane-chat-primary); font-size: var(--tplane-chat-font-size-xs); }
.wc__temp { margin-top: 8px; color: var(--tplane-chat-text); font-size: 32px; font-weight: 700; }
.wc__cond { color: var(--tplane-chat-text-muted); }
.wc__meta { display: flex; gap: 24px; margin: 12px 0 0; }
.wc__meta dt { color: var(--tplane-chat-text-muted); font-size: var(--tplane-chat-font-size-xs); text-transform: uppercase; }
.wc__meta dd { margin: 0; color: var(--tplane-chat-text); 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.
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 { max-width: 360px; padding: 16px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-card); background: var(--tplane-chat-surface-alt); color: var(--tplane-chat-text); }
.cb__summary { margin: 0 0 12px; color: var(--tplane-chat-text); }
.cb--resolved .cb__summary { margin: 0; color: var(--tplane-chat-text-muted); }
.cb__actions { display: flex; gap: 8px; }
.cb__btn { padding: 6px 14px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-button); background: var(--tplane-chat-surface-alt); color: var(--tplane-chat-text); cursor: pointer; }
.cb__btn:focus-visible { outline: 2px solid var(--tplane-chat-primary); outline-offset: 2px; }
.cb__btn--primary { border-color: var(--tplane-chat-primary); background: var(--tplane-chat-primary); color: var(--tplane-chat-on-primary); }
`],
})
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 });
}
}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.
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
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.
Because the tool has no server implementation, the run finishes with a tool call that carries no result.
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.
A function handler runs, a view is acknowledged on mount, and an ask waits for the user's value.
The adapter records the result on the local tool call, adds a tool message addressed to the call id, and starts the next run, which the model answers.
Settle, flush, and resolve
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 freezes, and a tool message is added to the agent's outgoing messages. resolve(id, result) settles and then asks the adapter to continue the run, which is the normal path. flush() is where an adapter makes settled results durable without continuing; in @threadplane/ag-ui it is a deliberate no-op, because settle already placed the tool message in the message list that 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.
Consecutive client-tool continuations are capped per user turn, at ten by default. Set [clientToolContinuationPolicy] on <chat> to change the maximum or to observe the limit, and use maxTurns: 0 for no cap. Calls blocked by the limit are settled with an explanatory error so the model never sees an unanswered tool call.
Requirements on the backend
Three things must hold. The first fails silently, which makes it the one to check first.
The catalog is merged into graph state by ag-ui-langgraph. If State does not declare a tools channel the merged value is dropped, bind_client_tools binds nothing, and the model never learns the browser has tools.
The graph in this example compiles with MemorySaver() because ag-ui-langgraph reads the thread's graph state through a checkpointer; a graph served this way needs one, and outside development it should be durable. The tool result itself does not depend on it: the browser adds the tool message to its own message list, and the continuation run carries the whole list back.
Finally, keep the tool names in the registry and the names in the system prompt aligned. 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.
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 does that split for you.
What's Next
Render a backend tool call's result through a component the frontend owns.
Pause a run mid-flight and resume it with a human decision.
The runtime-neutral contract behind action, view, and ask.
How AG-UI protocol events become the Agent contract the chat UI reads.