{"slug": "files-the-model-never-has-to-read", "title": "Files the model never has to read", "summary": "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.", "body_md": "# Files the model never has to read.\n\n**Update, 2026-10-27:** the File tool over HTTP described below as “on main” shipped in **v1.125.0**.\n\n## 1. Where the bytes used to go\n\nWe 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.\n\nUntil v1.122, the bytes of those files had nowhere good to go.\n\n  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.\n\nThat 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.\n\nThe fix was to stop asking it to.\n\n## 2. The inbox and the one-line stub\n\n  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:\n\n```\n[file: volume:inbox:documents/report.pdf · application/pdf · 2.8 KiB · sha256:16d51716c3de… · from mcp:files/get_attachment]\n```\n\nThe 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.\n\nThe model gets a reference to the file. The runtime keeps the file.\n\nA few details make the stub trustworthy:\n\n- **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 own`fileName` ,`filename` ,`name` or`path` argument before falling back to the tool name. A document imported from it is no longer titled \"download-bytes\".\n- **The media type** is the declared one, else sniffed.\n- **Limits** hold: a file over`LOOMCYCLE_FILE_MAX_BYTES` (64 MiB) or a tree over`LOOMCYCLE_FILE_INBOX_MAX_BYTES` (1 GiB) gives`[file not kept: …]` . Either way, the bytes never reach the model.\n- **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` .\n\n  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.\n\n## 3. Base64 in text, by choice\n\nBase64 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.\n\n  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.\n\n## 4. Binary responses from the web\n\n  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.\n\n  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.\n\n## 5. The File tool\n\n`File` is a new tool that works on files without their bytes passing through the model.\n\n- **`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 only`File` ; another volume needs`Read` to look and`Write` to keep, and that tool's hooks judge the final path. A read-only binding stays read-only.\n- **`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.\n- **`upload`** sends a file from any readable volume as one PUT or POST, or by Microsoft Graph's ranged protocol: 320 KiB-aligned`Content-Range` chunks that follow`nextExpectedRanges` . It follows no redirect.\n\n  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.\n\n  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.\n\n`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.\n\n## 6. Who else can reach a run's files?\n\nThe inbox belongs to the run. Often, though, the person who started it needs the file it fetched: the attachment, the chart, the PDF.\n\n  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.\n\n  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.\n\n## 7. Git without a shell\n\n  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.\n\n  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.\n\n- **Read** in any volume mode:`status` ,`log` ,`show` ,`diff` ,`blame` ,`grep` and the rest.\n- **Change** in a read-write volume:`add` ,`commit` ,`checkout` , branches, tags, fast-forward`merge` .\n- **Network:**`clone` ,`fetch` ,`pull` ,`push` ,`ls_remote` ,`submodule_update` . Rebase, cherry-pick and non-fast-forward merges are not available.\n\n  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.\n\nThe model never sees a credential or the server's error text.\n\nNetwork 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.\n\n  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.\n\n  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.\n\nA 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.\n\n  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.", "url": "https://wpnews.pro/news/files-the-model-never-has-to-read", "canonical_source": "https://loomcycle.dev/blog/files-the-model-never-has-to-read.html", "published_at": "2026-10-25 15:00:00+00:00", "updated_at": "2026-10-27 13:15:12.494545+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["Loomcycle", "MCP", "WebFetch"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/files-the-model-never-has-to-read", "markdown": "https://wpnews.pro/news/files-the-model-never-has-to-read.md", "text": "https://wpnews.pro/news/files-the-model-never-has-to-read.txt", "jsonld": "https://wpnews.pro/news/files-the-model-never-has-to-read.jsonld"}}