PAIPAI

Define Client Tools

Define capabilities that run in the browser or app shell.

Client tools are capabilities that run in a browser, desktop shell, CLI, or other connected client. The backend never receives the implementation function. It receives either a server-declared tool call that has no backend execute, or a frontend-defined tool snapshot that describes a client-owned capability for the current run.

There are two supported declaration modes:

  • server-declared client-routed tools are stable tools in the agent contract, declared with defineTool() without execute;
  • frontend-defined client tools are declared in React with agent-binding clientTools config or the object form of useClientTool().

Both modes use the same runtime path: the model calls the tool, the runtime creates waiting client work, and configured or mounted client code can answer it with execute or render.

Server-Declared Client-Routed Tools

Use this for stable app capabilities that every compatible client should know about.

import { defineTool } from "@pai/core";
import { z } from "zod";

export const getSelectedText = defineTool({
  id: "getSelectedText",
  description: "Read the user's current browser selection",
  inputSchema: z.object({}),
  outputSchema: z.object({
    text: z.string(),
  }),
});

Add it to the agent:

export const agent = defineAgent({
  tools: {
    getSelectedText,
  },
});

Register it in React:

export const AssistantAI = Pai.agent("main", {
  clientTools: {
    getSelectedText: {
      execute: async () => ({
        text: window.getSelection()?.toString() ?? "",
      }),
    },
  },
});

<Pai.Provider client={paiClient}>
  <AssistantChat />
</Pai.Provider>

Because this tool has no backend execute, the runtime persists a waiting tool action when the model calls it. A configured clientTools entry or mounted useClientTool() registration can submit output automatically. If no matching client handler is mounted, the tool call remains pending work that can be submitted manually.

Client-executed definitions do not declare named suspend points. They expose only the implicit output action: its pending input is the tool input, and its submission payload is validated as the tool output.

Manual And External Output

A backend-declared tool without execute does not have to be answered by the initiating client. It can represent durable work that an operator, job, browser worker, or app integration completes later.

import { defineTool } from "@pai/core";
import { z } from "zod";

export const runBrowserCheck = defineTool({
  id: "runBrowserCheck",
  description: "Ask an external browser worker to inspect the current page",
  inputSchema: z.object({
    url: z.string().url(),
  }),
  outputSchema: z.object({
    status: z.enum(["passed", "failed"]),
    notes: z.string().optional(),
  }),
});

When the model calls the tool, the runtime exposes waiting work as a pending action:

const action = thread.getState().pending.find(
  (item) => item.name === "runBrowserCheck.output",
);

await action?.submit({
  status: "passed",
  notes: "The page rendered without console errors.",
});

The client API stays the same across suspend/resume, backend-declared tools without execute, client tools, and manual operator work: answer the pending action with submit, cancel, or fail.

Frontend-Defined Client Tools

Use this when the client owns the entire capability and the backend did not know about the tool at build time. The client provides the name, description, schemas, and implementation as agent-binding config or one hook call.

import { z } from "zod";

export const AssistantAI = Pai.agent("main", {
  clientTools: {
    "client.getSelectedText": {
      description: "Read the text currently selected in this client",
      inputSchema: z.object({}),
      outputSchema: z.object({
        text: z.string(),
      }),
      execute: async () => ({
        text: window.getSelection()?.toString() ?? "",
      }),
    },
  },
});

<Pai.Provider client={paiClient}>
  <AssistantChat />
</Pai.Provider>

Registration keeps the serializable tool definition in the local React provider. Requests made by this session include the latest definition snapshot. It does not upload executable code or UI code.

Frontend-defined client tools are typed in the client component that defines them. Other clients only know about them if they register the same tool definition.

Frontend-defined client tools do not need a separate server opt-in. A client that can access a thread may advertise them for that thread or session.

The runtime still performs structural validation:

  • frontend-defined tool names cannot conflict with backend tools;
  • reserved internal names are rejected;
  • descriptions and schemas must be serializable;
  • client outputs are validated against the declared output schema.

If submitted output is invalid, action.submit() is rejected and the tool remains waiting so the client can fix and submit again. Invalid client output is not written as a model-visible tool error unless the client explicitly calls action.fail().

Definitions and outputs are treated as untrusted client-supplied model context. They may influence the run, but they do not grant backend permissions.

Execution, Rendering, And Safety

An execute registration is local client state, not a permanent server setting. It means: this mounted React provider can automatically fulfill matching waiting tool actions.

Executable registrations are scoped and expire:

  • a hook registration mounted under a ThreadProvider belongs to that thread;
  • a hook registration mounted directly under a Pai.Provider applies to descendant threads;
  • unmounting removes it from future runs;
  • a reconnect remounts and registers local tools again;
  • automatic execution happens in the mounted client that observes the pending action.

If an executable registration is unavailable, the runtime exposes a pending action so another client or an operator can answer it.

If two tabs register the same tool, the runtime does not choose an arbitrary tab. Each tab only executes actions it observes through its own mounted provider or hook; if no client answers, the tool call waits as a pending action.

Client tools are captured from each send or action response request. If the user navigates and the mounted client tools change during an active run, that change affects the next request. To notify the agent, send another message or have trusted app code call thread.trigger(...); if the thread is busy, the runtime queues that work and starts it with a fresh client-tool snapshot.

Typed React bindings accept tool-name literals such as "getSelectedText" for server-declared client-routed tools. Frontend-defined config maps infer input and output payloads from inline Zod schemas.

Client Tool UI

Client tools can provide a renderer instead of, or in addition to, execute. Use this when the tool owns its continuation UI.

AssistantAI.useClientTool("confirmAction", {
  render: ({ input, state, action }) => {
    if (state !== "waiting") return null;

    return (
      <ConfirmPanel
        title={input.title}
        onConfirm={() => action.submit({ confirmed: true })}
        onCancel={() => action.submit({ confirmed: false })}
      />
    );
  },
});

The pending action remains durable runtime state. The renderer is local UI for displaying or explicitly answering it. The renderer function never leaves the client.

For server-declared tools, a render-only registration is purely local because the backend agent already declared the tool. For frontend-defined client tools, the serializable definition is still included in request snapshots so the model can call the tool, but no automatic execution runs unless you provide execute.

When a tool has both execute and render, the hook runs automatic execution for waiting actions and also registers the renderer. If automatic execution cannot answer, the same waiting action remains available for the renderer to complete manually.

On this page