Skip to content

Persist chat conversations (IndexedDB, SQLite, REST)

Everything so far lives in memory — reload the page and the thread is gone. aparté’s persistence subsystem closes that gap. A AparteConversationManager holds the list of conversations and notifies listeners on every change, but never touches storage directly — that’s the job of an AparteStorageAdapter you implement against any backend (localStorage, IndexedDB, SQLite WASM, your own REST API). A conversationId binding loads/persists the active thread; <aparte-conversation-list> renders a sidebar to switch between them.

The contract has three required methods — together they’re the minimum viable adapter:

interface AparteStorageAdapter {
loadAll(): Promise<AparteConversation[]>; // all conversations, full payload, newest first
save(conv: AparteConversation): Promise<void>; // upsert (create or update)
delete(id: string): Promise<void>; // permanent delete
// + optional split-storage extensions: archive?/unarchive?, loadMeta?/loadFull?,
// pin?/unpin?, rename?, and loadAttachments? for persisted attachment blobs.
}

An AparteConversation carries id, title, createdAt, updatedAt, a flat messages array (the active path — always kept for sidebar previews and compat), an optional tree (full branch topology, see Conversations & branching), and optional archivedAt / pinnedAt / schemaVersion (current version is 2; treat undefined as legacy data).

The current value is exported as APARTE_CONVERSATION_SCHEMA_VERSION, so an adapter can compare it against what it stored and migrate instead of guessing.

Here’s a complete adapter over localStorage, implementing the three required methods plus the optional archive / unarchive pair:

import type { AparteConversation, AparteStorageAdapter } from '@aparte/core';
const KEY = 'aparte:conversations';
function readAll(): AparteConversation[] {
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); }
catch { return []; }
}
function writeAll(convs: AparteConversation[]): void {
localStorage.setItem(KEY, JSON.stringify(convs));
}
export class LocalStorageAdapter implements AparteStorageAdapter {
async loadAll(): Promise<AparteConversation[]> {
return readAll().sort((a, b) => b.updatedAt - a.updatedAt);
}
async save(conv: AparteConversation): Promise<void> {
const all = readAll();
const i = all.findIndex((c) => c.id === conv.id);
if (i >= 0) all[i] = conv; else all.push(conv);
writeAll(all);
}
async delete(id: string): Promise<void> {
writeAll(readAll().filter((c) => c.id !== id));
}
// Optional — without them, archiving falls back to a plain save().
async archive(id: string): Promise<void> { this._setArchived(id, Date.now()); }
async unarchive(id: string): Promise<void> { this._setArchived(id, undefined); }
private _setArchived(id: string, archivedAt: number | undefined): void {
const all = readAll();
const conv = all.find((c) => c.id === id);
if (conv) { conv.archivedAt = archivedAt; writeAll(all); }
}
}

An IndexedDB (or SQLite WASM) adapter follows the same shape — every method is async precisely so any backend fits. Richer backends can additionally implement loadMeta() / loadFull(id) (fast sidebar listing vs. lazy full payload), pin / unpin / rename, and loadAttachments(msgId) for attachment blobs — all optional, consulted only when present. Anything beyond the conversation — a memory of user facts, a settings store, a gallery of every artifact ever produced — is your app’s own table: extend the interface in your code rather than expecting the library to carry your schema.

AparteConversationManager owns the in-memory list, mutates it, and calls your adapter — your app never calls the adapter directly.

import { aparteGlobalConfig, AparteConversationManager } from '@aparte/core';
import { LocalStorageAdapter } from './local-storage-adapter';
const manager = new AparteConversationManager(new LocalStorageAdapter());
await manager.init(); // hydrates the list from the adapter
aparteGlobalConfig.setConversationManager(manager); // registers it for every <aparte-*> component

Running several independently-configured chats on one page? Call setConversationManager on each chat’s own AparteConfig instance (passed as config) instead of the global singleton.

