PAIPAI

Thread Lifecycle In React

Create, route, and recover threads in a React app.

React components render a Thread. They still need a thread id or a thread object.

Common Patterns

PatternBehavior
Create id on page loadGood for one always-present assistant surface.
Create on first submitAvoids empty threads.
Route by threadIdGood for saved conversations and browser navigation.
Pass a Thread objectUseful when code outside React already opened the thread.
Workspace thread listGood for ChatGPT-style sidebars with saved threads and a New Chat action.

Create On Page Load

const [threadId] = useState(() => pai.newThreadId());

Then pass the id to the provider:

<AssistantAI.ThreadProvider threadId={threadId}>
  <AssistantChat />
</AssistantAI.ThreadProvider>

Persist A Local Draft Thread

Use localStorage for a single local draft/chat during prototyping. Production apps often move the id into a route such as /threads/:threadId.

const threadStorageKey = "assistant-thread-id";

const [threadId, setThreadId] = useState<string | null>(() =>
  localStorage.getItem(threadStorageKey),
);

useEffect(() => {
  if (threadId) return;

  const nextThreadId = pai.newThreadId();
  localStorage.setItem(threadStorageKey, nextThreadId);
  setThreadId(nextThreadId);
}, [threadId]);

if (!threadId) return <p>Loading</p>;

return (
  <AssistantAI.ThreadProvider threadId={threadId}>
    <AssistantChat />
  </AssistantAI.ThreadProvider>
);

Route By Thread Id

Use this when threads are durable user-facing resources.

function ThreadRoute({ threadId }: { threadId: string }) {
  return (
    <AssistantAI.ThreadProvider threadId={threadId}>
      <AssistantChat />
    </AssistantAI.ThreadProvider>
  );
}

Pass An Existing Thread

Use thread={thread} when code outside React owns the handle. The provider uses that object as supplied; it cannot retrofit the dynamic request-context resolver that the threadId form creates.

If pending-action or client-tool ToolData writes need changing clientData or tool declarations, create the handle with requestContext:

const thread = client.thread(threadId, {
  requestContext: () => ({
    clientData: currentClientData(),
    clientTools: currentClientTools(),
  }),
});

Use the threadId form when React-mounted useClientData or client-tool registrations should automatically supply that request context.

Client Data

Use clientData when the client needs to send typed, request-scoped UI context with thread operations. It is useful for things like locale, the active document, the selected workspace view, or editor state that should influence the agent without becoming trusted auth or scope.

Static per-thread data can live on ThreadProvider:

<AssistantAI.ThreadProvider
  threadId={threadId}
  clientData={{ locale: "en-AU", view: "rfp-editor" }}
>
  <AssistantChat />
</AssistantAI.ThreadProvider>

Mounted components can contribute their own data with useClientData:

function ActiveRfpScope({ rfpId }: { rfpId: string }) {
  AssistantAI.useClientData({
    activeRfp: { rfpId },
  });

  return null;
}

function RfpWorkspace({ threadId, rfpId }: { threadId: string; rfpId: string }) {
  return (
    <AssistantAI.ThreadProvider
      threadId={threadId}
      clientData={{ locale: "en-AU" }}
    >
      <ActiveRfpScope rfpId={rfpId} />
      <AssistantChat />
    </AssistantAI.ThreadProvider>
  );
}

Object-shaped client data is shallow-merged. ThreadProvider.clientData is the base value; mounted useClientData(...) contributions add or override top-level keys while those components are mounted. Passing undefined contributes nothing.

clientData is validated by the agent's clientData schema on the server. Do not use it for authorization decisions; use the trusted identity for that.

Thread List Workspace

Use useThreads() outside ThreadProvider when the screen owns thread selection.

function AssistantWorkspace() {
  const threads = AssistantAI.useThreads({ limit: 25 });
  const [threadId, setThreadId] = useState<string | null>(null);

  async function createNewChat() {
    const thread = threads.create();
    setThreadId(thread.threadId);
  }

  return (
    <main>
      <aside>
        <button onClick={createNewChat}>New chat</button>
        {threads.items.map((thread) => (
          <button
            key={thread.threadId}
            onClick={() => setThreadId(thread.threadId)}
          >
            {thread.title ?? thread.threadId}
          </button>
        ))}
        {threads.hasMore ? (
          <button disabled={threads.loadingMore} onClick={threads.loadMore}>
            Load more
          </button>
        ) : null}
      </aside>

      {threadId ? (
        <AssistantAI.ThreadProvider threadId={threadId}>
          <AssistantChat />
        </AssistantAI.ThreadProvider>
      ) : (
        <EmptyChat onCreate={createNewChat} />
      )}
    </main>
  );
}

Pai.Provider supplies the client for list, local thread creation, and threadId resolution. The app owns which thread is active. ThreadProvider owns refreshing and watching that one active thread.

useThreads() keeps pagination visible without making it mandatory. Small apps can ignore hasMore and loadMore(). Large workspaces can pass limit, render a "Load more" button, and let the hook carry the opaque page token internally.

When another surface creates a thread in the same scope, useThreads({ onThreadCreated }) receives the new thread summary from the live head stream. The hook does not insert it into items; call context.refresh() from the callback after checking filters such as parentThreadId, category, or listVisibility.

Live

ThreadProvider refreshes and observes the thread when it mounts or when threadId changes. If the id has no persisted server row yet, the first send realizes it.

If refresh or watch setup fails, hooks expose the error through their controller. A permanent background watch failure is also available as the sticky watchError on thread, chat, and thread-list controllers, while transient disconnects reconnect internally. A later successful list refresh clears useThreads().error but not useThreads().watchError, because the ended head watch is still no longer delivering updates. The bindings do not hide auth, scope, network, or contract errors.

Provider Placement

Put Pai.Provider high enough that all AI surfaces can reuse the same client.

<Pai.Provider client={pai}>
  <AppRoutes />
</Pai.Provider>

Put ThreadProvider around the UI for one active thread.

<AssistantAI.ThreadProvider threadId={threadId}>
  <AssistantChat />
  <ApprovalTray />
  <BrowserCapabilities />
</AssistantAI.ThreadProvider>

Unmounting a ThreadProvider stops observing the thread. It does not delete the thread or cancel a running task by default.

Use React Setup for provider wiring and Chat Hooks for native-message and composer rendering.

On this page