Chat with Your Own Storage
read as.md You own the UI, you own the storage. ggui gives you the streaming protocol
(useInvoke) plus pure helpers for persistence shape
(@ggui-ai/react/chat-helpers). Where messages live, how threads are indexed,
what your composer looks like — that’s yours.
When to use this pattern
Section titled “When to use this pattern”Pick this pattern when at least one holds:
- You already have a persistence layer (Postgres, Firestore, IndexedDB, your Redux store) and don’t want a second one.
- You need full control of the message schema — custom attachments, per-message ACLs, server-side fan-out.
- You’re integrating ggui into an existing chat surface, not building a new one.
If none apply and you just want “a chat UI that works”, reach for
useChatThread in @ggui-ai/react/chat-thread — same flow behind a single
hook with a pluggable MessageStorageAdapter and a ChatThreadProvider.
60-line example
Section titled “60-line example”The file below is the complete working integration — streaming, persistence, card rendering, send — in ~60 lines.
import { useEffect } from "react";import { GguiProvider, useInvoke } from "@ggui-ai/react";import { useRafThrottled, invokeMessageToContentGroups, extractRenderFromToolResult, type ContentGroup,} from "@ggui-ai/react/chat-helpers";
// Replace with any storage: localStorage, fetch, IndexedDB, Firestore, …const store: Array<{ threadId: string; group: ContentGroup }> = [];
function persist(threadId: string, messages: ReturnType<typeof useInvoke>["messages"]) { const seen = new Set(store.filter((e) => e.threadId === threadId).map((e) => e.group.key)); for (const msg of messages) { for (const group of invokeMessageToContentGroups(msg)) { if (!seen.has(group.key)) store.push({ threadId, group }); } }}
function Chat({ threadId, endpointUrl }: { threadId: string; endpointUrl: string }) { const { messages, send, isStreaming } = useInvoke({ endpointUrl }); const throttled = useRafThrottled(messages);
useEffect(() => { persist(threadId, messages); }, [threadId, messages]);
return ( <div> {throttled.map((m) => ( <div key={m.id}> <strong>{m.role}: </strong> {m.content.map((b, i) => { if (b.type === "text") return <p key={i}>{b.text}</p>; if (b.type === "tool_result") { const render = extractRenderFromToolResult(b); return <pre key={i}>{JSON.stringify(render, null, 2)}</pre>; } return null; })} </div> ))} <button disabled={isStreaming} onClick={() => send("hello", { clientMessageId: crypto.randomUUID() })} > Send hello </button> </div> );}
export default function App() { return ( <GguiProvider appId="demo"> <Chat threadId="t1" endpointUrl="http://127.0.0.1:6781" /> </GguiProvider> );}The ContentGroup contract
Section titled “The ContentGroup contract”invokeMessageToContentGroups(message) splits a finalized invoke message
into ContentGroups — the durable unit you persist:
interface ContentGroup { key: string; // `${message.id}-${startBlockIdx}` — see invariant below kind: "text" | "card" | "other"; authorRole: "user" | "agent"; blocks: ContentBlock[]; // a contiguous run of text, or a tool_use + tool_result pair cardSnapshot: unknown | null; // frozen GguiSession for kind="card" textPreview: string; // ~160-char preview for chat-list tiles}The key invariant. key is deterministic from message.id plus the
block index where the group starts. Two consequences:
- Idempotency. Re-persisting the same group with the same key is a
no-op. If your storage uses
keyas primary key (recommended), thepersist()loop above can run after every token delta without producing duplicates. - Streaming messages are excluded. A message whose
isStreamingistruereturns[]— groups appear only after the turn finalizes. That’s whypersist()doesn’t need a separate “on end_turn” callback.
Reloading a thread
Section titled “Reloading a thread”On thread reopen, rebuild the ConversationMessage[] your store remembers
and seed the hook via initialMessages. contentGroupsToConversationMessages
collapses groups sharing the same message.id prefix back into one message:
import { contentGroupsToConversationMessages } from "@ggui-ai/react/chat-helpers";
function useSeededInvoke(threadId: string, endpointUrl: string) { // Resolve before render — useInvoke captures initialMessages on mount. const groups = store.filter((e) => e.threadId === threadId).map((e) => e.group); const seed = contentGroupsToConversationMessages(groups); return useInvoke({ endpointUrl, initialMessages: seed });}initialMessages is a seed on mount. Changing it on re-render does
not reset hook state — intentional; the hook owns the conversation
after mount. To switch threads, unmount the <Chat> subtree (set
key={threadId}) and let the new instance seed from that thread’s store.
Why send({ clientMessageId }) matters
Section titled “Why send({ clientMessageId }) matters”useInvoke accepts an optional clientMessageId the caller controls:
send("hello", { clientMessageId: crypto.randomUUID() });The rendered user message’s id becomes that value, which buys:
- Retry without duplicates. Retrying the same send after a network
failure produces the same
clientMessageId→ sameContentGroup.key→ the outbox is idempotent by construction. - Cross-device continuity. Persist user messages optimistically on the
sending device; when the agent turn later replays on another device, the
same
clientMessageIdcollapses both into one thread entry.
Without clientMessageId, useInvoke falls back to a random user_<hex>
id — fine for ephemeral chats, wrong for durable storage.
What you still have to do
Section titled “What you still have to do”The helpers stop at shape. You still own:
- Transport to storage.
store.push(...)above is an in-memory array for brevity. Replace it withfetch('/persist'), an IndexedDB write, a Firestore batch — whatever fits your stack. - Thread indexing.
ContentGroup.textPreviewis the building block for chat-list tiles; wiring it into a sidebar is yours. - Reconnect + resume.
useInvokedoes not replay an interrupted stream. If the page reloads mid-turn, the in-flight assistant message is lost — only finalized groups persist.useChatThread(next segment up) closes this gap.
When those boundaries start to hurt, reach for useChatThread: same
primitives, plus a MessageStorageAdapter interface, a
ChatThreadProvider, the outbox, seed-on-reopen wiring, and the
optimistic-send UX.
See also
Section titled “See also”- React SDK reference —
useInvoke,useChatThread, and the<AppRenderer>render host - Glossary — gadget, tool, blueprint, render
- Self-hosted registry — point
endpointUrlat your own server