Every agent you've ever used works out of a fixed toolbox. Somebody decided ahead of time what it can read, what it can run and what it can change. That's fine right up until the job needs something that isn't in the box.
Hand an agent a file in a format it has no reader for and watch what happens. It reads raw bytes. It searches for something recognizable. It reads more bytes. It guesses. Request after request, it pokes at the problem with tools that were never built for it, and then it gives up. The model might be perfectly capable of solving it. The toolbox just won't let it.
So 0.5 took the lid off the toolbox. When StateFrame's agent hits a job it has no tool for, it builds one on the spot: a small program written for exactly that job. StateFrame runs it in a sandbox, makes it pass a smoke test, and only then lets the agent use it on the real thing.
I call them mutations, because the agent's toolbox changes shape to fit the task in front of it.
And here's the part I can't stop talking about: the agent that did it wasn't running on a frontier model. It was Qwen3.6-35B-A3B, an open model with about 3 billion active parameters, small enough to fit on many local GPUs. It didn't need to be big. It needed the right tool, and it built it.
#What a mutation is
A mutation unit is a small Python helper with declared JSON inputs and outputs. That's the whole idea. It takes a snapshot of the files it's allowed to see, does one bounded job, and returns a compact result.
The agent can write its own, and it also starts with a small kit of ready-made ones for the jobs that come up all the time:
| Helper | What it does |
|---|---|
structured_peek | Columns, row count and sample values from CSV, TSV, JSON, JSONL, TOML or INI |
file_facts | Size, line count, encoding, line endings and whether a file is binary |
archive_list | List what's inside a ZIP or tar without extracting it, and peek at one text file |
sqlite_peek | A SQLite database's schema and up to twenty rows per table |
snapshot_diff | Compare two snapshots and return what changed |
text_grep_struct | Structured search with line and column numbers |
log_watch | Count patterns in the end of a log and notice if it rotated or got truncated |
html_extract | Fetch a public page and pull out its text, links and tables (needs the web grant) |
So "how big is this CSV and what's in it" becomes one call:
stateframe run --repo ~/code/shop \
--task "Inspect data/events.csv and report its columns and row count." \
--mutations prebuilt --mutation-read 'data/**'That's one call that returns a small answer, so the agent can peek instead of reading the whole file.
#Building the tool it's missing
The kit is the easy part. The real point is what happens when nothing in it fits. Turn the level up to authored and the agent can build a brand-new helper in the middle of a task, for exactly the job in front of it.

Here's the test that made me sit up. I made up a file format: an old billing export, base64 text wrapping a small binary header and one compressed stream of records. Nothing anywhere has a reader for it, because it doesn't exist outside this test. The task was to report how many records of each type it held and what they added up to, and the repo's own test checked the answer. Without mutations, the agent read raw bytes, guessed, and the run failed.
With authored mutations on, the agent opened an authoring session and wrote this:
def handle(input, ctx):
import base64
import struct
import zlib
raw = base64.b64decode(ctx.input_bytes(input['input_id']))
assert raw[:4] == b'SFEV'
version, type_count, record_count = struct.unpack('<HHI', raw[4:12])
body = zlib.decompress(raw[12:])
names = {}
offset = 0
for _ in range(type_count):
code, name = struct.unpack_from('<H12s', body, offset)
names[code] = name.rstrip(b'\0').decode()
offset += 14
totals, counts = {}, {}
for _ in range(record_count):
_id, code, amount, _channel = struct.unpack_from('<IHd8s', body, offset)
offset += 22
name = names[code]
totals[name] = round(totals.get(name, 0.0) + amount, 2)
counts[name] = counts.get(name, 0) + 1
return {'records': record_count, 'count_by_type': counts, 'total_by_type': totals}That's the whole tool. Twenty-two lines: decode, check the header, decompress, walk the type table, walk the records, add them up.
It didn't get there on the first try, and that's my favorite part. The first draft asked for the file by its path instead of the ID StateFrame hands it, so it failed its own smoke test before it ever touched the task. StateFrame sent back the exact error and told it to fix the helper and submit again. The next version passed the smoke test, the agent used it on the real file, and it found 240 records across four types. The repo's test, which had failed before, passed. Ten requests, a little under twelve minutes, start to finish.
In an earlier attempt at the same task, the agent got two drafts refused, each with a precise reason, and then ran out of attempts for that authoring session. That's the budget doing its job. An agent that can't get a helper right doesn't get to keep trying forever.
Both of those were with that same Qwen3.6-35B-A3B, about 3 billion active parameters. Not a frontier model.
#Why I'm comfortable letting a model write code that runs
Because the model never gets to decide what that code is allowed to do. The engine does.
- It runs in a box. On Linux, a mutation runs under bubblewrap with a seccomp filter. It can't start other programs, can't create threads, and doesn't get your installed Python packages.
- No network unless you say so. A helper can't open a connection itself. With the public web grant on, it asks a separate broker process to fetch a page, and that broker checks every address it resolves.
- No writing to your code unless you say so. A helper can't touch the workspace. With a write grant, it can only propose edits, and the engine checks every one against your edit scope and protected files before applying anything. Test files are off limits.
- It sees snapshots, not your repository. At most 16 files, read-only copies, with common credential files and
.gitexcluded. - Hard budgets. Memory, CPU time, wall time, output size, how many authoring attempts it gets and how many times it can revise a helper. They're set by the engine, and the run and chat commands can't raise them.
- Its answers aren't proof. A mutation's result is treated as an untrusted observation. It can help the agent figure things out, but it can never count as a passing test or satisfy a done check. The real checks still decide when the work is finished.
With mutations off, which is the default, StateFrame sends exactly the same request bytes it did before mutations existed, and a test checks that.
- Shipped helpers
- 8
- From CSV peeks to archive listing to web extraction
- Fail to pass
- 10
- Requests for the agent to write a binary reader and get the right answer
- Input files per call
- 16
- Read-only snapshots, 4 MiB each, 16 MiB total
- Default state
- Off
- And off means byte-identical requests
#Why this matters for long runs
This connects back to the whole reason StateFrame exists. The prompt has a fixed budget. Every byte of a file the agent reads to learn one fact is a byte that isn't available for the code it's fixing, the test that's failing or the decision it made ten turns ago.
A mutation turns "read a lot, remember a little" into "compute the little, keep that." It's the same idea as the typed state frame, pushed one step further: let the engine and its tools carry the bookkeeping, and save the model's attention for the actual work.
It also means a weak spot doesn't always need a bigger model. If your agent keeps struggling with a file format, it can write the reader once, and you can keep that helper (stateframe mutations retain) for later.
#What's not there yet
- Linux only. macOS refuses with a clear message for now. It's planned.
- Small, bounded jobs. Mutations are for reading a structured file, comparing snapshots or scraping a page, not for running your whole build. That's what the shell and your checks are for.
- Kept helpers don't load automatically yet. You can keep a helper, but a later run won't pick it up as a ready-made tool on its own.
- A smoke test is just a smoke test. It proves the helper runs on the example it was given, not that it's correct in general. That's exactly why its output never counts as proof, and why the repo's own test had the final say in the story above.
The mutations guide has the full catalog, every grant, every budget and every refusal code. If you get your agent to build something weird and useful with it, please tell me about it on GitHub. I want to see what it makes.
