Chat Conversation Persistence Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 26 March 2026
- Status
- DRAFT
Overview¶
ChatClient instances in pkg/chat/ maintain conversation history in memory. When a process crashes, is restarted, or a CLI session ends, the entire conversation context is lost. For tools that use multi-turn AI conversations (debugging assistants, interactive code reviewers, operational runbooks), this means users must re-establish context from scratch after any interruption.
This specification adds a PersistentChatClient interface that enables serialization and deserialization of conversation state. A provider-agnostic snapshot format captures messages, tool configurations, and metadata. Storage is abstracted behind a ConversationStore interface with a filesystem implementation provided out of the box. Sensitive content (API keys, tokens in conversation) can be encrypted at rest.
Design Decisions¶
Separate PersistentChatClient interface: Not all consumers need persistence, and not all providers may support state export equally well. Providers that support persistence implement PersistentChatClient in addition to ChatClient. The type assertion pattern (matching the streaming spec) lets consumers discover capability at runtime.
Provider-agnostic snapshot format: Each provider stores messages in different native formats (Anthropic messages, OpenAI chat completions, Gemini content parts). Rather than forcing a lowest-common-denominator format, the snapshot stores provider-specific message data as opaque JSON alongside common metadata. This preserves full fidelity and avoids lossy conversion.
ConversationStore interface: Storage is abstracted so consumers can use the filesystem, a database, or a remote service. The default implementation uses afero.Fs for filesystem storage, keeping it testable with in-memory filesystems.
Optional encryption: Conversations may contain sensitive information (API keys, proprietary code, PII). An optional EncryptionProvider interface allows consumers to encrypt snapshots at rest. The default filesystem store supports this as an opt-in feature. No encryption is applied by default -- the consumer must explicitly provide a key.
Snapshot, not journaling: The persistence model is snapshot-based (save/load the full conversation state) rather than append-only journaling. Snapshots are simpler to implement, reason about, and recover from corruption. The trade-off is that partial recovery (replaying to a specific point) is not supported in this iteration.
ClaudeLocal excluded: ClaudeLocal uses a subprocess and does not expose its internal message history. It cannot implement PersistentChatClient. This is consistent with the streaming spec's treatment of ClaudeLocal.
Public API Changes¶
New Interface: PersistentChatClient¶
// PersistentChatClient extends ChatClient with conversation persistence.
// Implementations that support persistence implement this interface
// in addition to ChatClient.
type PersistentChatClient interface {
ChatClient
// Save serializes the current conversation state into a Snapshot.
Save() (*Snapshot, error)
// Restore loads conversation state from a Snapshot, replacing the
// current conversation history. The snapshot must have been created
// by the same provider type.
Restore(snapshot *Snapshot) error
}
Snapshot Type¶
// Snapshot represents a serializable conversation state.
type Snapshot struct {
// ID is a unique identifier for this snapshot.
ID string `json:"id"`
// Provider identifies which provider created this snapshot.
Provider Provider `json:"provider"`
// Model is the model that was in use.
Model string `json:"model"`
// SystemPrompt is the system prompt that was configured.
SystemPrompt string `json:"system_prompt"`
// Messages contains the provider-specific message history as JSON.
Messages json.RawMessage `json:"messages"`
// Tools contains the tool definitions that were configured.
Tools []ToolSnapshot `json:"tools,omitempty"`
// Metadata contains arbitrary key-value pairs for consumer use.
Metadata map[string]string `json:"metadata,omitempty"`
// CreatedAt is when the snapshot was created.
CreatedAt time.Time `json:"created_at"`
// Version is the snapshot format version for forward compatibility.
Version int `json:"version"`
}
// ToolSnapshot is a serializable representation of a Tool (excluding the Handler).
type ToolSnapshot struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters *jsonschema.Schema `json:"parameters"`
}
ConversationStore Interface¶
// ConversationStore provides persistence for conversation snapshots.
type ConversationStore interface {
// Save persists a snapshot. If a snapshot with the same ID exists,
// it is overwritten.
Save(ctx context.Context, snapshot *Snapshot) error
// Load retrieves a snapshot by ID.
Load(ctx context.Context, id string) (*Snapshot, error)
// List returns the IDs and metadata of all stored snapshots.
List(ctx context.Context) ([]SnapshotSummary, error)
// Delete removes a snapshot by ID.
Delete(ctx context.Context, id string) error
}
// SnapshotSummary contains metadata about a stored snapshot without
// the full message history.
type SnapshotSummary struct {
ID string `json:"id"`
Provider Provider `json:"provider"`
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
MessageCount int `json:"message_count"`
}
Filesystem Store¶
// NewFileStore creates a ConversationStore backed by the filesystem.
// Snapshots are stored as JSON files in the given directory.
func NewFileStore(fs afero.Fs, dir string, opts ...FileStoreOption) ConversationStore
// FileStoreOption configures the filesystem store.
type FileStoreOption func(*fileStoreConfig)
// WithEncryption enables AES-256-GCM encryption for stored snapshots.
// The key must be exactly 32 bytes.
func WithEncryption(key []byte) FileStoreOption
Usage¶
// Save a conversation
client, _ := chat.New(ctx, props, cfg)
// ... interact with client ...
if pc, ok := client.(chat.PersistentChatClient); ok {
snapshot, err := pc.Save()
if err != nil { /* handle */ }
store := chat.NewFileStore(props.FS, "~/.config/mytool/conversations")
err = store.Save(ctx, snapshot)
}
// Resume a conversation
store := chat.NewFileStore(props.FS, "~/.config/mytool/conversations")
snapshot, err := store.Load(ctx, "conversation-id")
client, _ := chat.New(ctx, props, chat.Config{Provider: snapshot.Provider, Model: snapshot.Model})
if pc, ok := client.(chat.PersistentChatClient); ok {
err = pc.Restore(snapshot)
// Continue the conversation
response, err := client.Chat(ctx, "Where were we?")
}
Internal Implementation¶
Claude Save/Restore¶
func (c *Claude) Save() (*Snapshot, error) {
messagesJSON, err := json.Marshal(c.messages)
if err != nil {
return nil, errors.Wrap(err, "failed to serialize claude messages")
}
return &Snapshot{
ID: uuid.New().String(),
Provider: ProviderClaude,
Model: string(c.model),
SystemPrompt: c.system,
Messages: messagesJSON,
Tools: snapshotTools(c.tools),
CreatedAt: time.Now(),
Version: 1,
}, nil
}
func (c *Claude) Restore(snapshot *Snapshot) error {
if snapshot.Provider != ProviderClaude {
return errors.Newf("snapshot provider mismatch: expected %s, got %s", ProviderClaude, snapshot.Provider)
}
var messages []anthropic.MessageParam
if err := json.Unmarshal(snapshot.Messages, &messages); err != nil {
return errors.Wrap(err, "failed to deserialize claude messages")
}
c.messages = messages
c.system = snapshot.SystemPrompt
return nil
}
OpenAI and Gemini implementations follow the same pattern, marshalling/unmarshalling their native message types.
FileStore Implementation¶
type fileStore struct {
fs afero.Fs
dir string
key []byte // nil means no encryption
}
func (s *fileStore) Save(ctx context.Context, snapshot *Snapshot) error {
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return errors.Wrap(err, "failed to serialize snapshot")
}
if s.key != nil {
data, err = encrypt(s.key, data)
if err != nil {
return errors.Wrap(err, "failed to encrypt snapshot")
}
}
path := filepath.Join(s.dir, snapshot.ID+".json")
return afero.WriteFile(s.fs, path, data, 0o600)
}
func (s *fileStore) Load(ctx context.Context, id string) (*Snapshot, error) {
path := filepath.Join(s.dir, id+".json")
data, err := afero.ReadFile(s.fs, path)
if err != nil {
return nil, errors.Wrap(err, "failed to read snapshot file")
}
if s.key != nil {
data, err = decrypt(s.key, data)
if err != nil {
return nil, errors.Wrap(err, "failed to decrypt snapshot")
}
}
var snapshot Snapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return nil, errors.Wrap(err, "failed to deserialize snapshot")
}
return &snapshot, nil
}
Encryption¶
AES-256-GCM with a random nonce prepended to the ciphertext. The nonce is extracted on decryption.
func encrypt(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
Project Structure¶
pkg/chat/
โโโ client.go <- MODIFIED: PersistentChatClient interface, Snapshot, ToolSnapshot types
โโโ persistence.go <- NEW: ConversationStore, SnapshotSummary, snapshotTools helper
โโโ filestore.go <- NEW: NewFileStore, FileStoreOption, WithEncryption, encrypt/decrypt
โโโ claude.go <- MODIFIED: Save/Restore implementation
โโโ openai.go <- MODIFIED: Save/Restore implementation
โโโ gemini.go <- MODIFIED: Save/Restore implementation
โโโ claude_local.go <- UNCHANGED: does not implement PersistentChatClient
โโโ persistence_test.go <- NEW: persistence interface tests
โโโ filestore_test.go <- NEW: store and encryption tests
Testing Strategy¶
| Test | Scenario |
|---|---|
TestClaude_SaveRestore |
Save state, create new client, restore, verify conversation continues |
TestOpenAI_SaveRestore |
Same for OpenAI |
TestGemini_SaveRestore |
Same for Gemini |
TestRestore_ProviderMismatch |
Restoring a Claude snapshot into an OpenAI client returns error |
TestSnapshot_Serialization |
Snapshot round-trips through JSON marshal/unmarshal |
TestSnapshot_ToolsExcludeHandlers |
ToolSnapshot does not contain Handler function references |
TestFileStore_SaveLoad |
Save then load, verify equality |
TestFileStore_List |
Multiple saves, list returns all summaries |
TestFileStore_Delete |
Save, delete, load returns not-found error |
TestFileStore_Overwrite |
Save with same ID overwrites previous snapshot |
TestFileStore_WithEncryption |
Save encrypted, load decrypted, verify content |
TestFileStore_EncryptionKeyMismatch |
Load with wrong key returns error |
TestFileStore_DirectoryCreation |
Store creates directory if it does not exist |
TestFileStore_Permissions |
Snapshot files are created with 0600 permissions |
TestClaudeLocal_NotPersistent |
Type assertion for PersistentChatClient fails |
TestSnapshot_Version |
Version field is set to 1 |
Test Helpers¶
All filesystem tests use afero.NewMemMapFs() for isolation.
Integration Tests¶
- File store round-trip on real filesystem: Use a temp directory (not afero MemMapFs), save a snapshot via
FileStore, load it back, and verify byte-level equality. Confirms OS-level file permissions, encoding, and path handling. - Encrypted round-trip: Save an encrypted snapshot to a real file, read the raw bytes and verify they are not plaintext, then load via
FileStorewith the correct key and verify content matches. - Multi-provider save/restore: Create snapshots from Claude, OpenAI, and Gemini clients, save all to a single
FileStore, list and verify summaries, then restore each and confirm provider metadata matches. - Corrupted file handling: Write garbage bytes to a snapshot file path, attempt to load, and verify a descriptive error is returned without panic.
- Gate with
testutil.SkipIfNotIntegration(t, "chat")in a dedicatedpersistence_integration_test.gofile.
E2E BDD Tests (Godog) โ Not applicable¶
Conversation persistence is a library-level feature with no CLI surface. Serialisation, encryption, and store operations are best verified through the unit tests and integration tests above. See the suitability assessment for guidance on when Godog adds value.
Coverage¶
- Target: 90%+ for
pkg/chat/including persistence paths.
Linting¶
golangci-lint run --fixmust pass.- No new
nolintdirectives.
Documentation¶
- Godoc for
PersistentChatClient,Snapshot,ConversationStore,NewFileStore, and all options. - Document the type assertion pattern for discovering persistence support.
- Update
docs/components/chat.mdwith persistence usage examples. - Document encryption considerations and key management guidance.
Backwards Compatibility¶
- No breaking changes.
ChatClientinterface is unchanged. Persistence is opt-in via type assertion. - Providers that implement
PersistentChatClientstill satisfyChatClient. ClaudeLocalis explicitly excluded -- documented, not a limitation.- The
Snapshot.Versionfield enables future format evolution without breaking existing stored snapshots.
Future Considerations¶
- Snapshot migration: When the snapshot format version changes, provide automatic migration from older versions.
- Conversation branching: Save at a point, restore, and diverge -- creating conversation branches for exploring different approaches.
- Cloud storage backends: Implement
ConversationStorefor S3, GCS, or other cloud storage for team-shared conversation history. - Automatic checkpointing: Periodically auto-save conversation state at configurable intervals as a crash recovery mechanism.
- Message-level encryption: Encrypt individual messages rather than the entire snapshot, allowing metadata queries on encrypted stores.
- Token counting in summaries: Include approximate token count in
SnapshotSummaryto help consumers estimate context window usage before restoring.
Implementation Phases¶
Phase 1 -- Interface and Types¶
- Define
PersistentChatClientinterface - Define
SnapshotandToolSnapshottypes - Define
ConversationStoreandSnapshotSummary - Add
snapshotToolshelper to convert[]Toolto[]ToolSnapshot - Add compile-time checks for provider implementations
Phase 2 -- Provider Implementations¶
- Implement
Save/Restorefor Claude - Implement
Save/Restorefor OpenAI - Implement
Save/Restorefor Gemini - Add provider mismatch validation in
Restore
Phase 3 -- FileStore¶
- Implement
fileStorewithSave,Load,List,Delete - Implement AES-256-GCM encryption/decryption
- Implement
WithEncryptionoption - Ensure directory creation and 0600 file permissions
Phase 4 -- Tests¶
- Unit tests for each provider's Save/Restore
- Unit tests for FileStore operations
- Encryption round-trip tests
- Error case tests (mismatch, corruption, missing files)
- Run with race detector
Verification¶
go build ./...
go test -race ./pkg/chat/...
go test ./...
golangci-lint run --fix
# Verify interface exists
grep -n 'PersistentChatClient' pkg/chat/client.go
# Verify provider implementations
grep -n 'func.*Save\|func.*Restore' pkg/chat/claude.go pkg/chat/openai.go pkg/chat/gemini.go
# Verify store
grep -n 'ConversationStore' pkg/chat/persistence.go