Where the humans go when the agents work in VMs
"Published":,"Minutes":8,"Summary":"How a ticket travels from a task board to a coding agent in a throwaway VM and back, inside bb, and why the agents' only path to the repository is a script that takes tests and nothing else."
}For six weeks I have been running a small software factory: coding agents work inside throwaway microVMs, and deterministic code decides whether their result is kept. This post is about the part I rebuilt last week, the path a ticket takes from a task board to a running agent and back, inside bb, the tool I use to manage my agents. By the end you should see why the human steps live in the IDE, why the agents’ only way into the repository is a script that accepts tests and nothing else, and what still breaks.
The factory in one paragraph
A ticket arrives with acceptance tests that already fail. The factory creates a fresh VM, clones the target repository into it, and lets a coding agent work until those tests pass. The agent cannot edit the tests. When it is done, code outside the VM re-runs the suite, checks that the test files are byte-identical to what was handed over, pulls the commits out as a git bundle (the harvest), opens a draft pull request, and destroys the VM. I call one such effort a lane. A person reviews the pull request and merges it, or does not. Models do review the work along the way, but the check that decides whether the work is kept is code, and it runs outside the VM.
What it looked like before
The factory lived in four places. The lifecycle was a Python CLI I ran from a terminal. Progress showed on a dashboard served from its own VM. Notifications came through a personal chat bot. The tickets themselves lived in a client’s issue tracker, which the factory could not read for good reasons I will get to.
Shipping one feature meant a dozen hand actions across those surfaces, and there was no single place where I could see a task, its lane, and what had happened to it. That was fine while I was the only person at the desk. It was not fine at eleven at night from a phone, and it was not something I could hand to anyone else.
What bb is
bb is an open-source tool for running many coding agents at once. It organises work into projects (a repository), threads (one agent conversation) and environments (where a thread runs: a checkout or a worktree, on a local or remote machine). On top of that it has a task board, presets that start a thread on a task with a fixed prompt and permission mode, saved workflows (a JavaScript file that calls agents in sequence and validates what they return), and plugins that add pages to the sidebar.
I was already spending my day in it, so moving the factory’s human steps there was the natural move.
One ticket, start to finish
Take a task with the key TASK-7 on the board.
Sitting in To-do is a signal to a person, not a trigger. Nothing polls the board. A lane costs money and eats into a subscription window, and a board column is too easy to flip, by a stray click or by an agent that decided it was helping. So a lane starts on an explicit human act and never on a timer. That rule predates bb; bb just gave it a button.
The first button is “Factory prep”. It dispatches a saved workflow. A Codex agent reads the task and writes two things: a ticket file the build agent will later execute, and a first draft of failing acceptance tests, in a temporary worktree cut from the integration branch. Then an Opus agent reviews both, read-only, with instructions to refute them: does every requirement have a test that would fail a wrong implementation, are the tests red because the feature is missing rather than because the setup is broken. The Codex agent revises. If the review had a blocking finding, one short re-review checks the correction. Only then does the last step land the branch.
What made this workflow tolerable is that the agents hand each other JSON, and code checks it before the next agent sees it. An earlier version passed prose between prompts, and a reviewer’s “looks fine” is not something a script can verify. This is:
function validateExecutionReport(value, label) {
if (value.noNewBaseRegression !== true
|| !Number.isInteger(value.redFailing)
|| !Number.isInteger(value.redTotal)
|| value.redTotal < 1
|| value.redFailing !== value.redTotal) {
throw new Error(`${label} does not prove unchanged base behavior and a fully red non-empty acceptance scope`);
}
}
If the author cannot report a non-empty set of acceptance tests that all fail, and a base suite with no new regression, the workflow stops there. It does not ask the reviewer to look at tests that are not failing yet.
One check I removed is worth mentioning. The reviewer reports the git status before and after its work, and I first compared the two strings byte for byte to prove it had changed nothing. That failed a review that had, in fact, changed nothing, because the strings are free text. The real enforcement is downstream, in the step that commits, so the comparison went away and the reviewer’s report stayed as evidence rather than as a gate.
The landing step is a script, not an agent. prep-finish is the only command in the whole path that commits and pushes. It refuses any file outside the ticket’s own acceptance directory:
outside = [f for f in touched if not f.startswith(f"{acc}/")]
if outside:
refuse(key, f"prep touched files outside `{acc}/`: ...")
The reason is the trust boundary. The prep agents read ticket text written by other people, on a machine that holds the key to the client’s git host. If a ticket says “also update the deploy config”, an agent may well try. It can try all it wants; its only road to origin is a 350-line script that takes test files under one directory and nothing else.
The same script reads exactly one thing from the task, its key, and never its title or description. Every flag the lane needs is derived from the ticket’s file name by convention:
tickets/TASK-7-TRK-1191-zero-day-cra.md
→ issue TRK-1191
→ acceptance tests/acceptance/TASK-7-TRK-1191-zero-day-cra
→ branch chore/test-TASK-7-TRK-1191-zero-day-cra
So nothing written inside a ticket can steer a launch on the host, because the host never parses the ticket. The build agent reads it later, inside a VM with no credentials.
The second button is “Factory launch”. There is one per roster, which is my word for the table of which model runs which phase (plan, build, review, document). The roster is the one thing a human chooses per launch, so it is the one thing on the button. The script enqueues a single lane and refuses if a lane on that ticket is already running; the queue is the authority on that, not the column the task sits in.
The Factory page shows the lane live. It is a bb plugin reading a SQLite database that the harvest step rebuilds: one row per run with its outcome, a bar of its phases coloured by which agent owned them, tokens spent, and a link to the pull request. Clicking a row opens the events of any phase, tool call by tool call. The plugin only reads that database, and it replaced the dashboard VM on 2026-09-13, which was one fewer thing to keep alive.
A second plugin answers a question the first cannot: what is each lane costing the machine right now. Each microVM is created with the run id as its name, so the monitor can list the sandboxes on the host and attribute CPU and memory to a lane by name alone. The trap there was measuring from inside. Opening a session into a sandbox to read its /proc/stat would keep it alive, because the sandbox daemon stops a VM thirty seconds after its last session disconnects, and a thirty-second poller would pin every idle lane forever. The monitor reads the backing process from the host instead and never enters a VM.
The result lands back on the task. The landing script moves the task to in progress once the tests are pushed; the queue then posts each outcome as a comment and moves it to in review when a build is accepted, or back to To-do when it fails or is refused. A person reads the draft pull request and merges it, and that act stays human on purpose.
Where it stands
This loop is a week old, so here is the honest state. The first ticket to go through the task board used an earlier design in which the spec review itself ran as a lane inside a VM. Several attempts failed on clone-and-publish plumbing before any review happened, and the build lane that followed failed too. That is what pushed the review out of the VM and into a bb workflow: a reviewer that only reads does not need an isolated machine, it needs the artifacts and a fence. The new prep workflow has run, and an early run is where the false-positive status check above came from. No ticket has yet gone all the way from the board to a merged pull request through the new path.
What I learned
The human boundary is a user-interface problem as much as an architecture one. My design notes had said from the first day that a person enqueues and a person merges. But as long as those acts were terminal commands, I was the only person who could perform them, and I performed them from memory. Two buttons on a task board did not change the rule; they made it visible and made it transferable.
I spent real effort writing instructions about what agents must not touch, and those instructions still matter for quality. What actually guarantees safety, though, is a short script that will not commit a file outside one directory and reads a task key instead of a ticket. Reviewers catch bad specs, and the script is there for the day a reviewer does not.
And a reviewer does not belong in a VM. The VM is for running code you do not trust. A reviewer that reads a diff and runs a test suite can live where the artifacts already are, and the version that lived in a VM spent its failures on plumbing instead of on reviewing.
The factory’s own repository is private for now. bb is at github.com/get-bb/bb.