Register Client Tools In React
Register browser-side capabilities from React.
Client tools let an agent request work that must happen in the browser or app shell.
There are three React registration forms:
- pass
clientToolsconfig for stable agent-binding capabilities; - call
AI.useClientTool()for handlers scoped to one mounted component; - call the package-root
useClientTool()when the surface should work for whichever agent is mounted around it.
Register stable tools on the agent binding:
export const AssistantAI = Pai.agent("main", {
clientTools: {
getSelectedText: {
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
}),
},
},
});
<Pai.Provider client={paiClient}>
<AssistantChat />
</Pai.Provider>Client tools are local React state. They do not create a persistent backend registration and do not return a backend status. While the provider is mounted, send and action response requests include the latest frontend-defined tool snapshots. Hook registrations mounted under a ThreadProvider are scoped to that thread; hook registrations mounted directly under a Pai.Provider apply to descendant threads. If a tool is unavailable when the agent needs it, the thread exposes a pending action instead of silently failing.
Frontend-Defined Client Tools
React can also register a tool that is defined entirely in the client. The config receives the public tool definition and the local handler together.
import { z } from "zod";
export const AssistantAI = Pai.agent("main", {
clientTools: {
"client.getSelectedText": {
description: "Read selected text from this browser tab",
inputSchema: z.object({}),
outputSchema: z.object({
text: z.string(),
}),
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
}),
},
},
});
<Pai.Provider client={paiClient}>
<AssistantChat />
</Pai.Provider>The client includes the serialized tool definition in requests made while the component is mounted. The runtime validates the name, schemas, and output, then treats the definition and output as untrusted client-supplied model context. The execute and render functions stay in the browser.
The executor's signal aborts when its registration unmounts or when its
pending output action is submitted, failed, or cancelled. Browser work should
honour that signal so it cannot outlive the action that authorized it.
Use server-declared client tools when production policy should decide that a tool exists. Use frontend-defined client tools when the client owns the capability and the server only needs the serialized tool description for the current session.
Agent-Agnostic Registration
A frontend-defined tool's schemas, execute, and render are all client-side, so its definition names no agent. Import useClientTool from the package root to register it against the nearest mounted ThreadProvider, whichever binding created it. A surface that more than one agent can drive then mounts the capability once.
import { useClientTool } from "@pai/react";
import { z } from "zod";
function ClipboardCapability() {
useClientTool({
name: "readClipboard",
description: "Read clipboard text from this browser client",
inputSchema: z.object({}),
outputSchema: z.object({ text: z.string() }),
execute: async () => ({ text: await navigator.clipboard.readText() }),
});
return null;
}
<AssistantAI.ThreadProvider thread={thread}>
<ClipboardCapability />
</AssistantAI.ThreadProvider>The registration is scoped to that provider's thread and lasts as long as the component is mounted, so an enclosing binding never advertises a tool it cannot route. This form requires a ThreadProvider inside Pai.Provider, and it takes client-defined tools only — a server-declared client-routed tool is named by one agent's contract, so keep those handlers on AI.useClientTool().
Defining a Tool Away From Its Registration
Inside a clientTools map, or an inline useClientTool call, the object literal is
contextually typed, so execute and render infer their payloads for free. Lift the
definition into its own module and that contextual type is gone. clientTool restores
it.
Include name and the result can be registered directly, which is what a definition shared by
several registration sites for one agent binding wants:
// read-page.ts
export const readPage = AssistantAI.clientTool({
name: "readPage",
description: "Read the current page",
inputSchema: z.object({ selector: z.string() }),
outputSchema: z.object({ text: z.string() }),
execute: ({ input }) => ({
text: document.querySelector(input.selector)?.textContent ?? "",
}),
});
// any surface, inside the thread provider
AssistantAI.useClientTool(readPage);
// or
<AssistantAI.ClientTool {...readPage} />;Leave name out and the result is a clientTools map entry, where the key names the
tool:
clientTools: {
readPage: AssistantAI.clientTool({ description, inputSchema: input, outputSchema: output, execute }),
}Both forms are plain objects, so passing the literal directly still works. clientTool
adds inference, not behaviour.
Tool Renderers
Use renderers for tool-call UI.
AssistantAI.useToolRenderer(
"createTask",
({ part, runStatus }) => {
if (runStatus === "running" && part.state === "input-available") {
return <span>Creating {part.input.title}</span>;
}
if (part.state === "output-available") {
return <span>Created {part.output.taskId}</span>;
}
if (part.state === "output-error") {
return <span>{part.errorText}</span>;
}
return null;
},
);part.state, part.input, part.output, and part.errorText use native AI SDK
tool-part semantics. runStatus reports the owning run's liveness — the one fact
the part cannot carry — and part.cancelled separates a call the runtime
abandoned from one the tool itself failed.
Client and server tool renderers follow the same input lifecycle. The agent generates and validates tool calls on the server; client tools execute in the browser once their pending output action is available.
While part.state is input-streaming, a client-defined renderer receives
unvalidated partial input. Required fields and nested array items may still be
missing. Narrow the state before treating input as complete:
render: ({ part }) => {
if (part.state === "input-streaming") {
return <span>Preparing {part.input?.title ?? "task"}…</span>;
}
if (part.state === "input-available") {
return <TaskCard title={part.input.title} />;
}
if (part.state === "output-error") {
return <span>{part.errorText}</span>;
}
return null;
};The browser input schema runs from input-available onward, so its defaults
and normalizations appear only after streaming. Streaming input uses the
schema's input type; complete input uses its parsed output type. Validation
failures during rendering are contained by the tool's error fallback.
A native output-error may represent invalid input or cancellation before
input completed. That state preserves part.errorText and part.cancelled;
its renderer receives parsed input if it validates, or undefined otherwise.
Render-Only Client Tools
Some client tools are mostly UI. They may not have an automatic execute; they render a control and submit a pending action when the user is done.
AssistantAI.useClientTool("confirmAction", {
render: ({ part, runStatus, action }) => {
if (
action === null ||
part.state !== "input-available" ||
!action
) {
return null;
}
return (
<ConfirmPanel
title={part.input.title ?? "Confirm action"}
onApprove={() => action.submit({ approved: true })}
onReject={() => action.submit({ approved: false })}
/>
);
},
});This supports tools that own the UI for continuing the run. The backend still owns durable pending action state.
The renderer component stays local. For a server-declared tool, render-only registration does not add anything to request snapshots because the agent already declared the tool. For a frontend-defined client tool, React still includes the serializable tool definition so the model can call it, but it does not execute automatically unless you provide execute.
If you provide both execute and render, the hook runs execute for waiting actions and also registers the renderer for the same tool. The renderer can show progress or fallback UI and may ignore action when it only needs to display status.