Documentation
Architecture
How conveyor.ai (internally the factory) is put together: what runs where, how a project gets stood up and connected, what lands in a project container versus the project's repo, and what the factory commits versus what it deliberately never does.
The product is conveyor.ai; the code, CLI helpers and data dirs say factory. Both names appear below and mean the same thing.
1. The core factory
One long-running container manages N projects. It is not a per-project runner — it is deployed once and holds all the state.
┌──────────────────── factory container ─────────────────────┐
browser ─────▶│ dashboard (static JS) + REST + SSE │
│ SQLite (/data/factory/factory.db) │
│ heartbeat engine · container pool · stage proxy │
└────────┬──────────────────────────────────▲────────────────┘
│ SSH (launch + setup) │ HTTP (callbacks)
┌────────▼─────────┐ ┌──────────┴───────┐
│ AIF-12's cont. │ ...... │ AIF-13's cont. │
│ tools run here │ │ tools run here │
└──────────────────┘ └──────────────────┘
one container per ISSUE, from a capped pool (§2)Two channels, deliberately asymmetric:
- SSH — outbound, control only. The factory SSHes into a project to install
helpers, run a tool's
setupscript, and launch a tool detached (setsid, fds to/dev/null). It never holds the connection open for the duration of a run. - HTTP — inbound, all the data. Once running, the tool calls back to the
factory at
FACTORY_URLwith output chunks, heartbeats, progress, status changes, comments and input requests. All best-effort with retry, so a tool survives a factory restart mid-run.
That split is why only project→factory HTTP reachability and factory→project SSH
are required; put both on a shared Docker network (FACTORY_NETWORK) and they
find each other by DNS alias.
Processes inside the container
scripts/container-init.sh is the entrypoint. As root it installs the operator
SSH key, persists sshd host keys on the volume, starts sshd on :22, adds dev
to the docker-socket group, then execs the server as unprivileged dev
(factory/server/src/main.ts).
The server boots in this order: open+migrate SQLite → ensure the outbound SSH keypair → seed declarative config only if the DB is empty → reconcile in-flight runs and stages → start HTTP → start the heartbeat.
The heartbeat engine
src/engine.ts beats on an interval (HEARTBEAT_SEC, default 15). Beats never
overlap — but the guard expires (beatMayStart), because a guard without one
turns a single wedged beat into a permanently silent engine.
Each beat first does the factory-wide work, in this order:
- reap runs — finalize runs whose heartbeat went stale, whose process is
gone (verified over SSH), or that are superseded (the issue already
advanced past the run's
status_version). - the pool sweeps, and their order is load-bearing (see the pool section
below): vanished containers first, since everything after it assumes a row
points at something real; then retry pending persists; then reclaim abandoned
holds; then reap idle containers on a billed backend (after the hold sweep,
so one handed back this beat is considered at once); then sweep orphaned
instances, throttled to
ORPHAN_SWEEP_SEC— "orphaned" meaning stamped with THIS factory's instance id (FACTORY_INSTANCE_ID, else generated into<data dir>/instance-id, AIF-656), so two factories sharing a docker socket or fly org never sweep each other; unstamped legacy instances are never swept. Orphaned stage/conversation session containers are released last. - the epic scheduler — deliberately before the project pass, so an issue promoted now launches in this same beat.
- dependency waits — let issues back in once their blockers land. The kickstart hook already does this the instant a blocker goes terminal; this is the safety net for moves no status change announces.
Then, per project (bounded concurrency, maxProjectConcurrency):
- ping over SSH — sets the reachability flag; skip an offline project.
- auto-setup / re-provision — hashes the provisioning inputs (the helper
script set + each pipeline tool's
setup_script+ a layout version). If the hash drifted from what was last applied, re-run setup over SSH. A pure local hash compare when nothing changed, so it costs nothing on a normal beat. reconcileProjectImageto pick up a changed.factory/development/Dockerfile. Fire-and-forget.- schedule — walking the project's issues oldest-waiter-first, so the
issue that has waited longest on a full step claims the next slot. For each
issue whose
statusmatches a pipeline step, launch the step's tool, subject to: the dependency gate, a container slot from the pool, one run per(step, status_version)so the same status can't re-fire, the step'smaxandmax_per_projectconcurrency, and a circuit breaker against status-loop ping-pong.
Linear sync, the GitHub Actions indicator, the deployed-to-production check and
the token check ride the end of the beat, each behind its own try/catch — an
outage in any of them must never stop reaping or scheduling.
The deployed mark (AIF-459). src/deploystatus.ts asks the GitHub
Deployments API which commit is live in each project's production environment
(params.production_environment, default production, matched
case-insensitively) and flags every merged issue whose merge commit is in that
commit's history — issues.deployed, rendered as a small mark on issue rows and
the issue detail header. Both expensive questions are answered in bulk rather
than per issue: one page of the project's closed PRs resolves a hundred merge
commits at once (matched to issues by branch name), and ancestry is membership
in one page of the deployed commit's history rather than a compare call each.
A candidate is only re-examined once the project's deployed sha changes, so the
steady state is two API calls per project per interval. A project whose pipeline
creates no GitHub Deployments is a silent no-op — no mark, no error — which is
why this repo's own deploy.yml records one (record-deployment) and every
other project must do the same for its issues to be marked.
Launching is transition-triggered, not polled: output and status arrive over HTTP as they happen.
Domain model
| Concept | What it is |
|---|---|
| Tool | setup script + exec command + param definitions. Factory-scoped (shared) or project-scoped (shadows a factory tool of the same name). |
| Workflow | A named bundle of statuses + a pipeline. Factory workflows are shared; each project also has a private custom one. |
| Project | A remote container: SSH info, root_dir, data_folder, params, issue-key prefix, and the workflow it uses. |
| Pipeline step | on: <status> → run tool with params, at most max at once. |
| Issue | Key (AIF-12), title, body, status, type, properties, documents, comments, plus a derived exec state (running/needs_input/pending/offline/none). |
Configuration: YAML seeds, the DB rules
config/tools|workflows|projects/*.yaml is a bootstrap seed, imported only
when the DB is empty (or on an explicit POST /api/reload). After that the
SQLite DB is the source of truth, because the dashboard writes to it directly —
so a restart never clobbers UI-managed data. factory.sh redeploy syncs the
repo's config tree into the live factory and reloads it after the health check
(upsert-only, best-effort).
Factory-side state (/data, the factory-data named volume)
/data/factory/
factory.db SQLite: projects, tools, workflows, issues, runs, users, conversation threads (`terminal_*` tables)
config/ the pushed YAML tree
ssh/ the factory's outbound keypair (+ known_hosts)
ssh-hostkeys/ sshd host keys, so the fingerprint survives rebuilds
runs/<run>/ factory-side copy of each run's output.log
claude/ the factory's own claude login (conversation agent)
terminal/factory/ the factory conversation's scratch + clonesEverything else in the container is rebuilt from the image. Run several
factories on one host by giving each a unique FACTORY_NAME and port set.
Deploying the factory itself
scripts/factory.sh points your local docker CLI at the host over SSH
(DOCKER_HOST=ssh://…), so up/redeploy build the image on the host — no
registry. redeploy is build → tag current as :prev → swap → health-check →
auto-rollback. In practice you don't run it: pushes to main are redeployed by
a self-hosted GitHub Actions runner that is itself a container on the docker host
(docker-out-of-docker), so no deploy credential ever lives in an agent-operated
container. See self-deploy.md.
2. Containers: one per issue, from a pool
The diagram above shows one container per project. That is no longer the unit of work: since AIF-139 each issue gets its own container, drawn from a capped, factory-wide pool, so two issues never share a checkout. A project's own container still exists — it is the pool's first member.
src/pool.ts is the side-effecting lifecycle, src/poolplan.ts the pure
allocation rules (no docker, no db, no clock — so they can be tested
exhaustively), src/poolstore.ts the rows.
Allocation
allocateFor(project, issue) never blocks the beat: it returns what it has right
now and provisions in the background. The rule, in order:
- an idle container of this project (and this backend) → reuse it;
- room under the cap (
MAX_CONTAINERS, default 4) → create one; - an idle container of another project → evict it, then create;
- otherwise → block until something frees up.
Plus wait: if one is already coming up for this project, sit tight rather than
race a second one up. A starting row counts against the cap the instant it
exists, which is what stops two beats both "finding room". Decide-and-reserve is
one synchronous transaction; the slow work happens outside it.
"Mine" means this project and this backend — a project that ran on local and was later switched to fly has rows on both, and they are not interchangeable.
Tiering: the ordered policy (AIF-302)
The factory-wide default is not one backend but an ordered list of tiers,
each with its own cap: [{local, cap 4}, {fly, cap 6}] reads "four local
containers; when local can place nothing, spill to fly, up to six".
decidePlacement runs the rule above once per tier, in policy order, and
takes the first tier that returns anything other than block. That composition
is the whole design, and three properties fall out of it rather than being coded:
waitbeats spilling. One already coming up on tier 1 is notblock, so nothing races a duplicate onto a tier that costs money.- Evicting beats spilling. Within a tier the order is still reuse → create → evict → block, so tier 2 is reached only when tier 1 is at its cap and has nothing idle. Evicting costs a cold start for work that already finished; spilling costs money and a cold start.
- The pool drains back on its own. Reuse is per tier, so as tier 1 frees up new work lands there and the spilled containers go idle and are reaped. No rebalancer.
Each tier sees only its own backend's rows, which is what makes the caps
per-backend without countsAgainstCap learning about backends. It also scopes
eviction, which is a deliberate change from the untiered rule: under
per-backend caps, evicting a fly container frees nothing on local. With one tier
every filter is the identity, so an unconfigured factory is unchanged.
A tier is only eligible if it can actually take the work. Tier 1 needs only
to be reachable; a spill tier must also have been available on the last two
samples (so a flapping backend cannot take a spill it will strand) and have a
published image for this project (spilling onto a backend with no image is a
multi-minute build at peak, looking like a hang — AIF-260 one level up). Health
comes from backendhealth.ts, sampled once per beat and read synchronously
inside the placement transaction; a sample older than four beats reads as
unavailable rather than as its last value. Health never stops the factory: if
no tier is eligible, the first one refused for a merely momentary reason is tried
anyway, which is what the factory did before tiering existed.
A project override still names one backend and pins the project to it, with no spill — AIF-259's principle, that an operator picks a backend for reasons and a project that must not move needs a way to say so. The ordered policy is a factory-wide default only.
Spilling is said out loud: leaving tier 1 puts a comment on the issue naming the tier and why, because tiering deliberately reintroduces "ran somewhere you did not individually pick" and the bar is that the issue itself answers it. The Containers screen badges each container's tier (derived from its backend against the current policy, so a reorder relabels rather than going stale) and the Health page reports running/idle/cap per tier. Each tier also carries its own reap timeout, defaulting to the global one — a spill tier wants a much shorter fuse, since it absorbs bursts rather than holding warm checkouts.
Addressing: the container, not the project
execSSH & co. take an SshTarget. Get one from resolveTarget(project, issue)
(or resolveSessionTarget), which returns the held container's target if the
project is pooled and the project's own host otherwise. Passing project
directly still compiles — it means "the project's own host", which is only right
for an unpooled project.
hasDedicatedContainer(issue) is the related question: if the issue owns its
container, its stages check the branch out in the container's main checkout
(nobody else is there to clash with). If not, they fork a worktree, as before.
Synthesis, not adoption
Every container is synthesized from the project's repo param and its image
(runArgsFor in backends/local.ts): its own name, which is also its network
alias and the stem of the two named volumes it owns (<name>-data,
<name>-workspaces). The project's first container and its twentieth are made
by the same call, and none of them is special — nothing is pinned, nothing is
exempt from eviction, and losing one costs nothing that cannot be rebuilt.
It used to work the other way (removed in AIF-292). A human ran
scripts/project.sh; the pool adopted the container that produced as the
project's first pool member, pinned so it was never evicted; and its docker
inspect became the template every sibling was cut from. That made the first
container a single point of failure and meant the server could not describe a
container it had not already been shown one of — so a project could not exist
until somebody had run a shell script against the docker host. The clone URL is
enough to build the image and the params are enough to run it, which is what the
fly backend had always done and what the local one now does too.
Release, and the sweeps that catch what release misses
A container goes back to the pool through persistAndRelease, which pushes
every branch the issue produced first and only then lets go. That ordering is
the protection: the container stays held — and so can never be evicted — until
the push has actually landed. A push that keeps being refused escalates to a
rescue ref after five consecutive failures, so the work can get off the box
rather than pinning it forever.
That escalation assumes the push can eventually land somewhere. When it cannot —
a root_dir that is not a checkout, so factory-persist itself cannot run and
--rescue is refused identically — the retry is bounded: after roughly an
hour of consecutive failures the factory says so on the issue thread and releases
the container anyway (AIF-475). A local diff that provably cannot be pushed is
worth less than a pool slot, and the pool is factory-wide: one misconfigured
project once held all 55 slots for two and a half days. The same bound covers the
helper-refresh deferral, so a refresh that can never land cannot hold a slot by
never attempting the push at all. Project root_dir must now be an absolute
path, which is what stops the commonest cause at the door.
Two paths release normally: a transition into a terminal status, and a
transition into cancelled (which cleanup.ts owns — pushing there would
resurrect work the operator threw away). Every other way an issue can stop
moving leaks the container: dragged back to backlog, failed into error,
parked waiting on a human. sweepAbandonedHolds is the rule that takes it back
on a timeout (CONTAINER_HOLD_TIMEOUT_SEC, default 1800), through the same
persistAndRelease. It is deliberately conservative — a run still going, a
stage serving from that checkout, a conversation work turn in flight, or an
unreadable clock all mean "don't touch it".
The window is per hold, not one factory-wide number (AIF-343). A container
an issue conversation asked for, that no pipeline run has since used, goes back
after TERMINAL_CONTAINER_HOLD_SEC (default 600) instead — nobody but the
operator ever wanted it. Once a run starts against it the ordinary rule applies
again, and CONTAINER_HOLD_TIMEOUT_SEC=0 still turns the whole sweep off.
That timeout is why dev-agent branches a missing $BRANCH off
origin/$BRANCH rather than the base branch: a later stage can land on a
container that has never seen the branch, and cutting from the base would orphan
the work.
The remaining sweeps cover the cases that fall between: sweepVanishedContainers
(a row whose instance was destroyed underneath it), sweepIdleBilledContainers
(idle, healthy row, on a backend that charges for it), and sweepOrphans (an
instance with no row — every teardown drops the row before calling the
backend, so a refused destroy leaves one running that nothing counts).
Compute backends
Since AIF-227/AIF-229 the pool does not talk to docker at all; it goes through a
ComputeBackend. src/backends/ is a registration table (local, fly) —
the same shape messaging.ts uses for its providers, and for the same reason:
adding one should be an entry plus a module, not another conditional threaded
through every call site.
Resolution happens in exactly two shapes, and keeping them apart is what stops a settings change breaking containers that already exist:
tiersFor(project) project override (one tier) > the ordered policy
backendFor(project) the first of those tiers that can actually run
backendForContainer(row) whatever CREATED that row > localbackendFor(project)chooses one. Called only fromreserve()— the one place that makes a row from nothing. A choice only counts if it names a backend that is both registered andconfigured();localis always both, so the chain always terminates.backendForContainer(row)recalls the one that made an existing container, off the row itself. Everything else — target, destroy, helper refresh, persist — goes through this, so it keeps addressing the box it actually made no matter what the project's setting says now. It deliberately checks registration but notconfigured()— revoking a token must not strand the machines that backend already owns.
Selection is a factory-wide ordered policy (config key
compute_backend_policy, the Backends tab on factory settings — see Tiering
above) plus a per-project override (the Backend select on a project's General
tab). The older single-value key compute_backend is still read when no policy
is stored, so a factory that had chosen one backend keeps running on it. The
credential itself is env, never the UI —
FLY_API_TOKEN, alongside LINEAR_API_KEY and the Twilio secrets — the
settings page only reports whether a secret is present.
An unusable selection is a hard stop, not a fallback (AIF-259.) This chain
used to degrade to local at every level. That was wrong: a project set to fly
quietly ran every issue on local docker, so the work "succeeded" in a venue
nobody chose while the fly dashboard stayed empty. backendFor now returns null
and the pool refuses to place work, which the engine notes on the issue rather
than skipping it silently every beat. backendForContainer still falls back,
deliberately — it addresses a box that already exists, and revoking a token must
not strand every container that backend owns.
Merge queues: how a pull request lands
A second registration table, deliberately the same shape as the compute one
above (AIF-307). It answers a different question — compute backends decide
where an issue's container runs, merge queues decide how its pull request
lands — and the two are chosen independently: a project on fly can still
land through Mergify. src/mergequeue/ holds the table; the merge stage hands
the PR off and stops, and the chosen queue is polled on the beat.
| kind | what it does | serializes? |
|---|---|---|
github | Watches the PR's checks and merges it itself when they are green. | no — the merge stage's max_per_project: 1 is what bounds it |
mergify | Hands the PR to Mergify (@mergifyio queue) and watches what Mergify does with it, through GitHub-visible signals only. | yes — Mergify admits and orders the merges |
Both are configured() on GH_TOKEN alone; neither needs a second credential.
ownsSerialization is what a queue declares about itself, so nothing above the
seam has to test for a backend by name.
Selection mirrors the compute chain — a per-project override (the Merge queue
select on a project's General tab, or
PATCH /api/projects/:id {"merge_queue":"mergify"}) over a factory-wide default
(config key merge_queue_backend, the Backends tab on factory settings). A
backend's declared params are rendered under whichever picker selects it and are
stored in the project's ordinary params map — mergify_queue is the only one
today.
An unusable selection is a hard stop here too, and for a sharper reason than AIF-259's: a project pointed at Mergify whose PRs were quietly landed by the factory's own merger has had its queue discipline silently removed. So a selection must name a queue that is both registered and configured, or nothing lands and the wait fails with the reason on the issue.
The one asymmetry worth knowing: when nothing is chosen at either level the
chain terminates on github ungated — no configured() check. A factory with
no GH_TOKEN therefore keeps its pre-AIF-307 behaviour (the poll throws, the
wait retries next beat) instead of failing the issue outright. Choosing github
explicitly is validated and can be refused; inheriting it cannot. This is why the
settings select sends "" rather than "github" for its default option — the
two are stored differently and resolve differently.
docs/merge-queues.md covers the backends themselves, including what a repo must
have for Mergify to work and what happens when it does not.
Agent backends: which coding agent does the work
A third registration table of the same shape (src/agents/, AIF-392), answering
the third question: compute backends decide where a container runs, merge
queues how its PR lands, agents who writes the code — every pipeline
stage and every conversation turn.
| kind | what it is | needs |
|---|---|---|
claude | Claude Code, exactly as the factory has always run it. | usually nothing — the container's own login authenticates it |
kimi | Kimi (Moonshot), via its Anthropic-compatible endpoint. | KIMI_API_KEY |
openai | OpenAI, via an Anthropic-compatible gateway the operator runs. | OPENAI_BASE_URL and OPENAI_API_KEY |
openrouter | OpenRouter, which fronts many providers and serves the Messages API itself. | OPENROUTER_API_KEY |
The last three are one implementation: a passthrough that points the claude CLI
at another provider with ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. Because
that is an env change at the server, no tool or workflow YAML changes when the
agent does — but a workflow's model: alias does, so runner.ts rewrites
PARAM_MODEL through the selected backend on the way to the run.
Unlike the two tables above there is no per-project override: one
factory-wide choice (config key agent_backend, the Agent section of the
Backends tab), resolved once per run so the credential a run authenticates with
and the model it is told to use provably come from the same backend. An unusable
selection is a hard stop here too — the run fails with the reason on the issue
rather than quietly spending someone else's budget on Claude.
The github asymmetry repeats exactly: nothing stored resolves to claude
ungated, because a factory whose credential is the container's own
/data/claude login has no token in its env at all and gating the terminator
would take every one of those from working to running nothing. Choosing claude
by name is validated and can be refused.
A backend's non-secret settings are stored, not env-only (AIF-441): its base
URL and the model it runs for each of the three aliases are per-backend rows in
the config table (agent_settings:<kind>), edited from the same Backends tab and
resolved stored > env var > the registration's default. API keys are the
exception and stay env-only, so nothing on that page can carry a credential —
which is also why the settings write is not gated on the backend being usable:
for the gateway backend the base URL is what makes it usable.
docs/agent-backends.md covers the backends themselves — the credential per
backend, the model-alias table, the settings, and how to point a factory at Kimi
or OpenAI end to end.
The fly backend, and its bootstrap
A fly machine is not a docker container on a host the factory owns, so several things differ:
- Image (
flyimage.ts). A machine boots fromregistry.fly.io/<app>:latestand nothing else creates that app or that image, so a project on the fly backend is dead until one publish has happened. The rebuild loop (projectrebuild.ts) does it: with no machine and nothing in the registry (backend.imagePublished) it builds and publishes, then stops — there is no instance to swap onto. Do not re-gate that path on the project already having an instance; that was the deadlock (AIF-260). One app per project (FLY_APP_TEMPLATE, defaultconveyor-{project}-project). - Warm state (
warmimage.ts). A cold machine would clone the repo and install dependencies over the public internet on every issue, so the repo and its dependency caches are baked as a layer on top, only on the fly publish path — a fly Volume can't do this, since it attaches to one machine at a time and isolation here is the machine boundary. Best-effort: a failed warm build still publishes the plain image, and the machine just starts cold. - Machine (
fly.ts). One machine per issue, cut from the image's floating:latest, taggedfactory_pool/factory_projectso the orphan sweep can tell its own machines from anything else in the org.pooled()is unconditionally true: nothing pre-exists on fly to take over, and since AIF-292 there is nothing to take over on the local backend either. - Reachability (
scripts/fly-wg.sh).target()returns the machine's 6PN (fly private network) address. The factory container joins the org's private network as a WireGuard peer at boot, fromcontainer-init.sh, as root and best-effort — which buys arbitrary TCP rather than just SSH, so staging (§3) needs no fly-specific code: the stage proxy opens its plain HTTP hop to the same 6PN address. If the tunnel is down, switching a project to fly still "succeeds" and its machines simply cannot be reached;fly-wg.sh --statussays which world you're in,fly-check.shproves the token opens the org's Machines API.
Fly also bills for idle machines, which is why sweepIdleBilledContainers
exists (CONTAINER_FLY_IDLE_REAP_SEC, default 300). On docker an idle container
costs nothing and is a warm box for the next issue, so it is only reclaimed
lazily, when the pool is at the cap and needs the slot. See
../CLAUDE.md for the operational runbook.
There is no fallback — a project that cannot be built is refused (AIF-611)
A project used to be able to opt out of the pool — no repo to build from, or
CONTAINER_POOL=0 — and keep running on one long-lived box named by
projects.ssh_host. That column is gone, along with the flag, the legacy
allocation result and requiresProjectHost.
What replaced the fallback is an honest refusal. allocate() answers unusable
with the reason when the backend will not pool the project (on local that means
it carries no repo, so there is nothing to build a container FROM), and the
beat comments that reason on the issue once. resolveTarget and
resolveSessionTarget return null rather than standing in the project, so
every caller has to say what it does with "no container yet" — wait, degrade, or
refuse. That is the point: the old fallback compiled only because Project
structurally satisfies SshTarget, and what it produced at run time was an SSH
to '' that read as the tool crashing.
3. Standing up and connecting a project
Three actors, and it matters which does what.
From the dashboard — add a project with its clone URL
There is no provisioning step for a human to run (AIF-292; scripts/project.sh
was deleted with it). A project needs a name and a repo param, and the factory
does the rest the first time an issue asks for a container: create() builds the
image if this daemon does not have it, then runs a container from it.
The image is synthesized from the factory's project/Dockerfile with the
repo's .factory/development/Dockerfile fragment appended, project/features.json
merged with the repo's .factory/development/features.json
(backends/imagebuild.ts). Everything the factory reads to build the
development container lives under the repo's .factory/development/ — clearly
marked as a factory interface. A repo's own deploy/ is its live-deployment
setup and is never read here.
Devcontainer is used only as a feature-aware image builder — no devcontainer
runtime and no workspace mounts. When the merged feature set is non-empty the
image is built by the devcontainer CLI (backends/devfeatures.ts, pinned and
invoked through npx) over the same context; when it is empty the build is a
plain docker build and no Node download happens. A declared feature that cannot
be installed fails the build — there is deliberately no featureless fallback,
because a silently featureless image is what AIF-215 was.
The built image's feature containerEnv is folded into the container (base64 via
FEATURE_ENV_B64, read by project/container-init.sh into /etc/environment)
so SSH sessions see it. That is not redundant with the build: the CLI bakes ENV
only for legacy v1 features, so a v2 feature — every ghcr.io/devcontainers/*
one — records its containerEnv only in the image's devcontainer.metadata
label, for a runtime we do not run to apply. Both backends read that label off the
image and pass the result as container env.
Cross-building (fly) goes through the same CLI with --platform. It shells out to
docker buildx build and never names a builder, so BUILDX_BUILDER in its
environment is what selects the aif-cross one — without it the build lands on
the default builder, which accepts --platform, ignores it, and returns a
host-architecture image (see §the cross-build note in imagebuild.ts).
Facts about the repo the factory needs before any container exists — the default
branch, its HEAD, and the dev-config hash that decides whether an image has
drifted — are read straight from the git remote (repodeploy.ts, one
git ls-remote plus a bare depth-1 fetch). They used to be read over SSH from the
project's own container, which is why a project needed one before it could have
one.
No project shape needs a box of its own any more: a project with no repo is
refused rather than run on one — see There is no fallback above.
Inside the project container — project/container-init.sh
On boot, as root: write env to /etc/environment (PAM loads it for SSH login
sessions, which is how the factory's non-interactive shells see it), install the
factory's public key and the operator's key into dev's authorized_keys,
persist sshd host keys, configure git identity + gh auth setup-git, then
clone the repo from its URL into the ~/workspaces volume — a clone, not a
bind mount, so the container is self-contained — bootstrap an initial commit if
the repo is empty, run the repo's .factory/development/setup.sh (or a default
lockfile install), and finally exec sshd -D.
From the web UI — Connect
Registering the project in the dashboard and pressing Connect runs
src/setup.ts over SSH. That is a software install into an already-existing
container; it provisions nothing. It creates the data-folder tree, scps the
helper scripts into <root>/<data>/runtime/bin/, and runs each pipeline tool's
setup_script (hash-tracked, so it only re-runs when the script changes). A run
is never launched before its tool's setup has succeeded.
Connect is rarely needed by hand — the engine's ensureProvisioned does the same
thing automatically whenever the helper set or a tool's setup script changes.
Launching a run
src/runner.ts writes an ISSUE.md snapshot and the run dir over SSH, then
launches run-wrapper detached with a composed environment:
| Vars | Source |
|---|---|
FACTORY_URL, FACTORY_RUN_ID, FACTORY_RUN_TOKEN | callback base + the project's durable bearer token |
FACTORY_ISSUE_ID/KEY/TYPE, FACTORY_ISSUE_DIR, FACTORY_DOCS_DIR, FACTORY_RUN_DIR | this issue and run |
FACTORY_EXEC_FILE | path of exec.sh in the run dir, holding the tool's exec command (a file, not an env string: one env string is capped at 128 KiB — AIF-648) |
PROJECT_* | every project param |
PARAM_* | tool defaults overridden by step params |
PROPERTY_* | issue properties |
Param values are passed as env only — never interpolated into the remote shell command, so a value cannot break out.
run-wrapper runs bash "$FACTORY_EXEC_FILE", tails the combined log to the factory every
~2s, heartbeats when idle, and reports the exit code with retries. It also drops
a .factory-run marker in the run dir so the helper CLIs still work from a fresh
shell that lost the environment.
Staging (the Run button)
The factory has no docker daemon of its own for project work, so a stage runs
inside the project container: factory-stage up locates the branch's
worktree and runs the repo's own executable stage script, whose contract is
just "start the app and print PORT=<n>". The factory then proxies one of its
own stage ports (8080+) to it, giving the staged app a whole origin rather than a
sub-path. Up to STAGE_MAX_CONCURRENT at once. See staging.md.
4. What lives where — repo vs. container
Written by the developer, in the project repo (all optional)
The factory's development-container inputs live under .factory/development/ —
a clearly factory-owned interface. A repo's own deploy/ (live deployment) is a
separate concern the factory never reads.
| File | Effect |
|---|---|
.factory/development/env | variable overrides; also passed to setup.sh as env |
.factory/development/features.json | devcontainer features (strict JSON), merged over the factory default |
.factory/development/Dockerfile | extra build steps appended to the default image (a fragment — no FROM) |
.factory/development/setup.sh | dev setup run after clone (else a default lockfile install) |
.factory/development/compose.override.yaml | ports, mounts, sidecars (service dev) |
stage | executable; starts the app and prints PORT=<n> — enables the Run button |
CLAUDE.md | instructions the agent reads every run |
Samples live in project/examples/.
Deployed by the factory into the container, never committed
Installed into <root_dir>/<data_folder>/runtime/bin/ (default .factory/runtime/bin/)
and put on the tool's PATH:
run-wrapper, factory-progress, factory-ask, factory-set-status,
factory-comment, factory-summary, factory-issue, factory-history,
factory-ready, factory-wait, factory-stage, factory-usage,
factory-cleanup, factory-persist, factory-checkpoint, factory-pr,
factory-reintegrate, factory-git-auth.
The list itself lives in HELPERS (src/setup.ts); that array is what
ensureProvisioned hashes, so adding one is enough to push it to every project.
factory-persist and factory-checkpoint are two halves of one guarantee: work
must not live on only one container. Persist pushes every committed branch and
deliberately invents no commit; checkpoint makes the wip: commit that gives it
something to push (AIF-263). The dev-agent checkpoints after every failed attempt
at a stage, and the pool checkpoints immediately before persisting a container it
is about to release — so an uncommitted edit is saved at the last moment it can
be, rather than reported as PERSIST_DIRTY and lost with the box. The commits sit
on the issue's own branch and are squashed away when its PR merges.
factory-pr land <repo-root> <branch> <base> is what the Merge stage uses to
put a branch on the base branch: it pushes, opens (or reuses) the PR, waits for
its checks and squash-merges when they are green (AIF-197). It speaks the GitHub
REST API with curl + $GH_TOKEN rather than gh, which arrives as a
devcontainer feature and so is absent from any container built before AIF-215 —
and from a legacy project the factory does not build at all. Exit 3 means the
origin is not on GitHub, and the stage falls back to a local merge + push.
These are baked into the factory image and re-scp'd whenever they change.
Baked into the project image
Node, Claude Code, TypeScript, curl, and gh (from the default devcontainer
feature); sshd running as user dev. Tool setup scripts install anything
else a specific tool needs. The wrapper additionally requires bash, curl,
coreutils, ps and setsid.
Persistent volumes on the project container
The repo checkout at /home/dev/workspaces/<name> and the one-time Claude login
at /data/claude are per-container named volumes, so an image rebuild swaps the
container without losing the workspace or the login. Per-container is exact:
the volumes are named after the container that owns them (volumesFor), so each
issue's container gets its own checkout and teardown removes precisely those.
5. Committed vs. runtime-only
This is the rule the on-disk layout exists to enforce: the factory writes a lot
into the project checkout, and almost none of it is tracked. The split is a
single directory boundary under the project's data_folder (default .factory/).
<root_dir>/
.factory/
.gitignore ← contains exactly: runtime/
issues/<issue-id>/documents/ ← TRACKED. stage docs, committed onto the branch
documents/ ← TRACKED (opt-in). central project docs
runtime/ ← IGNORED WHOLESALE. everything below here
bin/ the helper scripts
issues/<issue-id>/
ISSUE.md the snapshot a tool reads
.claude-session session id for stage-to-stage resume
documents/ the working docs a stage writes
runs/<run-id>/ output.log, prompt.txt, agent.out, usage.txt, .factory-runsrc/paths.ts is where this lives. Note the deliberate asymmetry: the tracked
docs dir (.factory/issues/<id>/documents) is a sibling of runtime/, which
is what lets one runtime/ ignore line cover every runtime artifact without also
ignoring the documents.
What the factory commits
On a successful non-merge stage that has a worktree, the dev-agent tool:
- rewrites
.factory/.gitignoretoruntime/(in place, so existing projects migrate); - copies
$FACTORY_DOCS_DIR/*.md→.factory/issues/<id>/documents/; git add -Aand commits asAI Factory <factory@users.noreply.github.com>with message<phase>: <slug>;git push -u origin <branch>and comments on the issue whether it worked.
So a branch carries the real code changes plus that issue's stage documents
(PRD.md, ARCHITECTURE.md, JDI.md, REVIEW.md, …), and nothing else the
factory generated.
Optionally — per project, via manage_project_docs — the review stage also
reconciles central docs at .factory/documents/ (features.md, design.md,
api.md, architecture.md). Only when accepting and advancing, never when
pushing back. These describe the project as it is now; they are not a
changelog. FACTORY_PROJECT_DOCS_DIR is passed relative to the repo root on
purpose — the agent's CWD is the worktree, and an absolute path would land edits
in the main checkout, off-branch.
Each of those four documents is held to a line budget (doc_budget_lines, 600
by default; a project raises it with a project param of the same name) and opens
with a one-line-per-section map, and the reviewer is told each file's current
size and to compact rather than append past it. The whole clause is skipped when
the branch's diff touches only tests, docs//top-level markdown, or .factory/
— an objective path check in dev-agent, not something the agent is asked to
judge. (big_task reconciles too, since it contains its own review.)
What is never committed
Everything under runtime/: the ISSUE.md snapshot, run logs and prompts,
per-run scratch, .factory-run markers, the installed helper bin/, stage
bookkeeping, and the .claude-session id (machine-local, and outside
documents/ specifically so the doc-copy can't sweep it into a commit). The
result is that a project's checkout never accrues dirty state from the factory
operating on it.
Branch and worktree lifecycle
Each issue works on its own branch, issue/<key>-<slug>, forked from
PROJECT_BASE_BRANCH (default main). Where that branch is checked out
depends on whether the issue owns its container (AIF-149):
- it does (the normal case since AIF-139) — the branch is checked out in the container's main checkout. Nothing else runs in this container, so there is nobody to clash with, and the checkout a human SSHes into is the one the agents actually used;
- it doesn't (an unpooled project, whose issues share one container) — the
stage forks an isolated worktree at
<parent>/.factory-worktrees/<repo>/<key>, or two concurrent stages would fight over one working tree.
An issue that already has a worktree keeps it even on a dedicated container: it may hold uncommitted work, and moving an in-flight issue would strand it.
Beyond that:
- the checkout is created idempotently by the first stage that needs it and reused by every later stage of that issue;
- a branch that exists only on origin is fetched first, and a missing
$BRANCHis cut fromorigin/$BRANCHin preference to the base — a stage can resume on a container that has never seen the branch (see the hold sweep above), and cutting from the base would silently abandon the work; - a stage with
workspace: none(e.g. theplanphase) skips all of this and runs read-only in the main checkout — nothing is committed or pushed, though its documents still land inFACTORY_DOCS_DIRfor the dashboard; - the merge phase runs from the repo root with explicit
git -C, because it may remove the worktree —cd-ing in first would delete its own CWD. It only removes one when the worktree and the repo root are genuinely different directories; - cancelling an issue cleans up its branch and worktree (
src/cleanup.ts), and releases its container.
6. Epics, and landing one as a branch
An epic is an issue of type epic — same issues row, key, thread,
documents, properties — not a table of its own (AIF-487). Members point at
it with epic_id; an epic cannot itself belong to another epic. A workflow
that declares no epic type has no epics, which is the signal the dashboard
uses to hide the tab. Running an epic means the scheduler promotes members,
in the epic's manual order, to their type's hand-off status, keeping
EPIC_ISSUE_PROCESSING_LIMIT in flight — but it no longer decides an epic is
finished: once every member is terminal the epic sits on in_progress and
waits for a human's Approve.
epic_branch is an issue property (types: [epic]), off by default:
epic_branch | Members fork from / land on | Approve closes to |
|---|---|---|
| off | the project's own base branch | done, directly (the beat, no pipeline step) |
| on | epic/<key>-<slug> | testing -> updating -> merging -> done |
- The branch name,
epic/<key>-<slug>(epicBranchName()inepics.ts), is a contract withci.yml'spull_requestfilter (below) — both have to agree on the pattern. - The server computes it once per issue and hands it to dev-agent as
FACTORY_BASE_BRANCH(the same var the project's own base branch already used), which is what makes the fork point, update-to-latest, the merge step and factory-pr's PR base all move together. Flipping the box only affects work that hasn't landed yet. - The branch is cut by the epic's first member that needs it, not the
server (which has no checkout) — forked off the project's base branch and
pushed. A push origin refuses falls back to the project's base branch
rather than working against a base GitHub would later refuse a PR into.
That fallback is reported on that member's own thread, not the epic's —
factory-commentposts to the issue whose run is speaking — so an epic whose members quietly landed on the base branch shows nothing on its own thread to say why. - The epic issue's own work IS that branch: it reaches dev-agent/test-runner
as a second var,
FACTORY_WORK_BRANCH("" for everything else). dev-agent checks it out and never creates it — since a member cuts it first, a missing branch here means the usual fall-through would cut a fresh one off base and open a PR that reverts every member's work, so it stops instead. - The fork on Approve lives in the beat (
epicrun.ts'ssettleApprovedEpics), not a pipeline step: a step is keyed by(type, status)alone, so one onapprovedwould fire for a branchless epic too and race the close.testingtests the whole epic branch as ONE tree — something no member's own test run does;updatingfolds in a moved base and opens the PR;mergingwaits on the queue. It sharesqueue: mergewith ordinary member merges (AIF-250) — one per-project slot for both. - Two knobs in that pipeline look like oversights and are not. The
Testingstep setsauto_approve_status: ""explicitly, because it defaults toapproved— and on an autopilot epic a green run would jump there, straight back into the beat's fork, which would send it totestingagain forever. A human already approved this epic; there is no second gate to skip. And the epic type'serrorstatus — where the landing flow parks an epic it cannot proceed with at all, no branch on origin or no test script — is declared afterdoneon purpose.runningEpicStatus()derives "the status that means the factory is working this epic" as the FIRST active-category status by sort, and does not skip parked ones;errorsharesin_progress'simplementationcategory, so declaring it earlier would make it that status, pointing the member scheduler and the dashboard's Start button aterrorinstead ofin_progress.
A project turning epic_branch on needs its own CI to match. ci.yml's
pull_request trigger matches the PR's base branch, so a member's PR into
epic/<key>-<slug> is checked only if that filter includes epic/** (this
repo's does: branches: [main, "epic/**"]). Skip it on another project and
such a PR gets no checks at all — nothing red, because nothing ran — so
green-gate never appears, Mergify's check-success=green-gate is never
satisfied, and the issue sits on merging forever with nothing to point at.
7. Specialists: a standing agent that files its own epics
A specialist is a long-running domain expert attached to a project — one quality (performance, accessibility, architecture, test health, security, docs…) watched continuously. On a cadence it evaluates the codebase, records what it believes is wrong, files issues for the fixes, and lets the ordinary pipelines implement them. You set it up once, then check in to see what it did.
It is its own entity — a specialists row, its own page and nav item, its
own key from the same per-project sequence issues and epics draw from, its own
comment thread — that creates and manages epics as the visible unit of its
work. Every epic it opens is completely ordinary: normal members, normal branch
(§6), normal Approve, normal summary, normal close to done. Nothing about
epics changes. Everything specific to standing work — charter, budget,
autonomy, WIP, cadence, the findings ledger, the report — lives on the
specialist, which is what keeps it out of the epic.
It is also deliberately not an issue row of some type: specialist. A
specialist has no workflow status machine, no pipeline and no place on a board,
so being an issue would mean hiding it from every issue surface — and each of
those exclusions is a future bug.
The design that was dropped. The specialist used to be a long-lived epic that never closes, with a custom
evaluating/paused/over_budgettrack bolted onto the epic type. Because one epic branch had to survive being squash-merged intomainover and over, it needed a freeze → re-cut → replay → force-push dance to stay alive. Making the specialist its own entity removed the need for all of it: it opens a fresh ordinary epic, works it, lands it through the unmodified Approve flow, and opens the next one. There is no git mechanic here at all.
Domains
A domain is the area of the product an issue is work on — a per-project tag
on a real column (issues.domain_id, domains table), not a workflow property,
so it filters and indexes everywhere. null is ordinary human work. The slug is
the handle and is never silently suffixed (a collision is a 409); none is
reserved as the filter word for "no domain at all". An unknown domain narrows
to an empty list on a filter but is a 400 on a write — a dropped tag on a
create is silent data loss where an empty list is visibly empty.
Inheritance is resolved server-side, and the two callers are not symmetric:
| Caller | Rule |
|---|---|
an agent on a run token (issueapi.ts) | a domain in the request is ignored. The answer is the domain of the issue this run is for, else that issue's epic's, else the epic the new issue is filed into. |
| a human (dashboard, public API) | an explicit choice always wins — including a deliberate none. Inheritance only fills a blank. |
Caller lineage first is what makes the agent rule "you cannot escape your domain" rather than "you pick a domain by choosing an epic". A domain nobody chose is explained on the new issue's thread, folded into the creation note.
Epic ownership inheritance is the same argument one level up: an agent
working inside a specialist's epic — on the epic or on a member — files its
follow-ups into that same epic, because a follow-up that escapes it escapes the
WIP cap and the budget with it. Scoped to specialist-owned epics only (a plain
epic keeps today's loose behaviour), and unlike the domain an explicit --epic
wins rather than being ignored, since it only fills a gap.
Epic ownership and succession
The link is issues.specialist_id — issues, not epics: an epic is an
issue (§6), and the legacy epics table has been unread since schema v43. It is
set only on epic rows. specialists.active_epic_id is the other half of the
same fact, and setActiveEpic() writes both in one transaction, because
splitting that write across callers is how halves drift.
A specialist has at most one active epic — the one new findings are filed
into. Clearing the pointer drops the specialist's side only: the epic keeps its
specialist_id, which is a fact about who created it that stays true after the
specialist moves on, and is what lets a landed epic still be attributed.
Succession is one decision (decideSuccession) and runs on the beat: the landed
epic is cleared on the beat that notices. The beat never opens the replacement
(AIF-642) — the first member a cycle files does (openEpicForFiling, inside the
create route, after every check has passed), so a cycle that finds nothing leaves
no empty epic behind. A halted specialist
does nothing at all — not even the bookkeeping clear, because that would discard
the one fact a returning operator needs. The new epic is named <name> — pass
<n>, carries the specialist's domain, and gets epic_branch from the
specialist's landing. Landing itself is the unmodified Approve flow; the
epic-cadence policy can only ever delay that moment, never bring it forward.
The evaluation cycle
A cycle is a standalone run attached to no issue. It needs the whole run
apparatus — detached launch, heartbeats, output capture, the factory-*
helpers, usage and cost — but must be invisible as an issue, so it gets an
ordinary runs row whose issue_id holds the specialist's own uuid. That
is precedent, not a trick: the thread is keyed to comments the same way. What
falls out of it is the good part — invisibility is structural, because every
issue-scoped query either filters issue_id=? or INNER JOINs issues, so a
cycle run holds no step slot and cannot surface as an issue; and the helpers
work unchanged, since factory-comment posts to addComment(run.issue_id, …)
which is the specialist's thread. step_id is deliberately empty so a clean
exit does not try to bump the status version of an issue that does not exist.
The due check reports the first gate that applies, in the order an operator
would ask them: disabled → in_cycle → halted → wip → interval →
quiet → unchanged. Run now records a durable request and the beat
starts it (two launch paths is how a button and a due check come to disagree),
and it bypasses only the last three — budget, pause, retirement and the WIP cap
are all somebody having said stop. Probes run server-side before the agent,
because their output is one of the prompt's inputs.
The findings ledger is keyed (specialist, fingerprint) — not to the epic
it was found in, which is the whole reason a ledger exists instead of just issue
bodies: a fingerprint has to survive an epic landing and the next one opening.
Evidence is required (an opinion recorded as a finding is what makes a ledger
unreadable after a month), and a rejected fingerprint needs new_evidence to
come back, or a specialist would argue with its operator forever. Decisions —
and only decisions — append to specialist_finding_events, so a
reject → re-open → reject keeps both reasons.
A cycle that found nothing doubles the interval (consecutive_noops), capped at
a week. The cap is on the result, not the multiplier, so an operator who
configured a longer interval is never stretched past it by the backoff.
Governance: autonomy, WIP, budget
status is where the machine is; enabled is what the operator asked for.
They are separate so a specialist parked over_budget still remembers it is
wanted, and comes back on its own. active and evaluating are going;
everything else (paused, over_budget, error, retired) is halted, and
halted means no promotion, no landing, no succession.
| Axis | Effect |
|---|---|
autonomy | propose files members for a human to release and needs a human to land; auto lets the scheduler promote and land. Halted beats both — but propose is not a halt, so a human releasing a member by hand still works. |
max_running_members | How many members may execute at once. Counted in runs, not statuses — a human bulk-releasing ten members would otherwise read as ten against a cap of one and deadlock every one of them. |
max_open_members | How much may be open in the active epic. A read the cycle asks before filing, not a gate: a human may hand-file past it. |
| background priority | Specialist work sorts behind every issue a human is waiting on. Stable, and nothing is filtered — with the pool free everything still launches this beat, just in a different order. |
The budget ledger spans everything the specialist has ever cost: its own
cycle runs and every epic it has created, current and landed. That falls out
of one join (COALESCE(i.epic_id, i.id) resolves a member to its epic and an
epic to itself, plus a second arm for the issue-less cycle run) rather than
needing a history table. Periods are calendar-aligned in UTC — a trailing
window has no boundary, and both "warn once per period" and "back to active
when the window rolls" need one. 0 means no cap.
Enforcement is at stage boundaries, so a single run can overshoot the cap by
one run. That is a real property, not a rounding error: spend is only known
once a run reports its usage, so a run that started under the cap can end over
it. The UI says so in its footnote and shows the overshoot unclamped — "125% of
$50.00" is the fact an operator needs; clamping to 100% would hide how far past
it went. on_exhaust then decides what happens to work already moving:
finish_in_flight (default) lets it finish, pause_all stops it at the next
stage boundary. Either way a member that has never run is refused, because
finish_in_flight is a promise about work already started, not a licence to
start more — and the epic's own landing pipeline is exempt, since by then the
money is spent and refusing to land would strand every member's work.
Separately, protected_paths is a fence on what it may change. The filing
side tells the agent never to open work inside one; the scripted Testing stage
checks what the branch actually changed, before the suite (a green run
printed under a violation reads as approval of it) and parks the issue at
blocked rather than bouncing it — this is not something an agent iterates on.
The whole block is skipped when FACTORY_PROTECTED_PATHS is empty, which is
every issue no specialist owns, and that guard is what makes it safe to put in a
file every issue in the factory runs.
The report
The specialist's report rolls up what it has changed across every epic it has ever opened. Only history is stored: each landing freezes the epic's own write-up, each member's closing summary and the finding that justified it. The scorecard, what is in flight and what was turned down are computed on every read from rows the cycle already keeps — so a cycle refreshes them by doing its ordinary work, and a human rejecting a finding an hour later shows up at once rather than next cycle.
Every displayed string is frozen at landing and nothing on the read path joins back for text; ids are kept beside the frozen keys so links work while the rows live. Text frozen, links live — turning either back into a join would make a two-month-old report rewrite itself. No agent writes any of it: the only agent-authored words are the cycle's own summary line.
"Show me the delta since I last looked" is nothing more exotic than epics of
mine that landed since your last visit — a per-user last_viewed_at mark, and
never a filter on what is shown (the report reads the same for everyone; the
mark drives the badge). An empty mark honestly reads as "all of it is new".
?since=last_integration narrows the history to the most recent landing.
Gotchas
issues.specialist_id, neverepics.specialist_id. The legacy table is unread; writing there sets a field nothing reads.- A run may have no issue. Three places had to learn it — the cost ledger's
attribution join,
specialistOwningRun(so a cycle ending rechecks the budget), andreapIfDead(so a dead cycle is reaped instead of leaving the specialistevaluatingfor good). A fourth caller that assumesgetIssue( run.issue_id)is non-null is the next bug of this shape. - In that ledger,
e.type = 'epic'sits in the JOIN condition, not a WHERE. As a WHERE clause it throws the issue-less cycle arm away again — the one mistake the query's shape invites. - The one-specialist-per-domain index is PARTIAL (
WHERE status <> 'retired'). It has to mean one live one, or retiring would burn the domain forever — retirement being the only exit the table offers. - Read
status, notenabled, to decide whether it may act, and remembererroris in the halted set (the issue's own summary of the track omits it). - The interval backoff caps the result, not the exponent. Clamping the exponent tightly silently becomes the real limit, and "capped at a week" quietly stops being true for every short interval — which is exactly the bug its test found.
- Turning
branchlanding off is refused while the active epic still holds unlanded work. The epic's ownepic_branchcannot move with it (members have already opened PRs against that branch), so the switch would leave the specialist claiming per-member landing while its epic accumulates onto a branch nobody will land.abandonActiveEpicis the way through, and it leaves the epic alone — not cancelled, not deleted — because a half-finished epic that still exists is something a human can finish by hand. - Retirement is terminal and refuses to strand work, unless asked to abandon.
- A cycle's thread writes land on the specialist, not an issue, which is what lets an operator watch work appear on the page they are already on.
Dogfooding it on this repo
The eight built-in templates (config/specialist-templates/builtin.yaml, read
from disk on demand — they only pre-fill the New-specialist dialog, so there is
no row to keep in step) include Architecture and Accessibility, either
of which is a good first specialist here. A sensible first setup:
- Landing
branch, so each pass accumulates onepic/<key>-<slug>and lands as one reviewable unit. This repo'sci.ymlalready matchesepic/**, which §6 warns is the thing another project has to add. - Autonomy
propose— set it explicitly, becausebranchlanding defaults it toauto. You want to read the first few passes before the scheduler promotes and lands on its own. - A modest weekly budget (
budget_period: week, a lowperiod_budget_usd), which bounds the experiment whatever else is misconfigured. Remember it can overshoot by one run. - Leave the daily
min_interval_min(1440) andonly_when_changedalone, and keepmax_open_memberssmall.
A specialist is created paused and disabled whatever else you pass, so nothing runs until you resume it.
Where to read more
| Topic | Doc |
|---|---|
| The delivery pipeline, stage by stage | workflow.md |
| Status/workflow mechanics | statuses-and-workflows.md |
| Running on fly.io | §2 above, then the runbook in ../CLAUDE.md |
| Merge queues | merge-queues.md |
| Agent backends (Kimi, OpenAI) | agent-backends.md |
| Self-deploy / CI runner | self-deploy.md |
| Running a branch | staging.md |
| Ad hoc conversations | conversation.md |
| Linear sync | linear-sync.md |
| Specialists (API surface) | api.md |
| Agent git conventions | ../github/WORKFLOW.md |
| Quick start + operating | ../README.md |