PAIPAI

@pai/react

Typed React providers, hooks, controllers, and render helpers.

Package: @pai/react

createPaiReact

function createPaiReact<TPai extends PaiContractShape>(
  contract?: PaiContractRuntimeFor<TPai>,
): PaiReactRoot<TPai>;

Create one React root for the app-level PAI contract, then derive an agent binding for each agent you render.

import { createPaiReact } from "@pai/react";
import type { AssistantPai } from "../server/pai";

export const Pai = createPaiReact<AssistantPai>();
export const AssistantAI = Pai.agent("main");

The optional contract argument is the runtime metadata object that pai generate client emits alongside the contract type. Generated clients always pass it, so prefer the generated root over a hand-written one. TPai is erased at compile time, so this object is the only thing that can resolve a registered tool name to its stable definition id at runtime — which is how createClientHarness stamps mock tool parts with the same id a real run would.

PaiReactRoot

type PaiContractShape = {
  agents: Record<string, AgentContract>;
};

type PaiReactRoot<TPai extends PaiContractShape> = {
  readonly contract?: PaiContractRuntimeFor<TPai>;

  Provider(props: PaiProviderProps<TPai>): React.ReactElement | null;

  agent<TId extends keyof TPai["agents"] & string>(
    id: TId,
    options?: PaiAgentBindingOptions<TPai["agents"][TId]>,
  ): PaiAgentBinding<TPai, TId>;
};

Pai.contract is whatever was passed to createPaiReact, and is absent on a root created without one.

Pai.Provider owns the app client. Each Pai.agent(id) binding owns the thread-scoped hooks, tool renderers, client tools, and components for that one agent.

export function AssistantPage({ threadId }: { threadId: string }) {
  return (
    <Pai.Provider client={paiClient}>
      <AssistantAI.ThreadProvider threadId={threadId}>
        <AssistantChat />
      </AssistantAI.ThreadProvider>
    </Pai.Provider>
  );
}

Runtime Contract

type AgentToolMetadataMap = Record<
  string,
  { readonly name: string; readonly id?: string }
>;

type AgentContractRuntime = {
  readonly name: string;
  readonly tools: AgentToolMetadataMap;
};

type PaiContractRuntime = {
  readonly agents: Record<string, AgentContractRuntime>;
};

type PaiContractRuntimeFor<TPai extends PaiContractShape> = {
  readonly agents: {
    readonly [TId in keyof TPai["agents"] & string]: AgentContractRuntime;
  };
};

The generated contract.ts object, which is the runtime half of a generated client: everything a contract type describes about payload schemas is erased, and these names and ids are what survive. tools is keyed by the name each agent registers a tool under; id is the tool definition's stable id, absent for provider-executed tools that have no app definition.

PaiContractRuntimeFor<TPai> requires an entry for every agent TPai declares, so a stale hand-written contract fails to compile instead of resolving no ids for the agent it forgot. Write PaiContractRuntime when you accept any app's contract.

Agent Options

type PaiAgentBindingOptions<TContract extends AgentContract> = {
  toolRenderers?: ToolRendererMap<TContract>;
  clientTools?: ClientToolMap<TContract>;
  toolErrorFallback?: React.ComponentType<ToolErrorFallbackProps> | null;
};

Use options when a renderer or client capability is stable for the agent binding:

export const AssistantAI = Pai.agent("main", {
  toolRenderers: {
    lookupOrder: (tool) => <OrderCard tool={tool} />,
  },
});

Use hook registrations when the renderer or client tool depends on mounted component state:

function RouteLocalTools() {
  AssistantAI.useClientTool("getSelectedText", {
    execute: async () => ({
      text: window.getSelection()?.toString() ?? "",
    }),
  });
  return null;
}

Agent Binding

type ProtocolPendingActionView<TContract extends AgentContract> =
  import("@pai/protocol").PendingActionView<TContract>;

type PaiAgentBinding<
  TPai extends PaiContractShape,
  TId extends keyof TPai["agents"] & string,
