PAIPAI

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

server/tools/get-selection.ts
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

On this page