A comment is a conversation that waits for you

Every REX comment is its own thread with its own agent session, stored on the document rather than in a chat window. What is stored and what is derived, how four SDKs resume a conversation four different ways, and the resume that was silently broken until a debug report asked the filesystem instead of the SDK.

You had a good conversation with a model about the fourth paragraph of the pricing document. It found a contradiction, you argued, it conceded, you agreed on a wording. That was Tuesday.

It is now the following Wednesday. The conversation is in a chat history under a title the model invented, and the document has moved on since. Which chat was it? Which paragraph — the one that is now the fifth? The chat has no idea where it took place, and the document has no idea it happened.

In REX the conversation is the comment. It is written on a place in the document — or several places, across several documents. It is one thread with one agent session. Close REX, come back next week, and it is exactly where you left it: on the paragraph, with the answer, ready for the next sentence. This post is about what that takes.

What is actually stored

Everything lives in ~/.rex/rex.db, a SQLite file that sits outside every repository — in the first spec’s words, “so it can never be committed by accident”. Three tables carry a conversation:

Table One row per What it holds
thread comment status, the note you typed, an optional name, its group
thread_target place which document, the anchor, whether it still resolves, which message added it
message turn role, kind, text, tool call, model, cost

thread_target carrying its own document_id is what lets one comment span documents. message is one row per turn, and the spec forbids the obvious shortcut in a sentence I still agree with: “Never a JSON blob per thread — a blob gives you the worst of both a database and a file.”

Every turn is written as the stream arrives, and that ordering settles who owns the conversation:

The database is the record of the conversation. The SDK session is only a resume optimisation.

The SDK’s transcript is a cache. REX’s rows are the record. Everything else in this post follows from that line.

Stored, or derived

The more interesting decisions were about what not to store.

Which version a comment is about is derived, never stored. REX can show a document beside the agent’s proposed version, and a comment can sit on either. An earlier draft put a version field on the anchor and had every surface stamp it. The rule that replaced it: “Where it resolves is what it is about.” An anchor that resolves in both panes is about the document; one that resolves only in the new pane is about the change. No field, no migration, and one class of bug — a stored version disagreeing with where the text actually turned out to be — cannot exist.

Whether a comment is a note was stored, on purpose. A note is a comment you write for yourself and never send. It could have been derived — no answer, no session — and the spec says why that is wrong: “an ASK that failed has no answer either, and the two must not look alike.” One command depends on the difference: “Ask all” skips notes. Without the flag, a fan-out would send every comment you had deliberately kept back.

Next week

A follow-up needs the agent to remember. The Claude Agent SDK keeps its own transcript at ~/.claude/projects/<sanitised-cwd>/<sessionId>.jsonl, and the spec is blunt about that directory: “That directory is a cache and gets cleaned. REX threads live for weeks.”

The adapter REX was ported from — VEX’s — logged a warning when the file was missing and started a blank session, silently losing the conversation. REX’s rule from day one: if the file exists, resume it; if not, render the thread’s own message rows and open a fresh session whose first prompt carries them:

export function replayPrompt(transcript: string, message: string, header: readonly string[] = []): string {
  const tags = tagsFor(transcript);
  const top = header.length > 0 ? `${header.join("\n")}\n\n` : "";
  return [
    `${top}This conversation continues an earlier discussion. Here is the transcript so far:`,
    "",
    ...tags.block("discussion", transcript),
    "",
    "The user now asks:",
    "",
    message,
  ].join("\n");
}

The replay is built from REX’s rows, never from an SDK’s transcript, and it drops thinking, tool results and completion markers as noise. You keep the thread. Only the cache was lost.

Four SDKs, four ways to remember

Then REX grew three more agents and a gateway you can point at a different model mid-conversation, and “the session” stopped being one thing. The rule became one session per (thread, SDK, gateway):

CREATE TABLE IF NOT EXISTS thread_session (
  thread_id   TEXT NOT NULL REFERENCES thread(id) ON DELETE CASCADE,
  sdk         TEXT NOT NULL,
  gateway_id  TEXT NOT NULL REFERENCES agent_gateway(id) ON DELETE CASCADE,
  base_url    TEXT,
  session_id  TEXT NOT NULL,
  created_at  TEXT NOT NULL,
  PRIMARY KEY (thread_id, sdk, gateway_id)
);

base_url is on the row and not merely on the gateway, and that is the detail I would have got wrong without the spec forcing the question. Edit a gateway’s host and a stored session id still looks valid — but resuming it “would ask a different server to continue state it has never seen.” A mismatch takes the replay path, exactly like a cleaned cache.

Whether a new combination gets the conversation so far was a decision, not a default: I chose to seed it, so switching models never loses the thread. The price is one full replay per new combination — minutes on a local model — and the composer says so, once: “LiteLLM has not seen this comment yet. It will be given the conversation so far.”

And the four SDKs do not agree on what a session is:

SDK Given REX’s session id Resumes by Survives
Claude Agent SDK accepts it, deterministic from the thread id its transcript file until the cache is cleaned
Codex discards it; thread_start names the thread thread_resume proven from a second process
OpenCode discards it; POST /session names it GET /session/{id} a server restart and a service restart, via XDG_DATA_HOME
Deep Agents n/a — supports_resume is false it does not; every send replays one run

