The fleet file — factory.yml

The host-level file: one per host, owned by the host owner. It names the host and the repositories every engineer’s pod on that host holds, each at a pinned ref, and who may hold them. A repository’s own container settings are not here — each repository carries its standard dev-container file. Where it all runs is the host spec the fleet points at. From nothing to a first pod: Quickstart.

# yaml-language-server: $schema=https://clanker.net/schema/factory-fleet.v2.json
version: 2
host: ./hosts/dev-host.yml # or a repo:/stack: reference, or the host spec inline
pod: # present → one POD per engineer; absent → one container per repo
  default: clankerlabs/CLANKERNET # what a fresh pod shows first
repos:
  - url: https://github.com/clankerlabs/clankerengineer
    ref: develop # a branch (resolved at every read) or a 40-character sha
    devcontainer: .devcontainer/devcontainer.json # the default
    access: { github: { org: clankerlabs } } # REQUIRED — who may hold a pod with it
  - url: clankerlabs/CLANKERNET # the bare slug is the same repository
    ref: main
    access: { github: { org: clankerlabs } }
  - url: clankerlabs/ClankerLabs
    ref: main
    devcontainer: false # no container: a clone every member of the pod can see
    access: { github: { org: clankerlabs } }
sync:
  schedule: "0 6 * * *" # when the daily fleet-sync runs, optional
  • Schema: https://clanker.net/schema/factory-fleet.v2.json.
  • Validate: factory validate factory.yml. The report names the host, every enabled repository with the sha its ref resolved to, the file that was read and its ssh port, and every enabled: false entry as skipped. --offline makes no network call (entries are listed unresolved with a warning); --json gives the same as data.

What a pod is

With pod: present, every engineer who provisions on this host holds ONE pod named after them: a member for each repository that has a dev-container file, a workspace clone for each one that does not — all behind ONE public name and ONE address on your private network.

flowchart TB
  subgraph pod["Pod dev-alice — one address on your private network"]
    direction TB
    door["Port 22 → the <b>selected</b> member"]
    m1["Member <b>clankerengineer</b><br/>its database, its ssh port 2201<br/>runs /workspace/clankerengineer"]
    m2["Member <b>clankernet</b><br/>its ssh port 2202<br/>runs /workspace/clankernet"]
    ws["<b>/workspace</b> — the shared root, seen by every member<br/>clankerengineer · clankernet · clankerlabs (a workspace clone)<br/>CLAUDE.md · AGENTS.md · the <code>clanker</code> agent"]
    door -. currently .-> m2
    m1 --- ws
    m2 --- ws
  end
  public["<b>dev-alice.your.zone</b><br/>and marketplace-dev-alice.your.zone …"] -- "the selected member" --> door

Members share one network: they reach each other on localhost, and every port a member declares is a port of the whole pod (which is why two members cannot declare the same number). Each member keeps its own storage — its clone, its database, its keys — and every checkout is a sibling in every member: a member’s clone is also mounted, read-write, at /workspace/<name> in every other member, beside the workspace clones. So from any member ls /workspace lists every repository of the fleet; the files are the ones each member runs. What stays a member’s own is its runtime — its processes, ports, hooks and tmux session. (pod.siblings: false keeps each clone inside its own member; repos[].pod.sibling: false keeps one clone out.) Nothing is lost on a teardown; only purge removes storage.

The shared root is also a project for the coding agents: it carries a CLAUDE.md you may edit, an AGENTS.md generated at every boot (every repository, its path, its ssh port, which one the public name shows), a clanker agent that works inside one repository, and the coordination server. A session started at /workspace fans out one clanker per repository and closes with the checks each repository names; a session inside one repository follows that repository’s own instructions. The members’ secrets are denied to that root session on purpose.

Without pod:, the fleet keeps the single-container shape: one container per engineer per repository, each with its own name and address.

Fields

Strict throughout: an unknown key is an error, not a silent no-op.

version (required)

2.

host (required)

Where the fleet runs — a host reference in one of three forms, or the host spec inline:

