A factory run takes tickets that already carry their planning documents and drives them through an ordered sequence of phases. The agent phases run together in one short-lived pod on the cell, on one agent session; the terminal gate is the control plane's own. The control plane sequences the phases, decides or parks each gate, and records everything; the pod does the repo work.
The factory starts where planning ends. Its input is a ticket that already has its contract documents — so the thing being automated is the build, not the decision about what to build.
The run's own sandboxed API key cannot read the ticket directly — ticket routes are deny-by-default for that credential, the same containment as every other route it isn't explicitly given. Instead, the control plane snapshots the in-scope ticket's title, body, and current contract documents into the phase prompt before the sandbox starts, so every phase sees the same frozen brief. A ticket or document that can't be found is named in the prompt, never silently dropped and never invented.
The phases
Three phases, in this order. Each one reports a fixed set of fields, and each one may only write inside a fixed set of paths.
1. implement
Reports summary
and changed_files. Writes lib/**, test/**, priv/**, config/**, cli/**.
2. test
Reports summary, changed_files, and test_results. Writes
test/**
only.
3. merge
Runs no agent and writes nothing. The control plane opens the pull request itself and then parks the run in front of a person, with the pull request and the files it changes as the evidence. Approving it merges that pull request and moves the tickets to done. It does not deploy.
AGENTS.md
and mix.lock
are protected in every phase no
matter what that phase may write — AGENTS.md
because it is generated from your agent config, and
mix.lock
because it is resolver output. On the runner, protected
always beats writable.
A phase that reports something invalid gets a bounded number of corrections — two for implement, one for test — and then the phase fails.
The agent phases share one pod and one agent session, so what the agent
read and why it chose what it chose is still there when the next phase
starts. The write rules are not
shared: each phase is enforced
against its own snapshot, so the test phase is refused a lib/**
write the implement phase made legitimately a moment earlier.
There is no deploy phase
Gates
Every phase ends at a gate. The gate is decided either by the orchestrator, from the phase's own reported result, or by a human.
A human gate parks the run in blocked until a person approves or
rejects it. There is no timeout path: nothing anywhere converts elapsed
time into an approval. An unattended gate is re-notified and keeps
holding. Every decision records who made it.
Where the work happens
A run forks a branch environment of the project — the same copy-on-write database branching and secret inheritance every other branch uses, not a factory-specific mechanism. The workspace pod joins that environment's namespace, so the agent reaches the branch's own services over internal DNS.
The pod serves nothing and has no ingress. Verification is your project's
real commands inside it: its test suite, its server on localhost, and Playwright for UI evidence — the Playwright MCP
server is in the image. Artifacts the agent uploads are stored against the
run.
A suite that needs a service alongside it — a database, a cache — declares
one on the repository, next to its check command, on the
project page. Each declared service runs as its own container in the same
pod, so the suite reaches it on localhost
at the port it declares, exactly as a CI service container works. The pod
does not start the agent until every declared service is accepting
connections, and the services are torn down with the run. A repository
that declares none is unchanged.
$ flicker factory repo set-sidecars 12 '[{"name":"postgres", "image":"pgvector/pgvector:pg16","port":5555,"run_as_user":999, "env":{"POSTGRES_USER":"postgres","POSTGRES_PASSWORD":"postgres","PGPORT":"5555"}}]' # or read it from a file under review, rather than shell history $ flicker factory repo set-sidecars 12 - < sandbox-services.json # an empty list clears them $ flicker factory repo set-sidecars 12 '[]'
Rosters
A roster is a project-scoped, named set of agent identities. Each identity carries a name and purpose, its harness, the endpoint and model it runs on, what it may write, what it may never touch, and an ordered list of agent-config profiles — later entries win.
A step picks a connection and a model: one of your organization's saved endpoints, and a model id from the list that endpoint itself publishes. An id the endpoint does not serve is refused when you save the step, not hours later inside a run. Every step needs one: a step that names no connection has nothing to talk to, and its run is refused at the start rather than failing opaquely inside a pod.
A run freezes its roster when it starts. The snapshot on the run is what actually executed — editing the roster afterwards changes what the next run does, and never rewrites the history of one that already ran.
Rosters are fully manageable from the CLI and the API
—
nothing here needs a browser. A roster with no steps cannot run, so roster list
and roster show
print the step count and say when it is zero.
$ flicker factory roster create nightly --description "the overnight lane" $ flicker factory roster add-entry 4 --name engineer --phase implement \ --model anthropic/claude-sonnet-4 --connection 2 --profile engineers $ flicker factory roster show 4 # field flags change only what you pass; --phase "" unbinds the step $ flicker factory roster update-entry 9 --phase ""
Credentials
Every step names a connection, and
runs on that connection's base_url
and api_key
— your own key against any
OpenAI-spec endpoint. The key is read when the step runs, not frozen
into the run, so rotating it does not strand work already queued.
There is no platform key behind it: a step whose connection is missing
or no longer resolves is refused, naming the step, so
a run can never fall through to a key its organization did not choose.
Flicker applies its own per-run spend ceiling at each phase boundary, from the token accounting the run records — so the bound does not depend on the provider being able to mint a limited key. It is deliberately one-directional: a figure at or over the limit stops the run, and a partial sum under it proves nothing and does not clear the run. A run whose lane reports no token counts at all is recorded as unbounded, not as within budget. Some lanes report nothing today, so that state is real: the run page says not enforced on its face, and the same finding is written to the run's trace.
The run's own flicker API credential expires on its own, is revoked when the run settles, and is swept by the reaper if the run never settles cleanly. It is clamped to the three routes its run actually needs — it cannot create runs, cannot write agent config or rosters, and cannot approve its own gate.
Containment
Each run gets its own namespace with a resource quota, a network policy, and an active deadline. A reaper deletes namespaces and terminates runs past their budget.
blocked is not idle. A run waiting on a person does not get
reaped for being slow.
Reading a run
The trace is append-only and ordered. It is the run's record. Everything that reads it — the CLI, the run page — reads it from the outside; nothing talks back into the sandbox.
$ flicker factory run --ticket 1719 --roster default $ flicker factory trace 27 # poll until the run passes, fails, or is cancelled $ flicker factory trace 27 --follow
The run page shows the same trace live, along with the run's phases, its frozen roster, and — if you're a manager — the approve and reject controls for a parked human gate.
A run's work is durable: the runner pushes the workspace to a
factory/run-<id>
checkpoint branch every few minutes and
when each phase ends, and uploads the agent's session and trace as run
artifacts. A failed run can be retried
— from the run
page or POST /api/v1/factory/runs/:id/retry
— as a new run
that bootstraps from that checkpoint instead of the repository head, so
the code written before the failure is not lost.
One retry is refused: a run that died while bootstrapping
its workspace, when nothing the failing step reads has changed since. The
run page names the step and shows what it printed, along with the inputs
the retry would be compared against — a repository URL, a ref, the
repository connection behind the credential. Fix the cause and the retry
goes through on its own; if the thing that changed is something flicker
cannot see, the run page offers Retry anyway
and the API takes {"force": true}.
The test phase's verify gate
The test phase's verdict is not just the agent's own report
of what it did — a repository can configure its own factory test command, and the runner execs it directly
(no shell) as a gate the phase must pass. Set it on the project page's
repository row, or from the CLI:
$ flicker factory repo set-test-command 12 -- mix test # an empty argv after -- clears it $ flicker factory repo set-test-command 12 --
Unconfigured is a recorded outcome, not a pass
runner.gate.unconfigured
trace event and the run page shows it; the test phase is then judged by
the agent's own report, not by anything that actually ran the suite.
That is a gap to close, not a passing grade.
What a passed run leaves behind
A run that passes ends with something you can review. The control plane —
not the agent — creates a stable branch, factory/run-<id>/delivery, at the run's final checkpoint
commit, and opens one pull request from it against your
repository's default branch. The checkpoint branch itself is force-pushed
on a timer, so it is never what a pull request points at.
The pull request's body carries the evidence rather than a summary of it:
the tickets in scope, a link to the run page, the head commit, and each
phase's own reported result — its summary, changed_files
and test_results.
Exactly one pull request per run. Delivery is safe to re-enter: it adopts the pull request that already exists for that branch instead of opening a second one. A run whose agent committed nothing opens nothing. And if delivery fails — no repository connection, GitHub unreachable — the run stays passed and the failure is recorded in its trace: a run that passed and could not be delivered is a passed run with an undelivered result, not a failed one.
Opening is not merging
Limits
Stated plainly, because they shape how you use it:
- Linux sandboxes only.
- One phase per pod. A phase that fails does not resume from the middle — it starts over.
- The factory starts where planning ends: it needs a ticket that already has its contract documents.