Useful reads once registered: manager.conversations, manager.activeConversations / manager.archivedConversations (newest first), manager.activeId, manager.active, and manager.subscribe(listener) (returns an unsubscribe fn; fires after every mutation). Mutations: createNew(title?), delete(id), archive(id), unarchive(id).

Optional bounded history: new AparteConversationManager(adapter, { retention: { maxMessages: 200 } }) trims a persisted conversation to its last N messages on every write — storage only, the live session in the DOM is never truncated.

3. Load and persist the active thread — conversationId

Section titled “3. Load and persist the active thread — conversationId”

Each wrapper’s <AparteChat> exposes a conversationId binding. Setting it loads that conversation’s messages (and branch tree, if any) via the registered manager; setting it to null deselects. Sending the first message while conversationId is unset lazily creates a conversation and reports the new id back, so you can sync a URL/router.

There is no binding without a wrapper — the pair is a wrapper convenience over AparteConversationController, which is the next section on this page.

controller.bind();
void controller.setConversationId('abc-123'); // or null to deselect

Without a wrapper, drive the same lifecycle yourself with AparteConversationController — it’s exactly what the wrappers use internally:

import { AparteConversationController } from '@aparte/core';
const chat = document.querySelector('aparte-chat')!;
const viewport = chat.viewport!;
const controller = new AparteConversationController({
hostId: 'main-chat',
host: chat,
getMessages: () => viewport.getMessages(),
setMessages: (m) => viewport.setMessages(m),
appendMessage: (m) => viewport.appendMessage(m),
clearMessages: () => viewport.clearMessages(),
setLoading: (on) => viewport.setLoading(on), // optional — the viewport draws the wait
exportTree: () => viewport.exportTree(), // optional — enables branch persistence
importTree: (t) => viewport.importTree(t), // optional
});
controller.bind();
void controller.setConversationId('abc-123'); // or null to deselect

A store takes its time. When your adapter implements loadMeta() and loadFull(id), the manager reads the list through the first and fetches a conversation’s messages through the second the first time it is opened (manager.ensureFull(id), and manager.isLoaded(id) says whether it has). While that fetch runs, the controller calls the binding’s setLoading(true): <aparte-chat-viewport> draws two skeleton turns, marks its scroll surface aria-busy and names the wait for a screen reader, and aparte-chat[center-empty] does not count it as empty — the welcome never shows over a conversation that is loading. The composer is disabled for the duration — a message sent into a transcript that has not arrived would be overwritten by it — and comes back as it was (a disabled you set stays). Two quick clicks are safe: a reply that lands for a conversation the user has already left is dropped. ensureFull() resolves false when the store has no such record — the row then does not count as loaded, and the controller goes home rather than keep an empty transcript active. A metadata change (updateTitle, pin, archive) on a conversation whose messages are not in memory goes through the adapter’s own hook when it has one (rename(), pin(), archive()…) and otherwise fetches the messages first, so a row the list came with is never written back without them; updateMessages() on such a row is refused with a warning. A metadata change leaves updatedAt alone — a rename, a pin and an archive keep the conversation’s place in the list, which sorts on the last message rather than on the last edit.

The list has the same word for its own wait: set loading on <aparte-conversation-list> while manager.init() runs, clear it when you assign conversations.

The framework wrappers draw the same wait themselves — the controller sets it while it fetches — and each has a loading prop (an input in Angular) for a wait of your own.

<aparte-conversation-list> is a display primitive: set its conversations property and active-id attribute. Each row is its title plus a ⋯ menu: rename, pin/unpin, archive/unarchive, delete (which asks first). The rows are grouped by date as soon as any item carries updatedAt; set flat to render them flat.

Add manage and the menu writes through to the manager you registered in step 2 — those gestures need no code of yours at all:

