The estimate was a week. Extract the agent from one framework, drop it into another, keep the behaviour. The tools were clean functions, the prompts were strings in a file, the orchestration was maybe three hundred lines. A week was generous.
Then someone asked what happened to the conversations. Not the code — the eighty thousand stored threads, with their message histories, tool results, user preferences the agent had accumulated, and a few hundred runs that were paused mid-flight waiting on a human approval. All of it lived inside the framework's checkpoint tables, in the framework's serialization format, keyed by the framework's identifiers. The week became a quarter, and most of it was not agent work at all. It was a data migration with a product owner attached, negotiating which history we could afford to lose.
That's the lesson this article is about, and it generalizes past any particular framework: your agent's state is data, and data outlives code. October's framework churn made the code side of this vivid — two major frameworks changed status in three weeks, which I went through in the framework comparison — but the expensive half was never the SDK. I've covered what agent memory is and why the context window isn't it in the memory piece; this is the narrower engineering question of who owns the bytes.
Four kinds of state, three different owners
The first mistake is treating "agent state" as one thing. It's at least four, they have different lifetimes and different value, and conflating them is what puts all of it inside a checkpoint table.
| Kind | Lifetime | Who should own it | What happens if you lose it |
|---|---|---|---|
| Conversation history — what was said | As long as the user expects to see it | You. This is product data. | A support ticket, or a regulator asking why you can't produce it |
| Durable facts — preferences, entities learned, decisions taken | Indefinite | You. This is the asset the agent accumulates. | The agent gets amnesia and users notice immediately |
| Run position — which node, which step, waiting on what | Minutes to days (approvals) | Framework, with your own shadow record | In-flight work is abandoned; if it had side effects, worse than abandoned |
| Scratch — intermediate reasoning, retry counters, tool dumps | The run | Framework. Genuinely disposable. | Nothing. Let the framework keep it. |
Read that table as a rule: the top two rows go in your database, in a schema you designed, and the framework gets a copy — not the original. The third row is shared custody, because the framework needs its own representation to resume but you need enough to know what is outstanding and to reconstruct it if you must. The fourth is the framework's business and you should not care.
What goes wrong in practice is that a checkpointer makes it so convenient to persist everything at once that all four rows end up in one opaque blob. It works beautifully until the day you need any of it outside that framework — for an audit query, a data export, a migration, or a second consumer.
graph TB
subgraph Y["Your durable store — your schema"]
CONV[("conversations
+ messages")]
FACTS[("agent_facts
durable memory")]
RUNS[("agent_runs
status + outstanding approvals")]
end
subgraph F["Framework's store"]
CKPT[("checkpoints
node position + scratch")]
end
APP["Agent application"] -->|"append message, upsert fact"| CONV
APP --> FACTS
APP -->|"open / close run"| RUNS
APP -->|"resume by thread id"| CKPT
CKPT -.->|"disposable: rebuildable
from the left-hand side"| APP
AUDIT["Audit · export · analytics · a second app"] --> CONV
AUDIT --> FACTS
AUDIT --> RUNS
The asymmetry that makes migration cheap: everything on the right can be thrown away and rebuilt from the left, but nothing on the left depends on the right. Note who reads your store — audit, export, analytics, and the next application — all of which are use cases a framework's checkpoint format will never serve, and all of which arrive whether you planned for them or not.
A schema worth owning
Nothing exotic. The point isn't cleverness, it's that these tables are yours and they'll still make sense when the framework you started on is a footnote.
-- Conversation history: product data. Append-only, queryable, exportable.
create table conversations (
id uuid primary key,
tenant_id text not null,
user_id text not null,
agent_slug text not null, -- which agent, for per-agent analytics
created_at timestamptz not null default now(),
closed_at timestamptz
);
create table messages (
id bigserial primary key,
conversation_id uuid not null references conversations(id),
seq int not null, -- ordering that does not rely on a clock
role text not null, -- user | assistant | tool | system
content text, -- what a human would read
tool_name text, -- null unless role = 'tool'
tool_args jsonb,
tool_result jsonb, -- trimmed: what the model was given
model text, -- pinned version, for reproducibility
in_tokens int,
out_tokens int,
created_at timestamptz not null default now(),
unique (conversation_id, seq)
);
-- Durable memory: the asset. Typed keys beat a free-text pile, because you
-- will need to expire, correct, and explain individual facts.
create table agent_facts (
id bigserial primary key,
tenant_id text not null,
subject text not null, -- 'user:8814' | 'account:ACME'
key text not null, -- 'preferred_channel' | 'timezone'
value jsonb not null,
confidence real,
source_message_id bigint references messages(id), -- provenance, always
valid_from timestamptz not null default now(),
valid_to timestamptz, -- soft-expire instead of deleting
unique (tenant_id, subject, key, valid_from)
);
-- Run position: your shadow of the framework's checkpoint. Small on purpose.
create table agent_runs (
id uuid primary key,
conversation_id uuid references conversations(id),
framework text not null, -- yes, record this. Migrations need it.
thread_key text not null, -- the framework's resume handle
status text not null, -- running | awaiting_human | done | failed
awaiting jsonb, -- what approval, requested when, by whom
step_count int not null default 0,
usd_cost numeric(12,6),
started_at timestamptz not null default now(),
ended_at timestamptz
);
Three details in there earn their keep repeatedly. Provenance on every fact (source_message_id) is what lets you answer "why does the agent think that?" — a question you will be asked, sometimes by someone with authority. Soft expiry (valid_to) rather than deletion, because agent memory is frequently wrong and correcting it is a normal operation, not an exception. And recording the framework on the run: it feels redundant right up until you're running two frameworks side by side during a migration and need to know which runs can be resumed by which process.
What a checkpointer is actually for
Not persistence. Persistence is what your database does. A checkpointer exists so a framework can resume its own execution — restore the graph position, the pending tool call, the in-flight scratch — after a crash or an approval wait. That's a genuinely valuable and hard thing, and it's the reason durable orchestration is worth having. It is not a system of record.
Which gives a clean rule: the checkpoint store should be rebuildable-or-droppable. If you lost it entirely, you should be able to replay a conversation's messages from your own tables into a fresh run and continue — losing the position of in-flight runs, which is why you keep agent_runs to know what those were. Design for that and a framework migration becomes: stop accepting new work, let in-flight runs drain, point the new framework at the same conversation tables, resume. Design for the opposite and it becomes a bespoke ETL between two undocumented serialization formats.
Two operational notes worth having in advance. Checkpoint tables grow — one row per step per run, containing the accumulated state, which is exactly the quadratic growth pattern that makes agents expensive in tokens, now in bytes. Set a retention policy on day one; nobody has ever regretted expiring last quarter's scratch. And the same reasoning applies to what you write into the checkpoint: state that is large, sensitive, or reconstructable belongs in your store with a reference in the checkpoint, not inlined into it. A checkpoint blob with unmasked personal data in it is a compliance problem in an unusual place, where your existing controls aren't looking.
The migration trap, stated plainly. Frameworks disagree about what state is. One models a conversation as a message list, another as a graph state object with reducers, a third as a session with typed slots. There is no mechanical translation between those views, because they encode different assumptions about what matters. So a migration that starts from the framework's format is a bespoke, one-way, semantically-lossy conversion — and you discover the losses in production, in the form of an agent that has forgotten something a user remembers. Starting from your canonical tables instead makes it a projection: build the new framework's initial state from your messages and facts, which you control the meaning of. I have done it both ways. The first cost a quarter and a difficult conversation about which history we could afford to lose. The second cost a sprint, and the interesting part of the work was actually the agent.
The seams, and how many you need
Three, and they're all small:
- A history port —
load(conversation_id) → messagesandappend(message). Your code calls this; the framework never touches your tables directly. When you switch frameworks you write a new adapter that shapes those messages into whatever the new one wants. - A memory port —
recall(subject, keys) → factsandremember(fact, provenance). Deliberately not "the framework's memory feature," because durable facts are the asset and the framework's memory abstraction is a convenience. - A run port —
open_run,mark_awaiting,close_run. Thin, and the thing that makes "what is outstanding?" a SQL query rather than an inspection of framework internals.
Tools sit outside all of this as plain functions, exposed over MCP where it helps, which is the other half of the portability story. Between framework-free tools and a state schema you own, what remains framework-specific is the orchestration graph itself — a few hundred lines, genuinely rewritable in a week, exactly as the original estimate assumed.
What to carry away
Separate the four kinds of state and put the two valuable ones — conversation history and durable facts — in your own database with your own schema, provenance on every fact and soft expiry instead of deletion. Keep a small shadow record of every run so "what is in flight and what is waiting on a human" is a query you can answer without reading framework internals.
Treat the checkpointer as what it is: a mechanism for a framework to resume itself, whose store should be droppable and rebuildable from your tables. Give it references rather than inlined sensitive data, and put a retention policy on it before it grows into a bill of its own. Then keep three thin ports — history, memory, runs — and let the framework be the replaceable component it is.
The test is one question, and it's worth asking in a design review while it's still cheap to answer: if we had to change frameworks next quarter, what would we lose? If the answer is "the orchestration code, which is a sprint," you're fine. If the answer involves the word "conversations," you have a data project ahead of you that nobody has scheduled.