> = {
  ThreadProvider(
    props: ThreadProviderProps<TPai["agents"][TId]>,
  ): React.ReactElement | null;

  useClient(): AgentClient<TPai["agents"][TId]>;
  useThread(): ThreadController<TPai["agents"][TId]>;
  useThreadState(): ThreadState<TPai["agents"][TId]>;
  useThreadSelector<TSelected>(
    selector: (state: ThreadState<TPai["agents"][TId]>) => TSelected,
  ): TSelected;
  useThreads(options?: UseThreadsOptions): ThreadsController<TPai["agents"][TId]>;
  useChat(options?: {
    onData?(part: DataUIPart<TPai["agents"][TId]["uiData"]>): void;
  }): ChatController<TPai["agents"][TId]>;
  useComposer(): ComposerController<TPai["agents"][TId]>;
  useComposerActions(): ComposerActions<TPai["agents"][TId]>;
  useQueue(): QueueController<TPai["agents"][TId]>;
  useAction<
    TAction extends ProtocolPendingActionView<TPai["agents"][TId]>,
  >(
    action: TAction,
  ): PendingActionController<TPai["agents"][TId], TAction>;

  Tool(props: ToolProps<TPai["agents"][TId]>): React.ReactElement | null;
  useToolRenderer<
    TName extends ToolRendererName<TPai["agents"][TId]>,
  >(
    name: TName,
    renderer: ToolRenderFn<TPai["agents"][TId], TName>,
  ): void;
  useToolEffect<
    TName extends ToolEffectName<TPai["agents"][TId]>,
  >(
    name: TName,
    handlers: ToolEffectHandlers<TPai["agents"][TId], TName>,
    options?: ToolEffectOptions,
  ): void;
  useRenderedTool(
    message: PaiMessage<TPai["agents"][TId]>,
    part:
      | PaiStaticToolPart<TPai["agents"][TId]>
      | PaiDynamicToolPart,
  ): React.ReactNode | null;

  useClientTool(...args: unknown[]): ClientToolRegistrationState;
  clientTool(definition: ClientDefinedToolOptions): ClientDefinedToolOptions;
  clientTool(config: ClientDefinedToolConfig): ClientDefinedToolConfig;
  ClientTool(props: ClientToolProps): null;

  useSubagent<
    TName extends ToolRendererName<TPai["agents"][TId]>,
  >(
    tool: ToolRenderProps<TPai["agents"][TId], TName>,
  ): SubagentRef | null;
  useClientData(
    clientData: ClientDataContribution<TPai["agents"][TId]> | undefined,
  ): void;
  useFileUrl(fileId: string | null | undefined, options?: UseFileUrlOptions): FileUrlController;
};

Native-first thread state

type ThreadState<TContract extends AgentContract = AgentContract> = {
  thread: PaiThreadHead<TContract>;
  messages: PaiMessage<TContract>[];
  runs: RunState<TContract>[];
  activeRunId: string | null;
  queue: ThreadQueueView<TContract>;
  usage: UsageSummary;
  capabilities: ThreadCapabilityView;
  error: ThreadErrorRecord | null;
};

type ChatController<TContract extends AgentContract = AgentContract> =
  ThreadController<TContract> & {
    messages: PaiMessage<TContract>[];
    isRunning: boolean;
    activity: ChatActivity;
    queue: QueueController<TContract>;
  };

The inherited send input and regenerate replacement use ClientSendInput<TContract>, so an agent binding accepts sparse declared message metadata and rejects invalid or unknown fields before transport.

chat.messages and chat.state.messages expose the same transcript collection. Each value has the AI SDK UIMessage shape—id, role, metadata, and parts—with contract-typed native parts. Standard text, file, source, application data-*, step-start, static tool-${name}, and dynamic-tool parts retain the SDK field names and state unions. PAI enriches tool parts with ToolData and suspension views, reasoning parts with an optional summary, and adds the provider-neutral attachment part.

