On this page
- Why saving native provider payloads as the main model fails
- Start with conversation items, not just strings
- Give every item an application-owned identity
- Separate author/role from protocol role names
- Model text as content, not as the entire item
- Content blocks make multimodal input easier
- Store attachment identity separately from provider file identity
- Tool calls deserve first-class records
- Keep tool results semantically portable when possible
- Reasoning must not be treated as ordinary assistant text
- Generation state should be separate from message content
- Keep provider execution metadata attached but optional
- Conversation settings are not messages
- Projects and reusable instructions should remain references where useful
- Editing creates branches, not time travel
- Context is derived from history, not identical to history
- Keep native provider objects only when they have real value
- Schema versioning is mandatory for durable local data
- Export formats should derive from the canonical model
- A practical type sketch
- Portability test
- Checklist
- Where BYOKchat fits
- Further reading
If an AI app supports more than one provider, the conversation database should not be a saved copy of one provider’s messages array.
A provider-neutral message model stores the semantic history your product needs, while provider adapters translate that history into native API requests at generation time.
The distinction is simple:
canonical conversation = product truth
provider request = execution representation
That separation is what makes provider switching, backups, editing, search, tools, and long-term migrations manageable.
Why saving native provider payloads as the main model fails
Suppose version 1 of your app supports only Provider A and stores this directly:
{
"role": "assistant",
"content": "Hello"
}
Later you add Provider B, which represents content as typed blocks, Provider C with reasoning items, and a local server with OpenAI-compatible tool calls.
You now have two bad choices:
- convert every provider into Provider A’s schema, losing native semantics;
- make every screen understand every provider’s raw object types.
Both create long-term coupling.
A better model records what happened in product terms and stores native metadata only where it is needed.
Start with conversation items, not just strings
An AI conversation is more than alternating text.
A useful canonical model may include:
type ConversationItem =
| UserMessage
| AssistantMessage
| ToolCall
| ToolResult
| AttachmentReference
| SystemEvent;
The exact types depend on the product, but treating everything as one String loses important structure.
For example, a tool call has a different lifecycle from assistant prose, and an attachment has a different persistence policy from a text message.
Give every item an application-owned identity
Do not use provider item IDs as the primary identity of your local data.
Prefer:
type ItemID = UUID;
and keep provider identities as scoped metadata:
type ProviderMetadata = {
connectionID: ConnectionID;
nativeResponseID?: String;
nativeItemID?: String;
};
Why?
Because provider IDs can be:
- absent for locally created items;
- invalid after import/restore;
- unusable after provider switching;
- scoped to one account or endpoint;
- temporary execution details rather than durable product identity.
Your conversation should remain readable even if every provider ID stops resolving.
Separate author/role from protocol role names
Providers differ in how they represent instructions and message roles.
Your application can model semantic authorship:
user
assistant
application instruction
project instruction
tool
without assuming every provider accepts those exact labels.
The adapter can later decide whether an application instruction becomes:
- a system/developer field;
- a top-level instruction parameter;
- a provider-native content block;
- part of a constructed prompt for a limited compatible endpoint.
This prevents protocol vocabulary from leaking into the canonical data model.
Model text as content, not as the entire item
An assistant turn may contain several kinds of content:
visible text
reasoning summary
tool calls
citations
file references
provider metadata
usage
finish state
A robust model can separate these concerns.
For example:
type AssistantMessage = {
id: ItemID;
visibleContent: [ContentBlock];
reasoning: ReasoningPresentation?;
generation: GenerationRecord;
provider: ProviderMetadata?;
};
This is more future-proof than a single content: String field.
Content blocks make multimodal input easier
A user message may contain:
text
image
file
quoted selection
structured reference
Representing the message as blocks gives the context builder more options:
enum ContentBlock {
case text(String)
case image(AttachmentID)
case file(AttachmentID)
case citation(CitationReference)
}
The provider adapter then decides whether to:
- upload a file and use a provider file ID;
- inline image bytes;
- send extracted text;
- reject an unsupported attachment type;
- route through local retrieval.
The canonical conversation does not need to change when execution strategy changes.
Store attachment identity separately from provider file identity
A local attachment may be represented by multiple remote forms over time.
local attachment ID
├── local file URL / blob
├── extracted text
├── thumbnail
├── Provider A file ID
└── Provider B upload ID
Do not replace your local attachment ID with the first remote file ID you obtain.
Remote IDs are cached execution artifacts scoped to a provider/account and may expire or be deleted.
Tool calls deserve first-class records
A tool-enabled conversation is not accurately represented by assistant text like:
"I called the weather tool."
Persist the actual execution sequence:
assistant tool call
→ tool approval state
→ tool execution
→ tool result
→ assistant continuation
A canonical tool-call record can store:
type ToolCall = {
id: ItemID;
logicalToolID: ToolID;
arguments: JSONValue;
executionState: ToolExecutionState;
providerCallID?: String;
};
The provider call ID matters for native continuation, but the local application ID matters for persistence, approval, retries, and deduplication.
See How AI Tool Calling Works.
Keep tool results semantically portable when possible
A completed tool result might contain:
- structured JSON;
- text;
- a file reference;
- an error;
- metadata/provenance.
Persist the result in an application representation that can be rendered and, when safe, translated into another provider’s context.
Do not store only the exact provider-specific tool-result wrapper.
This helps when a user switches providers after a read-only tool call: the new model can receive the tool evidence even if it cannot reuse the old provider’s native call ID.
Reasoning must not be treated as ordinary assistant text
Reasoning-capable providers may expose very different artifacts:
visible reasoning summary
opaque hidden state
encrypted continuation block
native reasoning item
usage only
A provider-neutral model should distinguish at least:
user-visible reasoning presentation
provider-scoped continuation state
reasoning usage/metadata
Do not render opaque provider state, and do not assume hidden reasoning can be ported to another model.
If the provider requires an encrypted reasoning item for stateless continuation, store it as scoped execution metadata with the correct connection/model identity.
See Stateful vs Stateless AI Conversations.
Generation state should be separate from message content
An assistant message can exist before it is complete.
Useful states include:
pending
streaming
waiting_for_tool
waiting_for_input
completed
partial
cancelled
failed
This enables crash recovery and accurate UI.
Do not encode generation state by guessing from content length:
text != empty → completed // wrong
A partial response can have substantial text and still be incomplete.
Keep provider execution metadata attached but optional
A generation record can include:
provider connection ID
model ID
provider response ID
request ID
finish reason
input/output usage
TTFT
duration
retry count
These fields improve diagnostics and continuation.
But the conversation must remain valid if some or all are missing—such as after import from Markdown or restoration from an older backup.
Conversation settings are not messages
A chat can have configuration such as:
selected provider connection
selected model
system prompt
reasoning preference
enabled MCP tools
context policy
Store those as conversation configuration, not synthetic hidden messages unless the product intentionally models them as timeline events.
This makes configuration changes easier to version and inspect.
Projects and reusable instructions should remain references where useful
Suppose a chat belongs to a project with reusable instructions and files.
Avoid copying the entire project into every conversation record unless you need an immutable snapshot.
Instead, model the relationship explicitly:
conversation → project ID
project → instructions/files/defaults
At generation time, the context builder resolves the effective configuration.
If historical reproducibility matters, store the applied project revision or generation snapshot as metadata.
Editing creates branches, not time travel
When a user edits an old message and regenerates, the previous assistant output does not necessarily vanish conceptually.
A robust model can represent branches:
turn 1
↓
turn 2 original
├── assistant A
└── edited turn 2
└── assistant B
You do not need a visible tree UI to benefit from revision-aware persistence.
At minimum, derived provider continuation state after the edit point must be invalidated for the new branch.
Context is derived from history, not identical to history
The canonical conversation can contain hundreds of turns.
For one generation, the context manager selects a smaller subset:
required instructions
project context
older summary
recent turns
relevant attachments
relevant tool results
current user message
The provider adapter then serializes that context.
This is the correct layering:
See How to Design Context Management for Long AI Conversations.
Keep native provider objects only when they have real value
Provider-native metadata is useful when needed for:
- response continuation;
- tool call correlation;
- file reuse;
- reasoning state;
- debugging;
- deletion of remote resources.
Store it deliberately and scope it correctly.
Avoid dumping every raw response into the core database “just in case.” Raw payloads can become large, unstable, privacy-sensitive, and tightly coupled to API versions.
Schema versioning is mandatory for durable local data
Your canonical model will evolve.
Store a version:
{
"schema_version": 4,
"conversation": { ... }
}
Migrations should preserve semantic history even when provider APIs have changed.
Backups should also include their own format/version information so an older export can be interpreted later.
Export formats should derive from the canonical model
Once the application owns semantic history, it can produce:
Markdown for humans
JSON for lossless backup
plain text for sharing
provider-specific request context at runtime
Each is a projection of the same source of truth.
If the database itself is one provider’s request format, every other export becomes fragile.
A practical type sketch
type Conversation = {
id: string;
title: string;
items: ConversationItem[];
settings: ConversationSettings;
projectID?: string;
activeBranchID: string;
schemaVersion: number;
};
type ConversationItem = {
id: string;
branchID: string;
createdAt: Date;
kind: "user" | "assistant" | "tool_call" | "tool_result" | "system_event";
content: ContentBlock[];
generation?: GenerationRecord;
providerMetadata?: ProviderMetadata;
};
This is illustrative, not a universal schema. The important property is that product semantics stay above provider protocol details.
Portability test
A simple design test is:
If Provider A disappeared tomorrow, could the app still render, search, export, and understand the conversation?
If the answer is no because your database is mostly opaque Provider A IDs and JSON blobs, the model is not genuinely provider-neutral.
A second test is:
Can the next turn be sent to Provider B without rewriting historical records?
If yes, your boundary is probably healthy.
Checklist
A provider-neutral conversation model should:
- use application-owned IDs;
- keep provider IDs as scoped optional metadata;
- model messages, attachments, tool calls, and tool results explicitly;
- distinguish reasoning presentation from opaque continuation state;
- represent partial/cancelled/failed generation states;
- keep credentials out of conversation data;
- separate durable history from active model context;
- tolerate missing provider metadata after restore/import;
- support schema migrations;
- invalidate derived provider state after edits/branching;
- export from canonical semantics rather than raw provider payloads.
Where BYOKchat fits
A multi-provider local client needs exactly this boundary: conversations remain durable and readable regardless of which provider generated each turn, while provider adapters retain the native IDs and state required for tools, reasoning, streaming, and continuation.
That is what lets one chat move across providers without making the database itself provider-specific.