Skip to main content
loomcycle
§ story · v1.122.0 → v1.124.0

Files the model never has to read.

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:

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.

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.

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.