PAIPAI

Tool UI

Default tool cards, custom renderers, and pending approvals.

Tool calls are message parts. Render them with Assistant.Tool unless you need a custom card.

import { isPaiToolPart } from "@pai/react";

if (isPaiToolPart(part)) {
  return (
    <Assistant.Tool
      key={part.toolCallId}
      message={message}
      part={part}
    />
  );
}

The default renderer is enough for "the model searched docs." Write a card when the output is a chart, a citation list, or an approval.

Custom cards

Register on the binding so every Assistant.Tool picks them up:

export const Assistant = Pai.agent("main", {
  toolRenderers: {
    searchDocs: (tool) => <SearchDocsCard tool={tool} />,
  },
});

Or pass toolRenderers on ThreadProvider for one view.

The card receives ToolRenderProps:

FieldMeaning
partNative tool part (state, input, output, errorText, data, cancelled)
runStatusLiveness of the run that produced the call. Distinguishes execute-in-flight from a stranded input-available part.
actionSet only while the tool is waiting on a person or a client tool

Read tool progress from ToolData: tool.part.data?.progress?.at(-1)?.value.

function SearchDocsCard({
  tool,
}: {
  tool: ToolRenderProps<AssistantPai["agents"]["main"], "searchDocs">;
}) {
  if (tool.part.state === "input-streaming") {
    return <p>Searching…</p>;
  }
  if (tool.part.state === "output-error") {
    return <p>{tool.part.errorText}</p>;
  }
  if (tool.part.state !== "output-available") {
    return <p>Search: {tool.part.input?.query}</p>;
  }
  return (
    <ul>
      {tool.part.output.hits.map((hit) => (
        <li key={hit.url}>{hit.title}</li>
      ))}
    </ul>
  );
}

Use import type { AssistantPai } from the server contract file (or codegen). Do not import the agent runtime into the browser.

Approvals

When the tool suspends, tool.action is the pending action. Submit from the card:

const action = tool.action;
if (action) {
  return (
    <div>
      <p>{action.input.summary}</p>
      <button
        type="button"
        onClick={() => void action.submit({ approved: true })}
      >
        Publish
      </button>
    </div>
  );
}

Assistant.useAction(action) adds isPending / isSubmitting if the card needs local submit state. Keep that hook in a child that only mounts when action exists.

For a tray outside the transcript, scan with chat.getPendingAction(message, part).

See Human approval for the server tool.

Next

On this page