PAIPAI

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.

On this page