Agent state you own: memory that survives a framework change

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.

KindLifetimeWho should own itWhat happens if you lose it
Conversation history — what was saidAs long as the user expects to see itYou. This is product data.A support ticket, or a regulator asking why you can't produce it
Durable facts — preferences, entities learned, decisions takenIndefiniteYou. This is the asset the agent accumulates.The agent gets amnesia and users notice immediately
Run position — which node, which step, waiting on whatMinutes to days (approvals)Framework, with your own shadow recordIn-flight work is abandoned; if it had side effects, worse than abandoned
Scratch — intermediate reasoning, retry counters, tool dumpsThe runFramework. 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 portload(conversation_id) → messages and append(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 portrecall(subject, keys) → facts and remember(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 portopen_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.