@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.cancelledseparates 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 asoutput-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.runStatusis the status of the run that produced the call, ornullwhen the thread no longer carries it. Liveness is the one fact a renderer cannot read off the part — aninput-availablecall looks identical whether it is executing now or was stranded by a run that ended. UseisStrandedToolCall(part, runStatus)for that check.actionis 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.erroris a local command or observation rejection caught by the React binding;controller.watchErroris a sticky terminal failure from the background thread or thread-head watch. Transient disconnects recover internally and do not set it;controller.state.erroris 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 API | Package-root behavior | Bound 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>
);