# Persist once, reference by id, hydrate on demand

An agent that reads your uploaded file perfectly and then forgets it on the next turn is not a memory bug. It is a context design where file content was only ever turn-transient. The fix also cuts token cost.

By Elson Tan (https://elsontan.com)
Published: 2026-04-07
Reading time: 4 min
Tags: LLM, Agents, Cost
Canonical: https://elsontan.com/blog/persistent-file-context-for-agents/

> 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.

## 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.

```ts
// 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:

```ts
{
  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](https://docs.anthropic.com/en/docs/build-with-claude/files)
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.

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](https://docs.anthropic.com/en/docs/build-with-claude/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](https://developers.cloudflare.com/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.

## Sources

- [Files API](https://docs.anthropic.com/en/docs/build-with-claude/files), Anthropic documentation
- [Prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching), Anthropic documentation
- [Cloudflare R2](https://developers.cloudflare.com/r2/), Cloudflare
- [Durable Objects SQL storage API](https://developers.cloudflare.com/durable-objects/api/sql-storage/), Cloudflare