Parts do not share one universal identifier. Use the identity native to the part kind—for example, a tool's toolCallId or a data part's id—and fall back to the message id plus part position for purely presentational content.

Optimistic sends use the same PaiMessage shape. Their message.metadata.pai.status is "pending"; after admission the canonical message has a run id, relation, and committed status.

Run lifecycle stays normalized in state.runs. Group the same message objects by message.metadata.pai.runId when a UI needs a run-aware view:

const groups = chat.state.runs.map((run) => ({
  run,
  messages: chat.messages.filter(
    (message) =>
      !isOptimisticInputMessage(message) &&
      message.metadata.pai.runId === run.runId,
  ),
}));

Standalone administrative messages and local optimistic sends have no run id. Use isOptimisticInputMessage(message) before reading canonical-only metadata; the guard also preserves the binding's concrete contract while narrowing. Within a run, message.metadata.pai.relation distinguishes "input", "context", and "output". Run metadata remains server-written and updates through ordinary thread observation.

Tool parts and renderer props

type ToolProps<TContract extends AgentContract = AgentContract> = {
  message: PaiMessage<TContract>;
  part: PaiStaticToolPart<TContract> | PaiDynamicToolPart;
  fallback?: React.ReactNode | ToolRenderFn<TContract>;
};

type ToolRenderProps<
  TContract extends AgentContract = AgentContract,
  TName extends ToolRendererName<TContract> = "*",
> = {
  part: ToolRenderPart<TContract, TName>;
  runStatus: RunLifecycleStatus | null;
  action: PendingActionView<TContract> | null;
};

Render a tool by passing its owning message and native tool part:

{chat.messages.map((message) =>
  message.parts.map((part) =>
    isPaiToolPart(part) ? (
      <AssistantAI.Tool
        key={part.toolCallId}
        message={message}
        part={part}
      />
    ) : null,
  ),
)}

part is authoritative and state-discriminated by the SDK. Read part.input, part.output, part.errorText, part.toolCallId, and part.state after the appropriate native-state check.

PAI adds no presentation state of its own. A renderer already knows which tool it renders, so the part plus two sidecars is everything it needs:

  • part.cancelled separates a call the runtime abandoned — a stop, or a run that ended with the call still outstanding — from one the tool itself failed. Both are stored as output-error, because the native grammar has no other terminal shape for "no result", so this flag is the only thing that distinguishes them. It never means the tool's work did not happen: abort is cooperative, so a tool may have completed after PAI stopped waiting.
  • runStatus is the status of the run that produced the call, or null when the thread no longer carries it. Liveness is the one fact a renderer cannot read off the part — an input-available call looks identical whether it is executing now or was stranded by a run that ended. Use isStrandedToolCall(part, runStatus) for that check.
  • action is the optional command sidecar for a pending suspension or client tool output.

The action sidecar is not serialized onto the message. Outside a renderer, resolve it from the exact pair:

const action = chat.getPendingAction(message, part);

useAction(action) adds local submitting, cancelling, and failing state for one resolved action.

The binding is intentionally an ordinary agent surface. Rendering a subagent is the same as rendering any other agent: validate subagent.agentId against the app's agent registry (or resolve an explicit app mapping from subagent.agent), choose that binding, mount its ThreadProvider, and reuse the same transcript or tool renderer components.

Loading state

controller.loading is true until an authoritative snapshot for the thread has been applied. A provider starts every thread from defaultThreadState, so a thread whose transcript has not arrived and a thread that is genuinely empty have the same empty messages until then. A surface that renders an empty state reads loading so it shows one only once the transcript is known:

if (chat.loading && chat.messages.length === 0) return <Spinner />;
if (chat.messages.length === 0) return <EmptyThread />;

It settles on the first watched snapshot, on refresh(), and on a terminal watch failure — nothing is coming after that, so the surface shows watchError rather than waiting behind it. It is false while observe is false, because a paused provider has nothing in flight.

