cd /news/ai-agents/files-the-model-never-has-to-read · home › topics › ai-agents › article
[ARTICLE · art-158753] src=loomcycle.dev ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Files the model never has to read

Loomcycle v1.125.0 shipped a File tool over HTTP and an inbox capture system that keeps files returned by MCP servers and web fetches in an ephemeral `inbox` volume instead of passing their bytes to the model, which now receives a one-line stub with path, media type, size, sha256 hash and originating tool. Capture is opt-in per MCP server via `binary_results: passthrough | capture`, where `capture` keeps base64 strings of at least 4,096 bytes only when they decode cleanly to a recognizable type, and files over `LOOMCYCLE_FILE_MAX_BYTES` (64 MiB) or a tree over `LOOMCYCLE_FILE_INBOX_MAX_BYTES` (1 GiB) are not kept. Every kept file emits a `file_captured` event on SSE, in the transcript, on gRPC and in both adapters, addressing the prior behavior where image, audio and resource blocks were dropped silently and binary HTTP bodies arrived as mangled strings.

by read9 min views2 publishedOct 25, 2026
Files the model never has to read
Image: Loomcycle (auto-discovered)

Update, 2026-10-27: the File tool over HTTP described below as “on main” shipped in v1.125.0.

1. Where the bytes used to go #

We are building a document-based reference system. It is fed by mail attachments and SharePoint files: board papers, scanned pages, spreadsheets, slide decks. An agent reaches them through MCP servers and plain HTTP, reads them, and turns them into documents a person can search.

Until v1.122, the bytes of those files had nowhere good to go.

An MCP tool result can carry image, audio and resource blocks. loomcycle passed text through and dropped the rest without a word, so an image-only result reached the model as an empty string. Some servers return a file as base64 inside a text block instead, and then the model received kilobytes or megabytes of characters it could neither read nor reproduce. It paid for every one of them in tokens. The HTTP tool and WebFetch returned a binary body as a mangled string. Nothing could download a URL into a volume, and nothing could upload a file unless the model re-typed its contents.

That is a lot of effort spent on the one thing a model is worst at: carrying bytes it does not understand from one place to another.

The fix was to stop asking it to.

2. The inbox and the one-line stub #

A file a tool returns is now kept in the run's inbox: an ephemeral volume of the run tree named inbox, made under the operator's dynamic_root volume on the first capture and purged with the tree. The model sees one line instead of the bytes:

[file: volume:inbox:documents/report.pdf · application/pdf · 2.8 KiB · sha256:16d51716c3de… · from mcp:files/get_attachment]

The line carries what the model needs to reason about the file: where it is, what type it is, how large it is, its hash, and which tool produced it. It does not carry the content.

The model gets a reference to the file. The runtime keeps the file.

A few details make the stub trustworthy:

  • Names keep their last segment, transliterated from non-latin and cut to[A-Za-z0-9._-] . A download tool often answers with bytes and no name; since #2161 the capture takes the name from the call's ownfileName ,filename ,name orpath argument before falling back to the tool name. A document imported from it is no longer titled "download-bytes".

  • The media type is the declared one, else sniffed.

  • Limits hold: a file overLOOMCYCLE_FILE_MAX_BYTES (64 MiB) or a tree overLOOMCYCLE_FILE_INBOX_MAX_BYTES (1 GiB) gives[file not kept: …] . Either way, the bytes never reach the model.

  • Every kept file emits a file_captured event on SSE, in the transcript, on gRPC and in both adapters. The Web UI shows it as one line in run cards and the terminal:⇩ ref · type · size · from origin .

    A resource_link becomes one line, resource text is kept, and an unknown block type is named rather than dropped silently. A text-only result is unchanged, byte for byte.

3. Base64 in text, by choice #

Base64 inside a text block is the awkward case. Sometimes a long base64 string is a file. Sometimes it is a cursor, a signature, or an opaque token the agent must pass back unchanged. A runtime that guessed wrong would break the second kind.

So capture is opt-in, per server: binary_results: passthrough | capture on mcp_servers.<name> and on MCPServerDefs. passthrough is the default and changes nothing. Under capture, a base64 string of at least 4,096 bytes is kept only when it decodes cleanly and to something recognisable: a type named by a sibling contentType, mimeType or mediaType field, a sniffed type, an extension on a sibling file name, or readable text. Only that string is replaced by the stub. The rest of the block, valid JSON or not, stays as it was. A cursor stays a cursor.

4. Binary responses from the web #

The HTTP tool and WebFetch now judge a response by its Content-Type, then a sniff, then the URL's extension. A binary body is streamed into the inbox: HTTP returns the status line and headers with the stub, WebFetch the stub alone. Text responses are byte-identical to before, including text mislabelled application/octet-stream. A Content-Disposition filename names the file.

WebFetch also gained assets: true. It fetches the page's images and its links to images, PDFs and Office files into web/<host>/ in the inbox, under the page's host policy, re-checked on every redirect, at most 20 by default. Fetched links are rewritten to their inbox references; the ones it skipped stay absolute and say why. An agent reading a page about a regulation gets the PDF of the regulation next to it, without asking.

5. The File tool #

