Hand a planned ticket to a sandbox on the cell, and decide the gates yourself

Software factory

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.

i

There is no deploy phase

Deliberately. The furthest a run reaches is a merged pull request, and only when a person approved that merge. A deploy is a human act, outside the factory's reach entirely.

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.

declare a Postgres service for a repository's suite
$ 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.

build a runnable roster without opening the UI
$ 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.

follow a run from the terminal
$ 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:

set or clear a repository's verify command
$ flicker factory repo set-test-command 12 -- mix test
# an empty argv after -- clears it
$ flicker factory repo set-test-command 12 --
i

Unconfigured is a recorded outcome, not a pass

There is deliberately no default command — guessing one would fail every run of a repository whose sandbox cannot execute it. A repository with no command configured records an explicit 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.

i

Opening is not merging

Nothing here merges, and nothing deploys. The pull request is where the factory stops and a person starts.

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.