A thread id the application just minted is a special case the binding cannot see: it is known to have no transcript, but its first snapshot still costs a round trip. An application that wants a new thread to render instantly should pair loading with its own knowledge of which ids it created.

Error State

useChat(), useThread(), and useThreads() distinguish terminal background observation failure from ordinary operation errors:

  • controller.error is a local command or observation rejection caught by the React binding;
  • controller.watchError is a sticky terminal failure from the background thread or thread-head watch. Transient disconnects recover internally and do not set it;
  • controller.state.error is the latest safe, durable background run failure.

The durable record exposes code, message, optional retryable, and optional errorId fields suitable for rendering and support correlation. It never contains the original server exception.

See Error Handling and Client Errors.

useToolEffect replays matching events in the initial observed thread snapshot by default. Pass { replayExisting: false } for effects that should react only to events arriving after that initial snapshot, such as refreshing a live view after a new mutation.

The hook registers with its ThreadProvider without subscribing the calling component to thread snapshots, so streamed text does not re-render an effect-only consumer. Callbacks run from a provider-owned passive effect only after React commits the state covered by that host render. When React coalesces several snapshots, callback payloads retain snapshot order even though the UI may already show the latest committed snapshot. A callback failure is isolated from thread commands and other registrations, then surfaces through the registering component's React error boundary.

Changing a provider's observe mode starts a new observation session. Effects catch up from its current durable snapshot; undrained intermediate ToolData revisions from the previous session are not carried across the restart.

Binding-specific and nearest-provider APIs

Agent bindings are the typed default. AssistantAI.Tool, AssistantAI.useToolEffect(), and AssistantAI.useClientData() stay on AssistantAI's binding-specific renderer and thread contexts even beneath another binding's ThreadProvider. AssistantAI.useFileUrl() is also agent-bound, but it reads AssistantAI's client from Pai.Provider and does not require a ThreadProvider.

Package-root utilities support components intentionally shared by many agent bindings:

import {
  ClientTool,
  clientTool,
  Tool,
  type AnyToolProps,
  useClientData,
  useClientTool,
  useComposer,
  useComposerActions,
  useFileUrl,
  useSend,
  useSubagent,
  useToolEffect,
} from "@pai/react";
function Tool<TContract extends AgentContract>(
  props: ToolProps<TContract>,
): React.ReactElement | null;

function Tool(props: AnyToolProps): React.ReactElement | null;

function useSend(): (
  input: ClientSendInput<AgentContract>,
  options?: SendOptions<AgentContract>,
) => Promise<RunHandle<AgentContract>>;

function useComposer(): ComposerController<AgentContract>;

function useComposerActions(): ComposerActions<AgentContract>;

function useFileUrl(
  fileId: string | null | undefined,
  options?: UseFileUrlOptions,
): FileUrlController;

function useClientData(
  clientData: ClientDataContribution<AgentContract> | undefined,
): void;

function useToolEffect<TName extends ToolEffectName<AgentContract>>(
  toolName: TName,
  handlers: ToolEffectHandlers<AgentContract, TName>,
  options?: ToolEffectOptions,
): void;

function useClientTool(
  definition: ClientDefinedToolOptions<AgentContract>,
): ClientToolRegistrationState;

function clientTool(
  definition: ClientDefinedToolOptions<AgentContract>,
): ClientDefinedToolOptions<AgentContract>;

function clientTool(
  config: ClientDefinedToolConfig<AgentContract>,
): ClientDefinedToolConfig<AgentContract>;

function ClientTool(props: ClientToolProps<AgentContract>): null;

function useSubagent<
  TContract extends AgentContract,
  TName extends ToolRendererName<TContract>,
>(
  tool: ToolRenderProps<TContract, TName>,
): SubagentRefOf<TContract, TName> | null;

Tool and the thread-scoped hooks resolve the nearest mounted ThreadProvider, regardless of which binding created it. Use them when the component should follow its render location at runtime; use AI.* when it belongs to a particular agent and needs that agent's complete contract typing. useSubagent() is the provider-independent exception.

