Thread & History Lifecycle

How a threadId is created, how conversation history is restored, how to switch or start threads, and how CopilotKit threads relate to your agent framework's own persistence.


What is this?#

Every CopilotKit conversation is scoped to a thread, identified by a threadId. This page explains the full lifecycle of that thread on the client: how the id is created, how prior history is restored when a conversation loads, how to switch between threads or start a new one, and where CopilotKit's threads end and your agent framework's own persistence (e.g. a LangGraph checkpointer) begins.

This page describes the v2 hooks and components imported from @copilotkit/react-core/v2. The v1 API (useCopilotChat, initialMessages, the v1 useThreads) behaves differently; see the v1 vs v2 notes at the end.

The lifecycle at a glance#

  1. Mint. When a chat mounts without an explicit threadId, the client generates one (a UUID v4).
  2. Run. Messages and tool calls stream under that threadId. If a server-side store is configured (the Enterprise Intelligence Platform, or a persisting AgentRunner), they are persisted as they happen so the thread can be replayed later. A runtime with no persistence layer keeps nothing server-side. See Threads & Persistence Architecture for the full server-side model.
  3. Hydrate. When a chat mounts with a known threadId, the client connects and replays the persisted history into the UI.
  4. Switch / start. You change the active thread (restoring its history) or start a fresh one (clearing the view).

How the threadId is created#

When you don't supply one, CopilotKit mints a threadId on the client, at mount, using a UUID v4. The value is resolved by this precedence (highest first):

  1. An explicit threadId prop on <CopilotChat> / <CopilotChatConfigurationProvider> (this is authoritative: it also drives history replay and disables the welcome screen).
  2. An active-thread override set via setActiveThreadId(...) / a picked thread row / startNewThread().
  3. A threadId inherited from a parent configuration provider.
  4. A non-authoritative threadId seed.
  5. Otherwise, a freshly minted randomUUID().
Explicit threadId (recommended when you own persistence)
import { CopilotChat } from "@copilotkit/react-core/v2";

// You control the id: history replays for it, and it's stable across remounts.
<CopilotChat agentId="my-agent" threadId={myThreadId} />

