---
title: Chat with Your Own Storage
description: Build a ggui-powered chat UI on top of your existing persistence layer using @ggui-ai/react and the pure helpers in @ggui-ai/react/chat-helpers.
---

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.

:::note[Which chat surface is this?]
ggui has two consumer chat paths. This recipe is the **invoke-protocol** path: `useInvoke` streams from your agent's `/invoke` endpoint and accumulates `ConversationMessage[]` you persist yourself. It's the right layer when you want full control of message storage. The other path — `useMcpAppsChat` + `<AppRenderer>`, used by the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample — hosts MCP-Apps renders as sandboxed iframes and rehydrates from a server snapshot rather than client storage. Pick this one when the storage is yours; pick that one when the server is authoritative.
:::

## 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

The file below is the complete working integration — streaming, persistence,
card rendering, send — in ~60 lines.

```tsx
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>
  );
}
```

:::tip
Point `endpointUrl` at your own self-hosted ggui server (see
[self-hosted-registry](/sdk/self-hosted-registry/)) — or, on the hosted
platform (coming soon), at `https://mcp.ggui.ai`.
The imports and shapes below are verified against `@ggui-ai/react` 0.3.0.
:::

## The `ContentGroup` contract

`invokeMessageToContentGroups(message)` splits a finalized invoke message
into `ContentGroup`s — the durable unit you persist:

```ts
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:

1. **Idempotency.** Re-persisting the same group with the same key is a
   no-op. If your storage uses `key` as primary key (recommended), the
   `persist()` loop above can run after every token delta without
   producing duplicates.
2. **Streaming messages are excluded.** A message whose `isStreaming` is
   `true` returns `[]` — groups appear only after the turn finalizes.
   That's why `persist()` doesn't need a separate "on end_turn" callback.

## 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:

```tsx
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

`useInvoke` accepts an optional `clientMessageId` the caller controls:

```tsx
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` → same `ContentGroup.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 `clientMessageId` collapses 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

The helpers stop at _shape_. You still own:

- **Transport to storage.** `store.push(...)` above is an in-memory array
  for brevity. Replace it with `fetch('/persist')`, an IndexedDB write, a
  Firestore batch — whatever fits your stack.
- **Thread indexing.** `ContentGroup.textPreview` is the building block
  for chat-list tiles; wiring it into a sidebar is yours.
- **Reconnect + resume.** `useInvoke` does 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

- [React SDK reference](/sdk/react/) — `useInvoke`, `useChatThread`, and the `<AppRenderer>` render host
- [Glossary](/glossary/) — gadget, tool, blueprint, render
- [Self-hosted registry](/sdk/self-hosted-registry/) — point
  `endpointUrl` at your own server