Pass a contract-bound PaiMessage<MyAgentContract> and its narrowed tool part when shared UI can preserve that agent's input, output, and pending-action types. Use AnyToolProps at an intentional cross-agent boundary; its fallback receives contract-erased renderer props.

Package-root APIPackage-root behaviorBound alternative
<Tool message={message} part={part} />Uses the nearest ThreadProvider, which must be inside Pai.Provider. Its generic contract is inferred from the typed message/part pair, or intentionally erased by AnyToolProps.<AI.Tool message={message} part={part} /> fixes the contract to the agent, requires Pai.Provider, and uses that binding's renderer scope.
useSend()Uses the nearest ThreadProvider; works with a direct thread handle outside Pai.Provider. It does not retain agent-specific contract typing.AI.useThread().send or AI.useChat().send requires that binding's ThreadProvider and retains the agent contract.
useComposer()Reads the nearest ThreadProvider's ephemeral draft, which is the same draft AI.useComposer() reads. Re-renders only the calling component, so the component rendering the input is where it belongs. It does not type submit options to one agent's contract.AI.useComposer() requires that binding's ThreadProvider and types its send options from the agent contract.
useComposerActions()The write half of the same draft: stable across draft changes and subscribed to no draft state, for a component that fills the composer without re-rendering as the user types.AI.useComposerActions() returns the same write-only surface with the agent contract retained.
useFileUrl(fileId, options)Uses the nearest ThreadProvider, which must be inside Pai.Provider because URL minting needs its agent client.AI.useFileUrl(fileId, options) uses that binding's agent client and only requires Pai.Provider.
useClientData(data)Contributes to the nearest ThreadProvider, including one mounted from a direct thread handle. The root form does not know the agent's clientData contract.AI.useClientData(data) requires that binding's ThreadProvider and types the contribution from its contract.
useToolEffect(name, handlers, options)Observes the nearest ThreadProvider, including one mounted from a direct thread handle. It does not narrow names or payloads to one agent's tools.AI.useToolEffect(name, handlers, options) requires that binding's ThreadProvider and retains its tool types.
useClientTool(definition)Registers a client-defined tool with the nearest ThreadProvider, which must be inside Pai.Provider. Advertised for that provider's thread only, and only while the component is mounted. Client-defined tools only — schemas and payloads are not narrowed to one agent's contract.AI.useClientTool(definition) requires that binding's ThreadProvider, retains its contract, and additionally accepts AI.useClientTool(name, options) to handle a server-declared client-routed tool.
useSubagent(tool)Purely derives a child-thread reference from typed renderer props; it reads no provider and infers the child roster.AI.useSubagent(tool) is also provider-independent and additionally narrows agentId to matching ids in the app registry.
function SharedTool({ message, part }: AnyToolProps) {
  const send = useSend();

  return (
    <Tool
      message={message}
      part={part}
      fallback={() => (
        <button onClick={() => send("Explain this tool call")}>
          Explain this tool call
        </button>
      )}
    />
  );
}

useSubagent(tool) does not fetch or watch the child thread. Mount the selected child binding's ThreadProvider with the returned threadId to observe and render its transcript.

// Inside a subagent tool renderer:
const subagent = useSubagent(tool);
if (!subagent) return null;

const childAgentId =
  subagent.agentId === "researcher" || subagent.agentId === "reviewer"
    ? subagent.agentId
    : null;
if (!childAgentId) return null;

return (
  <ChildThread
    agentId={childAgentId}
    threadId={subagent.threadId}
  />
);

Tool spec renderer catalogs

type ToolSpec = Omit<ToolContract, "hasExecute" | "id" | "name"> & {
  id: string;
  hasExecute: boolean;
};

type ToolSpecRenderFn<TSpec extends ToolSpec> = (
  props: ToolSpecRenderProps<TSpec>,
) => React.ReactNode;

type ToolSpecRendererMap<
  TSpecs extends {
    [TId in keyof TSpecs]: TId extends string
      ? ToolSpec & { readonly id: TId }
      : never;
  },