Auto-minted ids are stable across re-renders, but re-mint on remount. The fallback id is computed with useMemo, so a component remount (a changed React key, a parent unmount/remount, or React StrictMode's double-mount in dev) produces a new id and silently starts a new conversation. If you need an id that survives remounts, mint it yourself and pass it as the threadId prop (or persist it and restore it via setActiveThreadId). Don't rely on the auto-mint for continuity.

How history is restored (hydration)#

CopilotKit v2 does not use an initialMessages prop to seed a conversation. History is restored one of two ways:

1. Server-side replay. When a chat mounts with an explicit threadId, the client sets agent.threadId and calls connectAgent(), which replays that thread's persisted event history back into the UI, with no manual message wiring:

History replays for a known threadId (given a server-side store)
<CopilotChat agentId="my-agent" threadId={someExistingThreadId} />

Replay requires a server-side store to replay from: the Enterprise Intelligence Platform, or a persisting AgentRunner (e.g. the SQLite runner). A self-hosted runtime with no persistence layer has nothing to replay, so connectAgent() returns an empty stream and the conversation starts blank. If history isn't restoring, check that a store is configured, not the client code. The Persistence Architecture page covers how replay works server-side.

For a fresh (non-explicit) thread, e.g. after startNewThread(), there's nothing to replay, so connect is skipped and the message view is cleared.

2. Manual hydration. If you're managing messages yourself (for non-platform or custom cases), read them from the agent and set them directly:

Read and set messages via the agent
import { useAgent } from "@copilotkit/react-core/v2";

function MyComponent() {
  const { agent } = useAgent({ agentId: "my-agent" });

  // Read the current conversation
  const messages = agent.messages;

  // Replace it (e.g. hydrate from your own store)
  // agent.setMessages(myPersistedMessages);

  return null;
}

There is no v2 useCopilotChat hook and no v2 initialMessages prop. Read messages from useAgent().agent.messages and mutate with agent.setMessages(...) / agent.addMessage(...). (initialMessages still exists on the v1 useCopilotChat hook; see below.)

Scope Rich Threads to the signed-in user#

Your application owns end-user authentication. For an Intelligence-enabled Runtime, implement identifyUser(request) with the server-verified session or token from your auth provider and return a stable user id and name:

app/api/copilotkit/[[...slug]]/route.ts
const runtime = new CopilotRuntime({
  agents: { default: agent },
  intelligence,
  identifyUser: async (request) => {
    const session = await verifyAppSession(request); // Your server-side auth.
    if (!session?.user) throw new Error("Unauthorized");

    return {
      id: session.user.id,
      name: session.user.name,
    };
  },
});

Enterprise Intelligence combines that application user identity with the selected project when it scopes thread lists, subscriptions, history, rename, archive, unarchive, and delete operations. The project Runtime API key is a separate credential: keep it on the server and do not use the developer who created that key as the application user.

A static identifyUser value is suitable only for a single-user demo. In a multi-user application, every request must resolve the authenticated application user or those users will share the same thread scope.

See Authentication for forwarding and verifying your provider's auth context. identifyUser is the additional Runtime contract that assigns the verified application user to Rich Threads.

Switching threads and starting new ones#

Set the active thread to restore its history, or start a fresh conversation:

Switch or start a thread
import { useCopilotChatConfiguration } from "@copilotkit/react-core/v2";

function ThreadControls() {
  const config = useCopilotChatConfiguration();

  return (
    <>
      {/* Restore a known conversation (explicit → replays history) */}
      <button onClick={() => config?.setActiveThreadId(existingId, { explicit: true })}>
        Open conversation
      </button>

      {/* Start a fresh, empty conversation (mints a new non-explicit id) */}
      <button onClick={() => config?.startNewThread()}>New chat</button>
    </>
  );
}
  • setActiveThreadId(threadId, { explicit }): explicit: true (the default) treats it as a known thread and replays its history, while explicit: false shows the welcome screen.
  • startNewThread(): resets to a freshly minted, non-explicit id. Also available from useThreads() for thread-sidebar UIs.

Both setters no-op and log a warning when the threadId is prop-controlled (i.e. you passed an authoritative threadId prop). If you drive threads imperatively with these setters, don't also pass a threadId prop. Pick one source of truth.

Creating a thread with your own API on the first message#

A common ask: intercept the user's first message to mint a thread via your own backend (e.g. create a DB row and get its id) before the conversation starts.

On the built-in <CopilotChat> this cannot be done through onSubmitMessage: the component sets its own submit handler internally and discards any you pass. Instead, use one of these approaches.

Recommended: mint up front and drive the id. Create your thread when the user starts a new conversation (e.g. on a "New chat" action, or lazily just before you render the chat), then hand the id to CopilotKit:

Mint your thread, then drive it
async function startConversation() {
  const { id } = await myApi.createThread(); // your backend
  config?.setActiveThreadId(id, { explicit: true });
  // or render <CopilotChat threadId={id} />
}

At submit-time (headless only). If you must intercept exactly when the user hits send, compose the UI yourself with CopilotChatInput / CopilotChatView and pass your own onSubmitMessage (the built-in <CopilotChat> ignores it). Because the handler is yours, you set the thread on the agent and drive the send:

Headless: set the thread on the agent before sending
import { CopilotChatInput, useAgent } from "@copilotkit/react-core/v2";

function MyInput() {
  const { agent } = useAgent({ agentId: "my-agent" });

  return (
    <CopilotChatInput
      onSubmitMessage={async (text) => {
        const { id } = await myApi.createThread(); // your backend
        agent.threadId = id;                       // set it on the agent directly
        agent.addMessage({ role: "user", content: text });
        // ...then trigger the run with your send logic
      }}
    />
  );
}

For a send you fire in the same handler, set the id on the agent (agent.threadId = id); don't rely on setActiveThreadId(id). setActiveThreadId updates React state and only reaches the agent on the next render (and only when a <CopilotChat> is mounted to sync it), so a run kicked off immediately would still use the previous thread. Use setActiveThreadId / startNewThread for switching threads in the UI, and set agent.threadId directly when it must take effect before an imperative send.

onSubmitMessage is only a real interception seam in the headless path. On the built-in <CopilotChat> it's overridden, so reach for the "mint up front" approach there.

CopilotKit threads vs. your framework's checkpointer#

These are two different layers, correlated only by the shared threadId:

LayerWhat it storesManaged by
CopilotKit threadsConversation list + full AG-UI event history (messages, tool calls, state), with realtime syncThe Enterprise Intelligence Platform, via useThreads
Framework-native persistenceFramework-internal state/checkpointsYour agent, e.g. a LangGraph AsyncPostgresSaver

A LangGraph checkpointer (an AsyncPostgresSaver + compile(checkpointer=...)) creates LangGraph's own checkpoint tables. It does not create a CopilotKit "threads" table, and you won't see a threads table appear just from configuring a checkpointer. Conversely, useThreads rename/archive/delete operate on platform threads and do not reach into LangGraph (or ADK, etc.) stores unless your backend explicitly bridges them.

The bridge between the two is the threadId: when you pass an explicit CopilotKit threadId, it's forwarded to the backend as the AG-UI threadId, which your framework can use directly as (or map to) its own checkpoint/thread id.

Future runs can remain durable in both places#

After historical import, future conversations that run through CopilotKit are written to Enterprise Intelligence as Rich Threads. If your LangGraph agent continues using a durable checkpointer or LangGraph Platform, those same runs also continue through LangGraph's native persistence. The same applies to an ADK agent that remains connected to a durable session service with retained sessions. This lets you keep native framework storage and analytics while users get replay, generative UI, multimodal history, and realtime continuity through CopilotKit.

This is coordinated persistence during future CopilotKit-mediated runs, not general database replication. CopilotKit lifecycle actions such as rename, archive, and delete apply to the Enterprise Intelligence thread; they do not rename, archive, or delete records in the native framework store.

See LangGraph persistence for the framework-native (checkpointer) side, and Importing and synchronizing thread history for bringing existing framework conversations into the platform store. (LangGraph Platform requires thread ids to be UUIDs, which CopilotKit's auto-minted ids already are.)

MCP Apps activity and history#

MCP Apps render through activity messages (activityType: "mcp-apps"), and that rendering is a client-side concern: the registered renderer resolves the referenced UI resource and runs any follow-up in the browser. So the visible MCP App UI is re-derived on the client from the conversation when it loads. It isn't a separately-stored artifact you persist on its own.

Practically, MCP App UI restores the same way the rest of the conversation does: if your server-side store replays the thread's history (see server-side replay above), the tool-call/activity messages come back and the renderers run again on hydration. There's no separate MCP-Apps store to configure.

v1 vs v2#

  • v2 (@copilotkit/react-core/v2): useThreads returns the platform thread list; threadId lives per-chat (on <CopilotChat> / <CopilotChatConfigurationProvider>), not on the provider; read messages via useAgent().agent.messages and mutate via agent.setMessages(...). No initialMessages.
  • v1 (@copilotkit/react-core): useThreads returns { threadId, setThreadId, isThreadIdExplicit } (a single-conversation id manager, not the platform list). Its setThreadId silently no-ops when a threadId prop is present, because the prop shadows internal state. <CopilotKit threadId> sets the id at the provider, and useCopilotChat() exposes setMessages(...) and an initialMessages option. (Separately, the setThreadId on the main <CopilotKit> hooks throws if you call it while threadId is prop-supplied.)

See also#