FormResolved from
./hosts/<name>.ymla file relative to the fleet file
repo:<owner>/<repo>@<ref>:<path>.ymlthe host owner’s repository at that ref (a private one needs the workflow’s checkout_app_from)
stack:<org>/<project>/<stack>the host’s own published, non-secret description
an objectthe host spec itself, in place

--host on the command line overrides it: a host id, a path, or a reference.

repos[] (required, at least one)

KeyTypeDefaultMeaning
urlrepository URL or owner/name—The repository; the URL and the bare slug are one identity, and a repository listed twice is refused
refbranch/tag or 40-hex sha—Where the dev-container file is read. A branch means “whatever it is at read time” — the run summary records the sha it resolved to; pin a sha for reproducibility
devcontainerrepo-relative path, or false.devcontainer/devcontainer.jsonThe file to read at ref. false makes the entry a workspace: no container runs — the repository is cloned at /workspace/<name>, where every member sees it (needs pod:). A branch ref is fast-forwarded on every start and sync; a sha holds it at one commit
accessobject— (required)Who may hold a pod with this repository on this host, and how they reach it. Below
enabledbooleantruefalse keeps the entry in the file but out of every read: not fetched, not converged. In a pod fleet its member leaves every pod like a removed one (storage kept), and it still counts for the ssh port table, so its port waits for it
gatewaybooleanfalse (absent)true makes this the coordination repository: the people who can push it are the people who may sign in to the host’s coordination server, and in a pod fleet the FIRST flagged, enabled entry is where the pod coordinates — every member’s CLANKBASE_WORKSPACE is its name (as its checkout, /workspace/<name>), whichever member boots (several flagged: the first wins, validate warns, and pushers of any of them may sign in; none flagged: each member keeps a workspace named after its own repository). A host spec with a clankbase: block needs at least one flagged, enabled entry (host up and host converge refuse otherwise, validate warns). A host without that block needs none. The key keeps its original name
pod.sshPort1–65535, not 222200 + the entry’s place among container entriesThe member’s ssh port on the pod’s address (needs pod:). Pin it to keep the port across a reorder; two entries on one port are refused
pod.siblingbooleantrueWith pod.siblings on, this clone is mounted in every member at /workspace/<name>. false keeps this one clone inside its own member (which still sees the others)

The file is read at the sha the ref resolved to, so a push between two reads cannot straddle a run. A repository with no dev-container file at its ref fails naming the path, the ref and the sha:

fleet repo `acme/api`: no `.devcontainer/devcontainer.json` at ref `develop` (b2c…) — every fleet repo carries its dev-container file at the pinned ref

access

Who may hold a container of this repository on this host, and how they reach it:

KeyTypeDefaultMeaning
github.orglogin—Only members of this organisation may provision. Needs an org-read token by name in the host’s secrets store
github.userslogin[]—An explicit allow-list; needs no token
ssh.keySourcegithub-keys | sshid | listgithub-keysWhere authorised keys come from. github-keys = the login’s published keys
ssh.users.<login>.sshidstringthe loginsshid.io username override
ssh.users.<login>.keysstring[]—Static keys, for list
tailscale.hostnametemplate (single label)first label of the default hostnameThe container’s name on your private network — single-container fleets only. In a pod fleet the pod has ONE address (pod.tailscale.hostname) and this key is a warning asking you to remove it

Required, with github.org or github.users inside it. “Who may hold a pod on this host” is a grant of the host owner’s compute, so the host owner states it, in the host owner’s file — a repository cannot grant itself a host, which is why access is refused inside a dev-container file and why an entry without it is an error rather than an open door.

sync

KeyTypeDefaultMeaning
schedulefive-field cron expression—When the daily fleet-sync re-reads every repository at its ref and converges every pod on the host

pod — one pod per engineer

KeyTypeDefaultMeaning
hostnames.defaulttemplate ending in .{zone}dev-{user}.{zone}The pod’s one public name, answered by the selected member. Must contain {user}; {repo} is refused (one name for the whole pod). Its first label names the pod
hostnames.porttemplate ending in .{zone}{port}-dev-{user}.{zone}Every other public port of the selected member, by the port’s name. Must contain {user} and {port}
tailscale.hostnametemplate (single label)dev-{user}The pod’s ONE name on your private network. Must contain {user}. Every member’s ssh is a port on it
defaultowner/namethe first container entryWhat a fresh pod shows before anyone selects. Must be a container entry (a workspace has nothing to show)
siblingsbooleantrueEvery member’s clone mounted read-write in every member at /workspace/<name> — one engineer, one user, one trust domain (every member must declare the same remoteUser). false keeps each clone inside its own member

