Chat Hooks
Build a typed chat interface over native-first PAI messages.
useChat() is the default chat hook. Its messages field is the same canonical
PaiMessage[] collection exposed on chat.state.messages.
import { isPaiToolPart } from "@pai/react";
function AssistantChat() {
const chat = AssistantAI.useChat();
return (
<section>
{chat.messages.map((message) => (
<article key={message.id} data-role={message.role}>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={index}>{part.text}</p>;
}
if (isPaiToolPart(part)) {
return (
<AssistantAI.Tool
key={part.toolCallId}
message={message}
part={part}
/>
);
}
return null;
})}
</article>
))}
<ComposerRow />
{chat.isRunning ? (
<button type="button" onClick={() => chat.stop()}>
Stop
</button>
) : null}
</section>
);
}useChat()
const chat = AssistantAI.useChat();
chat.messages;
chat.state.runs;
chat.state.activeRunId;
chat.queue.items;
await chat.send("Draft a report");
await chat.stop();
await chat.refresh();Everything on useChat() changes with the conversation, and nothing on it
changes faster. Reading it therefore re-renders a component when the thread
moves, not when the user types. The draft lives on useComposer() for that
reason: text changes on every keystroke, so the subscription to it belongs in
the component that renders the input rather than in the hook every part of a
chat surface reads.
Pass onData to observe each application-authored native data chunk. Retained
chunks also update chat.messages; transient chunks are delivered once and do
not enter message history. Framework-reserved data-pai-* chunks never reach
this callback.
const chat = AssistantAI.useChat({
onData(part) {
if (part.type === "data-weather-map") {
updateLiveMap(part.data.points);
}
},
});The hook keeps the current callback in a ref. Changing a callback closure does not recreate the thread handle, restart observation, or resubscribe the native data stream.
chat.stop() cancels the active run and all pending work by default. Pass
continueWith to preserve selected queued work after cancellation:
await chat.stop({ continueWith: "steer" });The accepted values are "none" (the default), "steer", and "all".
Retained work resumes in normal priority order.
chat.messages is the rendering source of truth. When run ownership matters,
join those same message objects to chat.state.runs through
message.metadata.pai.runId:
const runGroups = chat.state.runs.map((run) => ({
run,
messages: chat.messages.filter(
(message) => message.metadata.pai.runId === run.runId,
),
}));Local optimistic sends and standalone administrative messages have no run id.
For correlated messages, message.metadata.pai.relation distinguishes input,
context, and output.
chat.loading is true until an authoritative snapshot has been applied, which
is what tells an empty chat.messages apart from one that has not arrived yet.
Read it before rendering an empty state, or the reader of an existing thread
sees "no messages" first. It settles on the first snapshot, on refresh(), and
once a connection attempt has failed and a retry is scheduled — an outage stops
the wait rather than extending it.
chat.error is the latest local command or observation error. When the
background watch ends permanently, chat.watchError identifies that terminal
connection failure so the UI can stop loading and offer a refresh. A transient
disconnect reconnects internally and does not set it. chat.state.error is the
latest safe durable background-run failure and may include a support reference.
See Error Handling.
useThread() And useThreadState()
useThread() returns the command controller and current state. Use
useThreadState() when a component only needs the public state value.
const thread = AssistantAI.useThread();
const state = AssistantAI.useThreadState();
state.messages;
thread.getPendingAction(message, toolPart);useThreadSelector()
Select a stable derived value without introducing another canonical message shape:
const completedMessages = AssistantAI.useThreadSelector((state) =>
state.messages.filter(
(message) => message.metadata.pai.status === "committed",
),
);useComposer()
useComposer() reads the nearest ThreadProvider's ephemeral draft — text,
staged attachments, and upload state — and the actions over it. Its submit
handler can be passed directly to a form.
const composer = AssistantAI.useComposer();The draft belongs to the mounted provider scope, not to the component that calls
the hook or to the durable logical thread. Every composer hook under one
ThreadProvider reads and writes the same draft, so
AssistantAI.useComposer() and the binding-agnostic useComposer() are two ways
to reach one draft rather than two drafts.
The binding-agnostic form needs no agent binding, so a shared composer component can be written once and mounted under any agent:
import { useComposer } from "@pai/react";
function ComposerRow() {
const composer = useComposer();
return (
<form onSubmit={composer.submit}>
<input
value={composer.text}
onChange={(event) => composer.setText(event.target.value)}
/>
<button type="submit" disabled={!composer.canSubmit}>
Send
</button>
</form>
);
}Where to read the draft
Reading the draft subscribes the caller to every keystroke, because the text is
state and whoever reads state re-renders when it changes. Call useComposer() in
the component that renders the input, and typing re-renders that row alone.
The draft is deliberately absent from useChat(). Lifting the useComposer()
read into a parent that also renders the transcript, tool cards, or a docked side
panel would reconsider that whole subtree on every character.
useComposerActions()
A component that only writes the draft — a starter prompt, a suggestion chip, a
"quote this message" control — should not re-render as the user then types.
useComposerActions() returns the write half (setText, attachFiles,
removeAttachment, submit) and subscribes to no draft state. Its result stays
stable as the draft changes, although provider context changes may replace it:
import { useComposerActions } from "@pai/react";
function StarterPrompts({ prompts }: { prompts: string[] }) {
const { setText } = useComposerActions();
return prompts.map((prompt) => (
<button key={prompt} type="button" onClick={() => setText(prompt)}>
{prompt}
</button>
));
}Switching a provider's threadId starts an empty draft and revokes attachment
previews held by the retired draft. Unmounting and remounting also starts empty.
Two provider instances have independent drafts, even when both use the same
logical threadId; this state is not persisted or synchronized across provider
instances.
Tool Actions
Tool renderer props are the native part plus two sidecars: runStatus for the
owning run's liveness and the optional action. Outside a renderer, call
thread.getPendingAction(message, part).
Native part.state remains authoritative. PAI adds no presentation state of its
own — part.cancelled says whether the runtime abandoned the call rather than
the tool failing, and everything else follows from part.state.
Memoization
A message that has not changed keeps its identity through projection, along with every part inside it. Only the messages a chunk actually touched are rebuilt, so appending a token to the newest message leaves the rest of the thread untouched. Memoize per message and the memo holds for the whole of a run.
The streaming message itself is rebuilt on each chunk, and so is every part inside it — the SDK reducer snapshots the whole message, so identity is not a useful signal within the message being written. Memoize a row on the message it renders, not on one of that message's parts.