Client Tool
Let an agent call browser-side code.
Client tools have two ownership modes:
- frontend-defined client tools: the client owns the tool contract and handler;
- server-declared client-routed tools: the server owns the tool contract, and the client only registers a local handler.
Prefer frontend-defined client tools for browser/app-shell capabilities that are specific to the current client. Use a server-declared tool without execute when the capability is part of the agent's stable product contract.
Frontend-Defined Client Tool
Define stable browser capabilities as clientTools config on the agent binding.
Frontend-defined tools include a serializable schema snapshot with run/action
requests and keep execute or render functions local to React.
// web/src/App.tsx
import { z } from "zod";
import { Pai } from "./pai-react";
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() ?? "",
}),
},
},
});
export function App({ pai }) {
return (
<Pai.Provider client={pai}>
<AssistantChat />
</Pai.Provider>
);
}When this client sends or resumes work, it includes the serializable tool definition in the request snapshot and keeps the implementation function local. The runtime validates the definition shape, reserved names, collisions with backend tools, and submitted output.
execute means this mounted client can automatically answer matching waiting tool actions. If the same frontend-defined tool only provides a renderer, the definition is still included in request snapshots, but calls wait for action.submit() instead of auto-executing.
For custom flows that need component-scoped or route-scoped handlers, keep using useClientTool() directly. Hook registrations mounted under a ThreadProvider are scoped to that thread; hook registrations mounted directly under a Pai.Provider apply to descendant threads. Matching hook registrations override static clientTools entries with the same name while mounted.
Server-Declared Client Handler
Use this form when the server should own the stable name, schema, and description, but the active client must provide the implementation.
// server/tools.ts
import { defineTool } from "@pai/core";
import { z } from "zod";
export const getSelectedText = defineTool({
id: "getSelectedText",
description: "Read the selected browser text",
inputSchema: z.object({}),
outputSchema: z.object({
text: z.string(),
}),
});// server/agent.ts
import { defineAgent } from "@pai/core";
import { getSelectedText } from "./tools";
export const agent = defineAgent({
name: "browser-assistant",
instructions: "Use selected browser text when it helps answer the user.",
tools: {
getSelectedText,
},
});// web/src/browser-capabilities.tsx
import { AssistantAI } from "./pai-react";
function BrowserCapabilities() {
AssistantAI.useClientTool("getSelectedText", {
execute: async () => ({
text: window.getSelection()?.toString() ?? "",
},
});
return null;
}This registration does not send the tool definition back to the server. It only records locally that this client can currently handle the already-known getSelectedText tool. While the component is mounted, matching waiting actions can be answered by the hook. When the component unmounts or disconnects, the handler becomes unavailable for future pending actions.
If you register only a renderer for a server-declared tool, nothing needs to be sent to the runtime; the renderer simply attaches to matching waiting tool parts in local UI.