showcase/shell-docs/src/content/snippets/shared/threads/self-managed.mdx
A common question: how do I point CopilotKit's threads at my own database instead of CopilotKit Intelligence?
The honest answer is that you can't, because there is no such extension point. CopilotKit does not define a "bring your own thread backend" endpoint spec that you can implement against — Rich Threads (useThreads, the Threads Drawer, cross-device sync, replayable event history) are a capability of CopilotKit Intelligence, not an interface with a swappable implementation.
What you can do without the platform is persist conversations yourself. That path is real and it works — it just looks different from Rich Threads, and it's worth being clear about what you get and what you give up before you build it.
| Self-managed | CopilotKit Intelligence | |
|---|---|---|
| Conversation survives a page reload | Yes, if your framework checkpoints it | Yes |
| Conversation follows the user across devices | Yes, if your store is server-side and user-scoped | Yes |
| Thread list, rename, archive, delete | You build it | useThreads, built in |
| Full AG-UI event history replayed into the UI | No — you restore framework state, not the event log | Yes |
| Generative UI and multimodal history restored | No | Yes |
| Realtime sync across tabs and devices | No | Yes |
| Resuming a run that's still in flight | No | Yes |
The line that matters most is the fourth row. Framework-native persistence stores your agent's state; it does not store the AG-UI event stream that produced the visible conversation. So a restored conversation can continue correctly while still not looking the way it did when the user left it — rendered tool-call components, streamed reasoning, and attachments are re-derived from the event history, which nobody kept.
Three pieces, none of them CopilotKit-specific.
threadIdDon't let CopilotKit mint the id. Mint it yourself, store it, and pass it in — that id is the only thing correlating the browser, the runtime, and your agent's store.
<CopilotChat agentId="my-agent" threadId={conversationId} />
An auto-minted id is re-created on remount, which silently starts a new conversation. See Thread lifecycle for the full precedence rules.
CopilotKit forwards the threadId to your agent as the AG-UI threadId. Use it as — or map it to — your framework's own thread identifier, and let the framework's persistence do the storing.
<Tabs items={["LangGraph", "Other frameworks"]}> <Tab value="LangGraph"> A checkpointer persists graph state per thread:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
checkpointer = AsyncPostgresSaver.from_conn_string(DB_URL)
graph = builder.compile(checkpointer=checkpointer)
The incoming threadId becomes the checkpointer's thread_id, so the next run against the same id resumes from the stored state. See LangGraph message persistence.
useThreads is platform-backed and will not return your rows, so the conversation list is application code: a table of (thread_id, user_id, title, updated_at) that you query and render, with the selected id handed to <CopilotChat threadId={...} />.
Scope every query to the signed-in user, and check ownership on the runtime side too — see Thread authorization.
Framework state alone doesn't repopulate the UI. If you want prior messages on screen at mount, you have to put them there — read them from your store and set them on the agent:
import { useEffect } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
function RestoreHistory({ threadId }: { threadId: string }) {
const { agent } = useAgent({ agentId: "my-agent" });
useEffect(() => {
let cancelled = false;
myApi.getMessages(threadId).then((messages) => {
if (!cancelled) agent?.setMessages(messages);
});
return () => {
cancelled = true;
};
}, [agent, threadId]);
return null;
}
This restores text. It does not restore generative UI, tool-call renders, or attachments — those come back only from a replayed AG-UI event history, which is what the platform store provides and a checkpointer does not.
Self-managed persistence is a reasonable fit when conversations are simple and mostly textual, you already run a database and an auth layer, and you'd rather own the storage than add a dependency.
It stops being the cheaper option once you want a thread list with rename and archive, conversations that look the same on return as when the user left, realtime sync across tabs, or the ability to rejoin a run still in progress. At that point you are re-implementing the platform, and the boundary in OSS vs Enterprise is worth re-reading.
If you have existing conversations in a framework store and want them as Rich Threads, Importing and synchronizing thread history covers the migration.