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:
sessionFilePathhardcoded~/.claude.CLAUDE_CONFIG_DIRoverrides 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.sessionExiststhen answeredtruefrom 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.