Skip to content

Persist conversations

By default a conversation's history is lost when a ChatClient is discarded. The persistence API lets you capture conversation state as an immutable snapshot, store it (optionally encrypted), and restore it into a fresh client later — enabling multi-turn conversations across process invocations.

claude, openai, openai-compatible, and gemini all support persistence. claude-local does not (it delegates to an external subprocess with no accessible internal state).

Step 1 — Check provider support

Persistence is discovered via a type assertion, the same pattern as streaming:

client, _ := chat.New(ctx, chat.Settings{Config: cfg})

pc, ok := client.(chat.PersistentChatClient)
if !ok {
    // e.g. ProviderClaudeLocal
    return errors.New("provider does not support persistence")
}

Step 2 — Save a snapshot

After a conversation, capture the current state:

_, _ = client.Chat(ctx, "What is Go's concurrency model?")
_, _ = client.Chat(ctx, "How do channels differ from mutexes?")

snapshot, err := pc.Save()
if err != nil {
    return fmt.Errorf("saving conversation: %w", err)
}
fmt.Println("saved conversation:", snapshot.ID) // a canonical UUID

A Snapshot captures the full message history (in the provider's native, opaque JSON format), the system prompt, tool metadata (name, description, parameter schema), a timestamp, and a format version. It never captures API tokens (security) or tool handler functions (not serialisable).

Step 3 — Store with a FileStore

FileStore implements ConversationStore over an afero.Fs, writing each snapshot as <id>.json with 0600 permissions (the directory is created 0700 if absent). Inject the filesystem, so the same code works against a real disk or an in-memory test FS:

store, err := chat.NewFileStore(fs, filepath.Join(configDir, "conversations"))
if err != nil {
    return err
}

if err := store.Save(ctx, snapshot); err != nil {
    return fmt.Errorf("storing snapshot: %w", err)
}

// List summaries without loading full histories
summaries, _ := store.List(ctx)
for _, s := range summaries {
    fmt.Printf("  %s  %s  %s  (%d messages)\n", s.ID, s.Provider, s.Model, s.MessageCount)
}

loaded, _ := store.Load(ctx, summaries[0].ID)
_ = store.Delete(ctx, summaries[0].ID)

The store operations are Save, Load, List, and Delete. List is deliberately robust: files whose names are not canonical UUIDs are logged at DEBUG (wire a logger with chat.WithLogger) and skipped, so one stray file cannot break enumeration.

Step 4 — Restore into a fresh client

Create a new client for the same provider and restore:

client, _ := chat.New(ctx, chat.Settings{Config: cfg})
pc := client.(chat.PersistentChatClient)

if err := pc.Restore(loaded); err != nil {
    return fmt.Errorf("restoring conversation: %w", err)
}

// Re-register tools — handlers are not serialised
_ = pc.SetTools(myTools)

reply, _ := pc.Chat(ctx, "Can you summarise what we discussed?")

Two rules to remember:

  • Provider must match. Restoring a Claude snapshot into an OpenAI client returns an error — the message format is provider-specific.
  • Re-register tool handlers. The model remembers past tool calls from the history, but handlers must be live via SetTools for any new calls.

Step 5 — Encrypt sensitive conversations

Enable transparent AES-256-GCM encryption with a 32-byte key. Generate one with chat.GenerateEncryptionKey, or supply your own:

key, err := chat.GenerateEncryptionKey() // 32 bytes from crypto/rand
if err != nil {
    return err
}

store, err := chat.NewFileStore(fs, dir, chat.WithEncryption(key))
if err != nil {
    return err // errors if the key is not exactly 32 bytes
}

// Save and Load are unchanged — encryption is transparent
_ = store.Save(ctx, snapshot)
loaded, _ := store.Load(ctx, snapshot.ID)

When encryption is enabled the snapshot JSON is encrypted before it touches disk, a fresh random nonce is prepended per save, the file contents are indistinguishable from random, and loading with the wrong key returns an error.

Key management is yours. The library never stores or manages encryption keys. Use the OS keychain, an env var, or a secrets manager to hold the key securely.

Validate externally-supplied snapshot IDs

If a snapshot identifier arrives from outside your process (a CLI flag, HTTP handler, or queue payload), validate it at the boundary before it reaches the store — see snapshot-storage security:

if err := chat.ValidateSnapshotID(id); err != nil {
    return err // reject before any filesystem work
}

Snapshots built via chat.NewSnapshot always get a fresh canonical UUID, so the validator is transparent for library-produced snapshots.