Engineering NotesStructured data and NL2SQL
Query results go to a file so the model never retypes them

When a user says "Give me the result you just queried as a CSV file," the model has to write the rows it already received back out as arguments to a file-writing tool. Tool arguments are model output, so every cell of the data comes out a second time as output tokens. Observed in production, copying a 321-row result this way took about 900,000 tokens, while that model's output limit for a single call was 128,000 tokens. Even when the data fits under the limit, the copy is slow and its values drift from the original.
So we took model output out of the data's path. When a tool result exceeds 1,000 characters, the server writes the original to a file in the sandbox before handing anything to the model, and attaches that file's path to the result. We call this a spill. Tools that build files, and the tool that calls other tools on the model's behalf, take that path instead of the data. This post covers how we set the threshold, which tools accept paths, and where path-based work stops and a new SQL query takes over. Shrinking a single result that exceeds the input limit is covered in “Shrink giant tool results before the provider cuts them, and say so”.
Retyped data is slow, comes back changed, and past a certain size never finishes
Retyping is not rare. Over two months in production, 83.3% of the characters that table- and file-building tools received as arguments were written after the SQL execution tool had already returned results in the same turn. A turn is one question and its answer. That came to 4.69 million characters in two months of the model rewriting data it had just received, character by character.
The first problem is time. One playbook run called an external tool 13 times with issue bodies, call stacks and comment text as arguments, totaling 89,337 characters. That step took 20 minutes 50 seconds of a 35-minute-58-second run, while the external server actually worked for 8.9 seconds. Output speed held steady at 75–90 tokens per second throughout, so the rest of the time was the model writing arguments.
The second problem is that values change. A model doesn't copy text; it generates it again. In a call stack a user asked for verbatim, libpthread.so.0 came back as libpthread.so.6. The source file contained .so.0 85 times and .so.6 not once. In the same answer, os_thr_id@entry=232155 lost its @entry=. Both changes look plausible, which makes them hard for a reader to catch.
The third problem is size. At 900,000 tokens for 321 rows, the output can't finish within that model's 128,000-token limit, and the file is created cut off partway through.
Raising the output limit or splitting the write across calls eases the third problem, but the first two remain. Telling the model in the prompt to "copy it exactly" doesn't change the fact that it regenerates the text. So we removed model output from the path the data travels.
Results over 1,000 characters are written to a file by the server first
When a tool finishes, the server checks the result's size before it goes to the model. Over 1,000 characters, the server writes the original to the sandbox's results folder and attaches the file path, format and byte count to the result. For table results, it also attaches the row count it actually wrote and the column names. When the model answers "321 rows in total," that number comes from the rows the server wrote.
Spill files are written by the rules below.
| Item | Rule |
|---|---|
| Table results (columns and rows) | CSV with one header line plus data. This file is itself the export the user wanted |
| Other results | Indented JSON. Single-line JSON can't be read in row ranges |
| MCP tool results | The wrapper around the response is stripped and the data inside follows the rules above |
| File name | The tool call ID. The same call always maps to the same path, so a retry doesn't write twice (idempotent) |
| Size limit | Results over 50MB are not written, since the read tools reject them |
| Retention | Deleted after 7 days by default, by a cleanup job that runs every 6 hours |
The MCP wrapper is stripped because of how MCP servers package data. They often serialize their data to a JSON string and send it inside a text field. Save that response indented as is, and the entire payload becomes one escaped string on a single line. In an isolated-environment measurement, a file written this way had 12 lines, and the model spent 26 rounds failing to find the spot it wanted with line-based read tools.
When a tool call fails, the full error message is written to a file too. The model gets a shortened message plus the file path. Of 26 document-generation script failures we examined, 22 had lost the exception line when the message was cut. The only thing the model has to fix a failure with is the next piece of code it writes, so we made sure that material isn't lost.
The threshold asks whether the model would have to retype the result
The threshold is set by whether the model would have to retype a result to pass it to another tool, not by whether the result is big enough to be worth keeping. A keep-only-large-results rule lands around 20,000 characters, covering 24.6% of results by count and 88.3% by volume. That means fewer files, but in the playbook run above, the text the model typed out for the external tool came from query results of 1,489, 3,454, 8,534 and 17,278 characters. Under a 20,000-character threshold none of them would have a file, and without a path the model has no option but to retype.
The threshold that covers the results the model would retype is 1,000 characters. Of 33,128 tool results over 30 days in production, those of 1,000 characters or more made up 67.6% by count and 99.2% by volume. Going lower would only add files, with 0.8% of the volume left to cover.
The check counts characters. It runs right after tool execution, and there is no reason to call the tokenizer one more time there. The precise token limit is enforced afterward by the mechanism that caps a single result at 50,000 tokens by default. Because the spill threshold is far lower, by the time a result needs shrinking the original is already on disk (“Shrink giant tool results before the provider cuts them, and say so”). The loop attaches file reread tools to every feature that carries at least one tool, so any feature can read these files (“One agent loop for every feature, with the differences in hooks”).
File-building tools take a path instead of the data
The tools that write CSV files and Excel sheets take a spill file path as their source argument instead of row data as arguments. The server reads that file directly and writes the new one. A 200-byte argument stands in for 900,000 tokens, and with no copying step, there are no copying errors. Along with the source, the model can specify three things.
- Which columns, in what order, with human-readable headers
- The sort column and direction
- A row range (start and count)
When the format doesn't need to change, the file copy tool attaches the spill file as is, and the user gets the full original through a download card. The attached copy is kept separately as a conversation output, so the 7-day cleanup doesn't touch it.
Scripts that build Word, PowerPoint and Excel documents work the same way. They don't paste rows into the code; the script reads the spill file when it runs. Long scripts themselves aren't retyped on every call either; they are passed by the path of a file in the sandbox. Retype a long script and the parts you didn't mean to change come back different, and those are where the document breaks.
The tool that draws a table inside an answer accepts up to 2,000 rows per table. Beyond that, its tool description tells the model to show only the rows needed as a table and build a CSV of the full set from the spill file path.
Tools that can't read files get an arguments file
MCP tools can't read sandbox files. To send an issue body to an external server, that body has to be inside the arguments. For this the model uses a delegating call, which calls another tool on its behalf. The model writes only the target tool's name and the path of a JSON file holding the arguments, and the server reads the arguments from the file and calls the target tool.
Not all arguments can live in a file. Splitting the playbook run's arguments field by field, 65% was original text copied from the database and 27% was content the model wrote from its own judgment, such as the reasoning behind a verdict or a ranking of related issues. That judgment exists in no file. So the delegating call uses the file as the body of the arguments and lays the values the model writes itself on top.
A spill file is rarely in the shape the target tool wants, so the usual sequence has two steps. First the Python execution tool reads the spill file and writes the target tool's arguments file; then the delegating call uses that file. When the target tool reads a file directly, the model uses the spill path without any hint. Add one step to build an arguments file, though, and we measured a model retyping 3,002 characters of body text even with the delegating tool and the spill path both in hand. The model doesn't come up with the two steps on its own, so the tool description spells out the sequence.
The loop substitutes the delegating call just before executing the tool. Permission checks and user approval judge the real, expanded arguments, and the approval prompt shows what will actually be sent (“One agent loop for every feature, with the differences in hooks”). The conversation history, on the other hand, keeps the short call the model wrote. Put the expanded arguments in the history and they come back as input in the next round, spending the saved tokens all over again.
Filtering and grouping go back to SQL, not onto files
Limiting the source argument to columns, sorting and row ranges is a deliberate line. Adding conditions and grouping would look convenient, since the job would finish without another SQL call. But at that point we would be rebuilding SQL badly, and the source database already does the same work faster and more accurately with its indexes. So when filtering or aggregation is needed, the model writes SQL again. “RAG can't count or sum rows, so we ask the source database in SQL” covers how we keep the model from reading a received row count as a total.
The Python execution tool sits inside the same line. It runs a script in the sandbox and returns standard output, with only the standard library and office document libraries. There is no pandas or numpy, and no network. It is for light work: combining several spill files or reshaping them into another tool's arguments.
Design choices
A 1,000-character threshold means many files. At 7-day retention, we estimate about 5,200 spill files and 86MB on disk at any time, roughly eight times the count under a 20,000-character threshold. The cleanup job that runs every 6 hours reclaims the space, at the cost that results older than 7 days can no longer be reread. Results the user needs to keep are moved into attachments and stored separately.
Path-based work stops at column selection, sorting and row ranges. Heavy analysis over files has to go back to SQL, and we chose that because aggregation is what the source database does best.
A failed spill never fails the tool call. If the disk is full and the write fails, it only logs a warning. Without a file, the model works from the result it received, and large results fall back to retyping. We judged that better than letting a preservation mechanism make queries fail.
Expanded arguments from delegating calls stay out of the history but are kept in the execution record, and for table-drawing calls they are stored again in the record the screen uses. To keep storage from growing without bound, arguments files have a 1MiB limit. The delegating call solves the model's output limit, not the receiving server's limits, which stay as they are, so we set the cap to comfortably cover sizes no model could type by hand.
If your agent passes tool results on to other tools, start by looking for places where data travels through model output. Where you find one, keep the original as a file at a size far below the threshold that triggers shrinking, and have your file-building tools and external tool calls take that path.
What problem does this solve?
More in Structured data and NL2SQL

Engineering Notes · Structured data and NL2SQL
Wrong-table SQL still runs, so search narrows the candidates first
A business database with thousands of tables often keeps backup and working copies such as ORDMSTBAK and ORDMS…

Engineering Notes · Structured data and NL2SQL
DDL doesn't say what a column means, so we read its values first
The order table's STATUSCODE CHAR(1) holds S, D and C.

Engineering Notes · Structured data and NL2SQL
Revenue differs by team, so questions carry the approved definition
Ask for "last month's revenue" and sales thinks of booked orders, while finance thinks of invoiced amounts net of returns.