The Deep Agents section of its spec is titled “Sessions: none, honestly.” Its checkpointer is an InMemorySaver that lives for one run, because a durable one would be a second place REX’s conversation lives, and the design is that there is exactly one. So every Deep Agents send pays the replay, visibly.

The resume that was quietly broken

Between the first build on 2026-08-21 and the first debug report two days later, resume did not work on my machine, and nothing said so.

The debug button — it copies a thread’s identifiers to the clipboard for a bug report — reports the session twice, on purpose: sdk store is what the SDK’s own record says, sdk log is what the filesystem says. The first report it ever produced found two faults at once:

  1. sessionFilePath hardcoded ~/.claude. CLAUDE_CONFIG_DIR overrides that, this machine sets one, and the SDK honours it. REX named a file that did not exist while the SDK wrote the transcript somewhere else.
  2. sessionExists then answered true from a stale file left by a REX launched with a different config directory. A reply resumed a session the SDK had never heard of, and the turn died with No conversation found with session ID.

The two facts had been collapsed into one resumable: yes, and that is what hid both. Now they are separate lines, because each way they disagree is a different bug:

sdk store sdk log Meaning
has it present the normal case
has it missing the store moved — REX is looking in the wrong config directory
no record present the SDK will refuse; the file is a leftover from another config directory
no record missing the cache was cleaned; replay, as designed

When the lookup later moved into the Python agent library, the boolean did not survive the move: session_exists became session_state, because “a method named _exists that returns a struct is a lie about itself.”

def config_dir() -> Path:
    override = os.environ.get("CLAUDE_CONFIG_DIR")
    return Path(override) if override else Path.home() / ".claude"

Two lines. Two days of a feature that reported itself working.

A comment can grow

The original design fixed a comment’s places at creation. That held until I described this to the agent on 2026-08-31:

If I do not agree with the answer, I want to point to another section in a different document and say something like: “Look, here it is written exactly the opposite of what you are saying. Read it.”

What happened instead: with comment 4’s card open, I dragged over the contradicting sentence in components.md, the sidebar switched to the selection panel, the card was gone, and the panel offered one thing — Ask about 1, which creates comment 5. Pasting the sentence into the reply is not an answer, and the spec’s reason is the thesis of this series: “The agent then gets text with no address: it cannot open the file the sentence is in, REX draws no highlight, the card’s place list does not mention the document, and a week later nothing says where ‘here’ was.”

So places now join a comment with any message. Each thread_target row records the message_id that added it, and that turn shows the new places as chips under the text — the head says the comment is about five places, the turns say which message brought each. The agent is told only the new passages; on a resumed session it remembers the first three, and listing them again would bury the two that matter.

Version 1.0 of that spec had a hole in its own premise: it said “while a card is on screen” and never asked how you reach the other document with the card still there. You could not — opening a document closed the card. The first live run found it at step 2.

Reading it back

A conversation you can continue is only useful if you can find it. The list is workspace-wide, and a comment is in exactly one of five lanes:

Lane Meaning
draft has places, never sent, meant to be
note text on a place, for you alone
open sent, waiting on you
gone the text it was written on no longer exists
resolved dealt with; terminal

Draft exists because I picked three places, was called away, and came back to a re-mounted sidebar that had forgotten them. “A half-built comment that survives is a comment” — and once it is a comment it needs a lane. Resolved is terminal: a resolved comment whose text is later removed never enters the gone lane. “An answered question cannot be lost. Flagging one is noise, and noise in the lane that exists to catch real losses is what makes the lane worth ignoring.”

Comments can be named, ordered and grouped — “the order is the clock, and the clock is not the agenda” — and rex export writes a document’s threads to Markdown or JSON, which is how a review reaches a pull request.

What it does not survive

Rename a file outside REX and its comments are stranded. document is keyed by path, so docs/api.md renamed to docs/API.md in Finder becomes a row nothing points at. “They are worse than deleted — they are in the database, invisible, attached to a name that no longer exists.” That is why rename and delete moved into REX’s file tree: the disk act and the database act are one transaction, and delete goes to the macOS Bin and keeps every comment.

Deep Agents pays the replay on every send. On a local model, where the spec measured a real agent turn at 5 to 15 minutes before the first token, a replayed transcript on top of that is a coffee.

A draft forgets its mode and model. Deliberately — message.mode records what a message was, and what the next send will be belongs to no row — but a reopened draft offers the defaults.

The lesson, second time round

In VEX I wrote about the field that was never populated: a session_id declared, typed and never assigned, in a system that had encoded an agent runs once into every layer.

REX started from the other end. The first spec’s first rule about agents was that the database is the record and the session is a cache, and every decision above — the replay path, a session table keyed on three things, the two-line debug report — is that rule meeting a new SDK. It was still broken for two days without anyone noticing. But it was broken in one place, with a report that could find it, and that is the difference between a conversation that waits for you and one that only seems to.

Next: the other half of trusting an agent with your document, a question cannot change your file. If you came here first, point, don’t paste is where the series starts.