All keys are optional — pod: {} takes every default. The pod’s templates name every member; a member’s own file may repeat them or leave them out, but a file declaring different templates is refused, because it would be routed under names it never gets.

Reaching your repos

A pod has ONE address on your private network and ONE public name. The onboarding card (the job summary of your provision) prints all of this for your own pod; the shape, for an engineer alice on the host above:

PortReaches
22the selected member, whichever it is
2201clankerengineer
2202clankernet
ssh developer@dev-alice                # :22 → the selected member → its tmux session
ssh -p 2201 developer@dev-alice        # clankerengineer, whatever is selected
ssh -p 2202 developer@dev-alice        # clankernet
mosh developer@dev-alice               # the selected member
scp -P 2202 file dev-alice:~/clankernet/

Every member runs its own ssh service on its port, authenticates you with its own entry’s access.ssh.keySource, and drops you into its workspaces tmux session at the shared root — where a session works across every repository. Every repository of the pod is a tmux session of its own, named by the repository and opened in /workspace/<name> (so cd .. is the root): this member’s own session runs the Procfile’s processes, each other one holds a login shell in its directory. prefix s lists them, Alt+Left/Right steps between them, prefix Tab returns to the last one, and the right of the status bar reads <you>@<member>; services.tmuxSession names the session of a standalone container only. The pod shares one host key, so a switch never changes what your client sees. The card also prints a ~/.ssh/config block so each member has a name of its own:

Host dev-alice
  HostName dev-alice
  User developer
Host dev-alice-clankerengineer
  HostName dev-alice
  User developer
  Port 2201

Framing a pod

The app can show your pod’s public name in a frame, and nothing else can. On a host whose clankbase: block names an appOrigin, every public route of every pod answers with Content-Security-Policy: frame-ancestors <origin>, where <origin> is that appOrigin (the app’s own origin), and the response carries no X-Frame-Options header. A host without one renders none of this, so nothing may frame its pods.

The router can set a header but not add one only when the app has not: on a pod’s routes the router’s Content-Security-Policy replaces the one your app sends, and an X-Frame-Options your app sends is dropped. An app that needs its own content policy for the page itself cannot keep it behind a pod route; leave framing to the router and do not set either header in the app. The rule is written into a pod’s routes when it is provisioned, so an existing pod gets it only after its next provision. The app’s own pages stay unframeable, and its content policy lists the pod zone as the one place it may frame.

The app derives the name to frame itself: dev-<signed-in login> under one of the pod zones it is built with (client/src/pod.js), exactly as planPod names the apex, and frames it only when that is one label under a pod zone. Nothing about it is written into the pod’s record.

Switching

select moves the public name (the apex and the port names) and the port-22 front door to one member. Nothing restarts; established ssh sessions keep their connection; only the next ssh dev-alice lands on the new member.

sequenceDiagram
  actor A as alice
  participant CN as member clankernet
  participant CE as member clankerengineer
  participant P as public name
  Note over P: shows clankernet
  A->>CN: factory select clankerengineer
  CN-->>P: route + port 22 now point at clankerengineer
  Note over P: shows clankerengineer — a few seconds, nothing restarted
  A->>CE: ssh dev-alice (port 22)
  Note over CN: still running, no public route<br/>ssh -p 2202 reaches it

Two ways, one result:

  • From inside any member: factory select <name> (factory select alone lists the members and the current selection). No credential leaves the host; a member can only pick from its own pod.
  • From the workflow: action: select with entry: owner/name. The run then checks that the public name answers 200 on the member’s health path and that port 22 reaches it.

The member that is not selected keeps running, and its public PORT names keep answering (a public port label belongs to one member of the pod, so nothing contends for it); only the apex — and the path-routed ports on it — moves with the selection. DNS never changes on a switch: every member’s public names are published once, at provision.

