Skip to content
← All writing

Persist once, reference by id, hydrate on demand

6 min read
.md
Cover illustration for Persist once, reference by id, hydrate on demand

TL;DR

Keep uploaded file content out of conversation history. Persist it once to object storage, put a short id and a one-line synopsis in history, and let the model pull the full text back through a tool call when it needs it. Idle turns then cost nothing for large attachments.

A user dropped in three vendor bills and asked for a summary. The agent read them and got it right.

Then they said “good, create the draft bills for those” and the agent told them it could not access the files any more.

Ninety seconds. Same conversation. Same window.

It looked like a memory bug and I spent a while treating it as one. It was not. On the upload turn I had pasted the extracted text straight into the prompt, then thrown it away, leaving a marker like [upload_ref:8f2a...] in the history. The agent could see that marker on every later turn and had absolutely no way to turn it back into a document.

It was not forgetting. It had never been given anything to look at.

Both obvious fixes are wrong

Once you see the bug, two fixes come to mind, and neither works.

Keep the file text in the conversation history, and you pay for those three PDFs on every single turn afterwards. Tens of thousands of tokens, resent when the user asks something completely unrelated.

Drop it after the turn, which is what I had done, and you get the amnesia.

There is no setting that fixes this, because the file is simply in the wrong place.

ApproachLater turns can read the fileCost on a turn that ignores the fileFailure mode
Inline the text in historyYesFull document, every turnContext window fills, bill scales with turn count
Drop it after the turnNoNothingAmnesia, and the marker looks like it should work
Persist, reference, hydrateYes, through a tool callMarker and synopsis onlyNeeds a lifecycle policy and an explicit expiry message

The shape that works

Persist once, reference by id, hydrate on demand.

Full text goes to object storage at upload time, keyed by a stable id. History carries only a short marker and a one-line synopsis, enough for the model to know the file exists and roughly what is in it. When a turn actually needs the content, the model calls a tool and pulls it back.

// At upload: extract once, persist, return a compact handle.
const id = crypto.randomUUID().slice(0, 8)
await env.FILES.put(`${sessionId}/${id}`, extractedText)

this.sql.exec(
  `INSERT INTO session_file (id, session_id, filename, synopsis, bytes, created_at)
   VALUES (?, ?, ?, ?, ?, ?)`,
  id, sessionId, file.name, synopsis, extractedText.length, Date.now()
)

// What history sees, on this turn and every later one:
// [[file:a3f9c210]] invoice-vendor-3.pdf, 2 pages, supplier invoice dated 2026-03-14

The tool the model calls is deliberately boring:

{
  name: 'read_session_file',
  description: 'Read the full text of a file the user uploaded in this session.',
  input_schema: {
    type: 'object',
    properties: {
      file_id: { type: 'string', description: 'The id inside [[file:...]] in the conversation' },
      offset:  { type: 'integer' },
      limit:   { type: 'integer', description: 'Characters to return, default 20000' },
    },
    required: ['file_id'],
  },
}

Offset and limit matter more than they look. Without them a single tool call on a large document puts you straight back into the problem you were solving, except now it happens mid-turn where truncation is harder to see.

This is the same pattern Anthropic’s Files API implements: upload once, reference by id across requests, rather than resending bytes.

What does this actually save?

Turns that do not touch the file pay nothing for it.

That is the whole economic argument, and it is worth being precise about it rather than quoting a percentage. If a conversation runs twelve turns and two of them involve the uploaded document, the inline approach pays for the document twelve times and this approach pays twice, plus about twenty tokens per turn for the marker and synopsis.

Twelve-turn conversation, two turns touch the fileInline in historyPersist and reference
Times the document is sent122
Per-turn overhead when the file is untouchedFull documentAbout 20 tokens
Total document sends over the conversation122
Extra cost of a longer conversationGrows with every turnFlat

Substitute your own document size and the ratio holds. The point is not the multiplier, it is that one column grows with turn count and the other does not.

The synopsis is what makes the model behave. Given a bare id it does not know whether reading is worth a tool call. Given “supplier invoice dated 2026-03-14” it can answer “which of these is from March” without reading anything at all.

Worth pairing with prompt caching, which addresses the other half of the growth curve: the stable system prompt and tool definitions that get resent every turn regardless of what you do with files.

Sub-agents need the id, not the text

The failure I did not anticipate: an orchestrator that delegates to a sub-agent and helpfully pastes the file content into the delegation prompt. That reintroduces the cost, and worse, the sub-agent’s copy can drift from the stored one if the file is ever re-extracted.

The delegation payload carries ids. The sub-agent gets the same read_session_file tool and resolves them itself. One source of truth, and the orchestrator never handles the bytes.

Lifecycle, which is on you

Storage keyed by session grows until something deletes it. I expire session files on a fixed window after last access and let the tool return a clear “this file has expired” rather than an empty string, because an empty string makes the model hallucinate content it thinks should be there.

Object storage with a lifecycle policy is the natural home for this. R2 is what I use, alongside the metadata table in the session’s own Durable Object so the index lives with the conversation it belongs to.

Where each piece ends up:

PieceLives inWhy there
Full extracted textObject storage, keyed by session and idLarge, read rarely, needs a lifecycle policy
Id, filename, synopsis, byte countMetadata table in the session’s Durable ObjectThe index belongs with the conversation it describes
Marker and one-line synopsisConversation historyThe model needs to know the file exists and roughly what it is
Full text, mid-turnThe tool result onlyPresent when needed, never appended to history

Sources

Common questions

Why does an AI agent forget a file the user uploaded earlier?

Usually because the extracted text was pasted into the prompt on the upload turn and then discarded, leaving only a marker in history. The model can see the marker on every later turn and has no way to turn it back into a document. It is not a memory bug; the content was only ever turn-transient.

How do you keep uploaded file content available across turns without paying for it every turn?

Persist the full text once to object storage under a stable id, put only a short marker and a one-line synopsis in conversation history, and give the model a tool that pulls the content back when a turn actually needs it. Turns that do not touch the file cost about 20 tokens for the marker instead of the whole document.

Why does the file tool need offset and limit parameters?

Without them, a single tool call on a large document dumps the entire text back into the turn, which is the problem you were trying to solve. It also moves the failure mid-turn, where truncation is harder to notice than it would have been in the history.

Why does the synopsis in conversation history matter?

Given a bare id the model cannot tell whether reading the file is worth a tool call. Given a line like "supplier invoice dated 2026-03-14" it can answer a question such as which invoice is from March without reading anything at all.

How should sub-agents receive uploaded file content?

By id, never as pasted text. An orchestrator that inlines the content into the delegation prompt reintroduces the cost it was avoiding, and the sub-agent copy can drift from the stored one if the file is re-extracted. Give the sub-agent the same read tool and let it resolve ids itself.

What should the tool return when a session file has expired?

An explicit expiry message, not an empty string. An empty result invites the model to hallucinate content it believes should be there, whereas a clear statement that the file has expired is something it can relay to the user.

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 5 min read

    Metering tokens when the bill is the product

    Billing per message is easy and wrong: one user sends a sentence, another uploads a report. Metering the tokens you actually spend is harder, and the hard parts are idempotency, allowance checks and what to do mid-conversation.

  • 4 min read

    Route cheap models to orchestration, not to the work

    Most agent platforms pick one model and use it everywhere. Splitting model selection by role rather than by task is where the cost curve actually bends.

  • 4 min read

    Reasoning effort is a cost dial, not a quality setting

    Every major provider now exposes a knob for how hard the model thinks. Left at its default it burns tokens on arithmetic. Set per role, it is one of the largest cost reductions available without changing models.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.