> = {
  readonly [TId in keyof TSpecs]?: ToolSpecRenderFn<TSpecs[TId]>;
};

function deriveToolRegistry<
  const TAgentTools extends Record<
    string,
    { readonly name: string; readonly id?: string }
  >,
  const TCatalog extends Record<string, unknown>,
>(
  agentTools: TAgentTools,
  catalog: TCatalog,
): DerivedToolRegistry<TAgentTools, TCatalog>;

ToolSpec describes one tool definition independently of any agent or registration name. Codegen emits a <Name>ToolSpecs map keyed by stable definition id. ToolSpecRenderProps carries the native tool part plus derived display, error, and action sidecars for one such spec; ToolSpecRenderFn is its render function type. Waiting props retain the spec's typed action input and resume payload. Because action results recursively carry the full originating agent contract, definition-scoped submit, cancel, and fail methods return Promise<unknown>.

ToolSpecRendererMap types one reusable, id-keyed renderer catalog and verifies that every map key matches that spec's literal id. deriveToolRegistry() joins an agent's generated tool metadata to that catalog by id and returns the renderer map keyed by that agent's registration names. Provider-executed tools and ids absent from the catalog are omitted. A complete literal catalog yields required derived entries; entries remain optional when the input catalog is optional or dynamic. DerivedToolRegistry is the exported type of that inferred result.

See Codegen for the end-to-end catalog and agent-registration workflow.

ThreadProvider

type ThreadProviderProps<TContract extends AgentContract> =
  (
    | {
        threadId: ThreadId;
        clientData?: TContract["clientData"];
        thread?: never;
      }
    | {
        thread: Thread<TContract>;
        threadId?: never;
      }
  ) & {
    observe?: boolean;
    readOnly?: boolean;
    toolRenderers?: ToolRendererMap<TContract>;
    children?: React.ReactNode;
  };

Use threadId when React should open the thread from the current client. Use thread when code outside React already has a handle. readOnly is a UI hint for transcript surfaces such as subagent child threads. Configure stable tool renderers once on Pai.agent(..., { toolRenderers }). Use ThreadProvider.toolRenderers only when one view subtree needs presentation that differs from that agent default, such as a compact subagent transcript or read-only audit view:

<ResearcherAI.ThreadProvider
  threadId={childThreadId}
  readOnly
  toolRenderers={researchToolRenderers}
>
  <Transcript />
</ResearcherAI.ThreadProvider>

Two ThreadProvider instances may render the same thread with independent overrides. A nested ThreadProvider starts a fresh renderer scope, so it does not inherit the outer provider's static or hook renderers; both scopes still fall back to the agent-provider-scoped renderers. Renderer precedence is: the nearest provider-scoped useToolRenderer, an agent-provider-scoped useToolRenderer, a client tool's render, the nearest ThreadProvider.toolRenderers, the agent binding's default toolRenderers, then the AI.Tool fallback. Within each hook registry or static map, a named renderer wins over "*"; each layer checks its wildcard before falling through to the next layer. A hook mounted outside ThreadProvider applies throughout that agent's current Pai.Provider subtree.

useAnchoredChatScroll

function useAnchoredChatScroll(options?: {
  anchorKey?: string | null;
  behavior?: "auto" | "instant" | "smooth";
}): AnchoredChatScrollController;

useAnchoredChatScroll() implements the common chat-scroll pattern where the latest user message is aligned to the top of the scroll viewport and assistant content streams into the space below it.

const scroll = useAnchoredChatScroll({
  anchorKey: latestUserMessageId,
});

return (
  <div ref={scroll.viewportRef} className="overflow-y-auto">
    <div ref={scroll.contentRef}>
      {messages.map((message) => (
        <MessageRow
          key={message.id}
          ref={scroll.anchorRef(message.id)}
          message={message}
        />
      ))}
      <div {...scroll.spacerProps} />
    </div>
  </div>
);

On this page