Client Tools
Let the model call something that only the browser can do.
A client tool runs in the app shell: pick a file, read the current form, call a native API.
The server still records the call on the thread. The browser performs it.
Use a client tool when the data does not exist on the server yet. Use a server tool when it does.
Server: declare the tool, no execute
import { defineTool } from "@pai/core";
import { z } from "zod";
export const getSelection = defineTool({
id: "getSelection",
description: "Read the text the user currently has selected",
inputSchema: z.object({}),
outputSchema: z.object({ text: z.string() }),
});Without execute, PAI treats the call as work the client must fulfill.
Register it on the agent like any other tool:
defineAgent({
tools: { getSelection },
});Client: implement it
Pass execute on the React binding. The server already declared the tool; the browser fills it in while that tree is mounted.
Add clientTools to the React chat binding — do not replace web/src/pai.ts:
export const Assistant = Pai.agent("main", {
clientTools: {
getSelection: {
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
}),
},
},
});execute receives { input, action, signal, data }. Empty-input tools can ignore it.
The model only sees client tools that are mounted. Unmount the page, and the next turn cannot call them.
You can also register a tool that exists only in the browser (schemas plus execute) on the same clientTools map, or next to a particular UI:
Assistant.useClientTool("getSelection", {
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
}),
});Treat client tool outputs like other client-supplied data: useful, not trusted
for access decisions. Authorization still belongs in resolveIdentity and
server tools.
Render progress
Client tools take render on the same clientTools / useClientTool options — not toolRenderers (that is for server cards).
getSelection: {
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
}),
render: ({ part, execution, action }) => {
if (part.state === "output-available") {
return <p>{part.output.text}</p>;
}
if (execution.status === "failed") {
return <p>{execution.error?.message}</p>;
}
if (action) {
return (
<button type="button" onClick={() => void action.submit({ text: "" })}>
Use empty selection
</button>
);
}
return <p>Reading…</p>;
},
},Next
- Human approval for waits that a person answers
- Commands for UI actions that should not start a model turn