Keeping a host converged: fleet-sync

fleet-sync (the daily job from sync.schedule, or on demand) converges every pod on the host to the fleet file. For each pod, each repository in the fleet is compared with what the pod holds:

flowchart LR
  r["A repository in the fleet…"]
  r --> u["unchanged, or only its sha moved"] --> ku["member keeps running<br/>same container, same session"]
  r --> c["its dev-container file changed,<br/>or a new image was published"] --> rc["member keeps running, reported <b>stale</b><br/>the engineer's own provision applies it"]
  r --> a["added"] --> j["joins every existing pod<br/>nobody re-dispatches provision"]
  r --> d["removed or disabled"] --> l["member leaves every pod<br/>storage kept, its public names pruned"]
  r --> n["no longer allowed for this person"] --> o["left out of their pod<br/>never torn down"]

Three rules protect people. fleet-sync never restarts a running container — nobody consented to that: a member whose dev-container file changed, or whose image the run pulled past (a new release), keeps running and its pod is reported stale, naming the container and why; the engineer applies it with their own provision, when it suits them. A moved commit alone is never a change — the clone lives on the member’s storage and sync fast-forwards it. A pod whose owner cannot be checked (the lookup failed rather than answered “no”) is reported as failed and nothing runs on it. And someone allowed for nothing is skipped and reported, never torn down — offboarding is an admin’s explicit teardown with user: (Quickstart). The run summary lists every pod with its members, their shas and ports, the selection and the outcome.

An engineer’s own sync is the same idea for one pod: every member fast-forwards its clone and re-runs its updateContentCommand, every workspace pulls — fast-forward only, never a reset, so a commit you made in a workspace clone is never lost.

Refusals, in one place

ConditionResult
version other than 2error
host neither a reference form nor a valid inline specerror
repos empty, or the same repository twice (either spelling)error
url not a repository URL or owner/nameerror
ref with whitespace, .., a leading -//, or a .lock suffixerror
devcontainer absolute, climbing (..), or not .jsonerror
access missing, or access.github missing / with neither org nor userserror
access.tailscale.hostname with an unknown placeholder or a doterror
sync.schedule not five cron fieldserror
any unknown key at any levelerror
a repository with no dev-container file at its ref (online)error
a ref that cannot be resolved (online)error
--offlinewarning — nothing fetched; a repo:/stack: host also skipped
devcontainer: false or pod.sshPort without a top-level pod:error — a workspace or a port means nothing without a pod
pod.hostnames.* without {user}, with {repo}, or not ending in .{zone}; port without {port}error
pod.tailscale.hostname without {user}, or with a doterror
pod.default not listed, or naming a workspace entryerror
two container entries on one ssh port (a pin colliding with a positional default counts)error
pod.sshPort = 22 (the front door)error
(online) pod.sshPort = 5432, 6379, 8079 or 9000 (the built-in services)error — one database, one cache, one object store per pod
in a pod fleet, per-entry access.tailscalewarning — ignored; the pod has one address
(online) services.postgres, services.redis or services.s3 in two memberserror — one database, one cache, one object store per pod
(online) a port number declared by two members, or equal to a member’s ssh porterror — every declared port is a port of the whole pod
(online) a public port name declared by two memberserror — its hostname routes for every member at once
(online) a member’s name reserved (net, redis, redis-rest, s3, s3-init, routes, egress), all digits, or shared with another member or workspaceerror — customizations.clankernet.repo.name renames it
(online) a workspace named like a file of the shared root (agents.md, .claude, …)error — it would be mounted over that file
(online) pod.siblings on and two members declaring different remoteUserserror — one user is what makes a shared tree safe
(online) a member’s name equal to any member’s public port nameerror — one label space in the pod’s routes
the coordination (flagged) entry is a container with pod.siblings: false or its own pod.sibling: falseerror — the pod coordinates around that checkout, so every member must see it
(online) a member’s file declaring hostnames different from the pod’serror — the pod’s templates name every member
(online) a member declaring the standard docker-in-docker featurewarning — unavailable in a pod; the member runs without it
(online) the members’ summed hostRequirements over the hostwarning