File is a new tool that works on files without their bytes passing through the model.

  • stat, ls, keep, rm and clear. keep copies a file out of the inbox into a durable volume, never over an existing file unless asked. The inbox needs onlyFile ; another volume needsRead to look andWrite to keep, and that tool's hooks judge the final path. A read-only binding stays read-only.

  • download streams a GET into the inbox or a volume, under the HTTP tool's host policy as narrowed for the run, with the private-address guard, same-host redirects only, and never a step down from https to http. Credential-looking headers are refused. Over the per-file cap, nothing is left behind.

  • upload sends a file from any readable volume as one PUT or POST, or by Microsoft Graph's ranged protocol: 320 KiB-alignedContent-Range chunks that follownextExpectedRanges . It follows no redirect.

    Both network ops need the run's own HTTP grant, and HTTP's hooks judge each one. A hook sees an upload's body as {from, size, sha256}, never the bytes.

    URLs get special care, because a pre-authenticated URL is a credential. Every URL in a result, an error or a record is shown with its query as ?…, and its query values are registered with the redactor for the run tree. Since v1.124, download and upload also refuse a URL holding a character a URL never holds unencoded: a double quote, a space, a backslash, a backtick. A model that copied an upload session's URL out of JSON together with its closing quote used to get 401 invalidSignature from SharePoint, read it as an expired session and give up. Now it is told which character is wrong and where, the URL is never repeated back, and the retry works.

extract and import turn a DOCX, XLSX, PPTX, HTML or PDF file into Markdown and then into a searchable Document. That pipeline, with OCR for scanned pages and transcription for recordings, is the subject of the next post.

6. Who else can reach a run's files? #

The inbox belongs to the run. Often, though, the person who started it needs the file it fetched: the attachment, the chart, the PDF.

The MCP file tool runs the File tool on one run's inbox, named by run_id, from outside the run. It is gated like get_run, and a run the caller may not read answers exactly like a missing one. stat, ls and import are open to any reader of the run. extract, rm, clear and keep are for the run's owner, an admin, and since #2158 a tenant operator acting on its own tenant's runs. keep lands only in a read-write dynamic volume of the caller's own tenant. download and upload are open to nobody from outside: network access stays inside the run, under its grants.

On main, the same twin is now on HTTP. POST /v1/runs/{run_id}/file runs it, and GET /v1/runs/{run_id}/inbox/{path} streams one file. The download is always an attachment, with nosniff and a sandboxing CSP. It is typed only from a list of types a browser cannot run; HTML, SVG and scripts go out as application/octet-stream. The response carries the file's SHA-256 and the producer recorded for it, as long as the bytes still match the record.

7. Git without a shell #

The same reasoning applied to repositories. An agent could do git work only through a shell, with GITHUB_TOKEN in its environment. The model can read that environment. And a real git runs whatever a repository's config names: hooks, core.fsmonitor, filter drivers, credential helpers. Cloning an untrusted repository and running git status in it is enough to execute its code.

The Git tool works on a repository in a bound volume through go-git. It never starts a process and never reads system git config.

  • Read in any volume mode:status ,log ,show ,diff ,blame ,grep and the rest.

  • Change in a read-write volume:add ,commit ,checkout , branches, tags, fast-forwardmerge .

  • Network:clone ,fetch ,pull ,push ,ls_remote ,submodule_update . Rebase, cherry-pick and non-fast-forward merges are not available.

    Network access needs the agent field git_remote ([fetch] or [fetch, push]) and a host on the operator's list. Pushes have their own host list and are off until it is set. The remote URL is checked when it is used, https only, so editing .git/config between the check and the call changes nothing. A credential resolves only when its allowed_urls cover the target, and a push gets a write-scoped GitHub App token from a resolver no read path uses.

The model never sees a credential or the server's error text.

Network failures reach the model as a class; the detail goes to the operator log. Every network op is audited with the credential's reference, never its value.

Commits carry the operator's author and a Loomcycle-Run: trailer, so every commit names the run that made it. commit and tag_create can be signed with an ed25519 key the run's crypto_scopes reach: the Crypto tool signs in git's SSH format, the key never leaves the runtime, and signed_by returns the public key for an allowed-signers file.

The bounds are the ones a hostile repository would test. A tree is walked against a file limit and 1 GiB before anything moves, so a repository that expands one subtree many times cannot fill the disk. Submodules are fetched only from a URL that passes the same checks as any remote, never the one .gitmodules names unchecked. No write lands inside .git, and the walk uses verified directory handles, so a symlink swap cannot move it out of the volume. A failed reset, checkout or merge restores HEAD, the branch and the index.

A model is good at deciding what to do with a file. It is poor at being the file. For a long time agent runtimes asked it to be both: the bytes went into the context, the credential went into the environment, and the model became the pipe everything passed through.

The inbox, File and Git move the pipe back into the runtime. The model holds a name, a type, a size and a hash, and asks for operations by name. The runtime moves the bytes, holds the token, checks the URL and keeps the record of where each file came from. In a reference system built from other people's attachments, that record is what lets anyone trust the answer later.

── more in #ai-agents 4 stories · sorted by recency
── more on @loomcycle 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/files-the-model-neve…] indexed:0 read:9min 2026-10-25 · —