const list = document.querySelector('aparte-conversation-list')!;
function render() {
list.conversations = manager.activeConversations;
list.setAttribute('active-id', manager.activeId ?? '');
}
manager.subscribe(render);
render();
// The two lines that finish the row menu — the first is step 2's, repeated here so the
// pair is visible together.
aparteGlobalConfig.setConversationManager(manager);
list.setAttribute('manage', '');
// Selecting a conversation is owned by the `conversationId` binding (or a window-level
// `aparte-conversation-select` event that every bound controller listens for), not the list.

When you own the persistence, leave manage off: the list then only announces the intent, and every gesture is yours to carry out. The events all bubble, with detail: { id } — rename adds title. They fire under manage too, first and cancelable, so preventDefault() on one takes that single gesture back and leaves the rest to the list.

list.addEventListener('aparte-conversation-rename', (e) => manager.updateTitle(e.detail.id, e.detail.title));
list.addEventListener('aparte-conversation-pin', (e) => manager.pin(e.detail.id));
list.addEventListener('aparte-conversation-unpin', (e) => manager.unpin(e.detail.id));
list.addEventListener('aparte-conversation-archive', (e) => manager.archive(e.detail.id));
list.addEventListener('aparte-conversation-unarchive', (e) => manager.unarchive(e.detail.id));
list.addEventListener('aparte-conversation-delete', (e) => manager.delete(e.detail.id));

Each wrapper ships a reactive helper around the same manager — call its init(adapter) once instead of constructing AparteConversationManager by hand (step 2 is done for you), then bind conversations / activeConversations / archivedConversations to the list and createNew / addMessage / updateMessages / updateTitle / pin / unpin / delete / archive / unarchive to actions:

WrapperHelper
ReactuseConversationManager() — hook, plain state
VueuseConversationManager() — composable, refs/computed
SveltecreateConversationManager() — stores
AngularConversationManagerService — injectable (providedIn: 'root')

Switching the active conversation stays owned by the conversationId binding — the helpers deliberately don’t expose a select().

Your adapter stores AparteMessage objects verbatim, segments included, and hands them back. One exception, and it is core’s own: the segments core derived by splitting a markdown reply on display are not written back — neither in messages nor in the branch tree. They are core’s reading of your content, so they are read again on the next open and a block grammar you register later applies to conversations already saved.

Core then adopts the segments you did store rather than treating them as new — and the difference is worth knowing, because two of the fields on a segment are facts and two are measurements.

on a restored segmentwhat happenswhy
id, type, content, …yours, untouchedit is your data
messageId, indexrecomputed from the array being joinedderivable facts. A stored value can only contradict the list it lands in — and no protocol persists either; Anthropic’s block index exists solely inside the streaming envelope, as a position
meta.aparte.startedAt / endedAtnot restored, and not inventeda span is something the client measured while the turn ran. A measurement nobody took is absent
isStreamingforced to falsea persisted stream is dead. Restored as streaming it would render a caret for ever, and the next completed turn would stamp it a brand-new end
meta.* (yours)round-trips as storedyour half of the bag

Nothing is lost that was ever there. Most backends store messages and have never heard of a segment — content parts carry no timestamp in any wire format — so there is usually nothing to lose. What changed is that core no longer fills the gap with now: a conversation from three weeks ago used to come back claiming every segment had started that second, and only on some of the load paths, so the same stored thread produced different numbers depending on whether you were in native or framework-managed mode and on whether a tree had been saved.

A request that outlived its page is closed for you. A tool_call persisted as awaiting-approval comes back as aborted. The loop that awaited the decision went with the page, so nothing can answer it — and aborted rather than rejected because nobody refused anything. Every load path shares one normalisation, so this holds whichever of them your adapter uses.

One thing core still cannot fix for you. A tool_call persisted as pending comes back with a spinner and an open span, because the handler that was running is likewise gone. If your adapter can be interrupted mid-turn, normalise that status on save.


See its element page for the exact property and attribute signatures, and Conversations & branching for exportTree() / importTree(), which this subsystem persists as the tree field.