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.

Capacity pool

Runs claim a slot: a reusable environment forked from the project's staging. Organization managers size the pool on the project's Factory page or on the staging environment's page. Set size and Remove only ever delete free slots, highest position first, together with the apps and database branches forked into them. A slot a run is using is refused, naming the run, and staging and main are never touched.

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 runs in its own namespace, and its network policy lets it reach the branch's own database and apps over internal DNS, and nothing else in the cluster.

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.

The image carries one Elixir and one Erlang/OTP (Elixir 1.18, OTP 28 today). It is not matched to your repository: when the repository's .tool-versions names a different OTP major or Elixir minor, the run page and the trace say so up front — a dependency built for another OTP may not compile in the pod.

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.

The agent calls that connection directly from its pod. An OpenCode connection is spoken in OpenCode's own dialect, and every model call of a run carries one session id, so OpenCode's usage console groups the whole run under it. The run page shows that id under Debug.

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 served over https from a public address. 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.

Cost is shown as two separate figures, never one: Billed is what is actually charged, and Estimated token cost is the run's tokens (input, output, cache read and cache write, each at its own rate) times your Settings → Rate card — the value of the usage, not a charge. A connection can be marked subscription (a flat monthly amount, set under Settings → Providers): its Billed figure is that flat fee with $0 added per call, and the spend ceiling is judged on the estimate instead, since the subscription itself is not metered. When no rate is known the estimate reads unknown: set rates, never $0, and the ceiling falls back to the token budget. GET /api/v1/factory/runs/:id carries both as usage.billed_usd and usage.estimated_token_cost_usd, with cache_read_tokens and cache_write_tokens.

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 default-deny network policy, and an active deadline. A reaper deletes namespaces and terminates runs past their budget.

  • The pod runs in a Kata microVM by default, not on the node's shared kernel, as a non-root user with every capability dropped, the runtime's default seccomp profile, and no Kubernetes API credential.
  • Inside the cluster it reaches only DNS and its own forked database and apps. Its disk use is capped, so filling it stops the run, not the node.
  • A run is refused a staging or production environment, or any environment that copies production secret values: the pod is given its environment's secrets, so runs use a factory pool slot or a keys-only fork.
  • A run is bounded, by default, at 6 hours and $25 of model spend; spend is checked at each phase boundary.
i

What this is not

Outbound web traffic is open, so the agent can reach the internet. This is containment for your own agents on your own code, not an exfiltration barrier. The forked database holds a copy of your data, and the run's repository token can push to the repository.

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.

Either way this is a fast local check, not the authority: the repository's own CI on the delivered pull request is (see below).

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.

CI decides, a person approves

The pull request runs your repository's own CI, and that is what judges the change. The run waits at its merge gate while CI runs, and only asks a person once CI has answered:

  • CI passes: at least one job succeeded and every other job succeeded or was skipped. The gate notifies you, and Approve merges the pull request at exactly the commit CI judged. If the branch moved since, GitHub refuses the merge and the run records that the new head is untested.
  • CI gives no verdict: jobs cancelled, stale, or all skipped, or the GitHub App lacks actions: read. The gate says which, and waits for you.
  • CI fails: the work goes back to the agent as a fix round, which is a new run on the same pull request. That run resumes the agent's session with the failing checks and the last 200 lines of each failing job's log, with secrets redacted. It pushes to the same branch by fast-forward, never force-push. Then CI runs again. If that fast-forward is refused, the fix round fails rather than passing over a pull request that is still red. A CI-failed run is not retried by hand: its fix round is the retry.
  • Rounds are bounded: the default is 3 fix rounds per roster. The spend ceiling covers the whole chain of rounds, not each round on its own. When either limit is reached the run says so, and the gate waits for you, still red.
  • No CI: if no workflow runs within 10 minutes, the gate says so. It never passes on its own.
  • CI that hangs: if CI hasn't finished after 60 minutes, the gate shows that and waits for you.

Approving needs green CI. To approve anyway, give a reason on the run page or as ci_override to POST /api/v1/factory/runs/:id/gate. The decision records who approved, why, and what CI said. Rejecting is always allowed.

i

What CI can be seen

The gate can only see GitHub Actions. If your repository runs a different CI system, the gate reports no CI and approving needs an override: it fails safe, it does not wave the change through.

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.