How a long document actually gets written
A forty-page report is not one long reply. It is a background job with a state machine — outline, questions, research, section-by-section writing — where every step can be re-run after a crash without duplicating work or losing budget, offsets for edits are computed by code rather than chosen by the model, and every version is kept.
An ordinary answer is one request to a model. A document is not: it runs for minutes to tens of minutes, it has to survive you closing the tab, and it has to stay consistent across forty pages that no single request could hold. So it runs as a background job with a state machine, and almost every design decision below follows from one question — what happens if this dies halfway?
The steps
| Step | What happens | If it dies halfway |
|---|---|---|
| Pick the type | Match the request to one of your document types | Re-run, overwrite |
| Outline | Produce the sections | Rows inserted with conflict-do-nothing |
| Ask | Ask you for what it needs, once, before writing | Waits — the answer and the wake-up are one transaction |
| Research | Gather facts per section, in rounds | Resumes at the next round |
| Write | One section at a time | Claims a section, writes it, moves on |
| Finish | Totals, notice, export available | Idempotent |
The step that best explains the design is research, because it is the one step that genuinely
cannot be made idempotent: run the same round twice and the searches may return different pages and
the extracted sentences may differ. So instead of idempotency it uses two things — facts are unique
per (document, section, fact) after normalisation, so a repeat cannot land twice; and the round
counter is incremented in the same transaction as that round's facts.
The consequence is worth stating plainly: a job that keeps crashing in round 3 resumes at round 4. Without that, every restart would begin again at round 1, and nothing puts an upper bound on how many times a process can restart. A crash loop cannot burn your budget.
Why one section at a time
Each section is claimed by a compare-and-set (ready → writing) and written in one transaction that
stores the text, the version and the counters together. That is what makes "the server restarted
mid-document" a non-event: finished sections are untouched, and the section that was in flight is
re-claimed rather than half-written twice.
It also keeps the request small. A section is written from its own brief, its own facts and a short context — not from the whole document. This matters more than it sounds: we measured tool-calling reliability collapse as material grows, from 7 of 8 calls at a small size to 2 of 8 and then 0 of 8 past roughly 5,000 words. A prompt that contains everything does not produce a better section; it produces a section that recites the material instead of following the brief.
Editing: offsets come from code
Select a passage in the canvas, say what you want changed, and only that passage is replaced. The mechanism is deliberately narrow:
- The client sends a character range —
[start, end). - The server takes
body[start:end]as the fragment to change. - The model receives that fragment plus a bounded context and returns replacement text only.
- The server splices:
body[:start] + replacement + body[end:].
The model is never asked for the range, and never returns a diff. A model asked to produce an edit range does not fail loudly when it is unsure — it returns a range that looks entirely reasonable, and a sentence quietly disappears from your document. The ranges come from the block splitter that rendered the text, so they are exact by construction.
If the section changed underneath you — because the agent was rewriting it at that moment — the edit returns a conflict rather than overwriting. You decide which version survives; that decision is yours to make, and the alternative is finding out days later.
Facts, and what happens to a number without one
Sections are written from a fact table, not from the model's memory. Each fact is stored with the source it came from, so any figure in the finished document traces back to a page or passage.
After a rewrite, the result is scanned by code: figures, codes and proper nouns that do not appear in the cited material are flagged. If any are found the passage is regenerated once; if they are still there, the passage comes back marked and not automatically applied. Notably, this check is not done by asking the model to check itself — asked whether a passage is supported, a model will say yes while looking at material that contradicts it.
Versions
Every change to a section is a version: the first draft, your manual edits, each requested rewrite, each restore. Restoring an old version adds a version rather than deleting anything, so the history stays a record of what actually happened rather than a record of what survived.
What appears in the chat
Writing leaves three kinds of message in the conversation, and none of them are written by the model:
- Starting — the title and how many sections, posted when the job actually begins burning tokens, not when the request was made.
- Updated — which sections changed and what happened to them, posted whenever a turn actually modified the document.
- Finished — sections, words, elapsed time.
The numbers in all three are computed from the database. This is not a stylistic preference: a model asked to report what it just did will produce a confident, specific, and occasionally false account — including "I have added that chart to the document" on a turn where the document did not change. Facts about what the system did belong to the system.
Limits worth knowing
- One writing worker runs per database. Jobs are claimed with
FOR UPDATE SKIP LOCKED, so the claiming semantics already tolerate more workers, but the current deployment runs one. - Cancellation has two halves: an in-process one that stops the current step quickly, and a persisted flag on the job row so a job that outlives the process still stops.
- If your agent has public sources switched off, the research step does not reach the internet at all — it is skipped, not degraded. Permission can only ever narrow.