Quickstart

From nothing to a developer pod on your own host, in your own accounts: a standard dev-container file in the repository your team builds, two files in the repository of whoever holds the host, one workflow that calls ours, the secrets your host spec names, and the web app people sign in through. Nothing else. What comes out is a mobile-first cloud development environment with Claude Code and Codex set up on every machine.

flowchart LR
  a["1 · Dev-container file<br/>in each repository"] --> c
  b["2 · Host spec + fleet file<br/>in the host owner's repository"] --> c
  c["3 · One workflow<br/>that calls ours"] --> d["host-up<br/><i>the owner, once</i>"]
  d --> e["provision<br/><i>each engineer, once</i>"]
  e --> f["A pod per engineer<br/>public name · private address · ssh per repo"]

What “your own accounts” means, and what we never hold, is Bring your own cloud — read its bill of materials first, because a host owner without a DNS zone stops at step 2.

1. The dev-container file — what a container of your repo is

.devcontainer/devcontainer.json, at the root of the repository that gets the containers, in the standard location, so the same file opens the repo in any editor that understands it. The standard fields describe the container; what the standard cannot express goes under customizations.replicator. Every key is in The dev-container file; the schema for the block is published at https://clanker.net/schema/devcontainer-customizations.v1.json.

{
  "image": "ghcr.io/acme/hello:factory", // prebuilt by your own CI
  "forwardPorts": [3000],
  "portsAttributes": { "3000": { "label": "web" } },
  "postCreateCommand": "npm ci",
  "customizations": {
    "replicator": {
      "$schema": "https://clanker.net/schema/devcontainer-customizations.v1.json",
      "ports": { "web": { "public": true, "health": "/healthz" } },
      "services": {
        "postgres": {
          "databases": ["hello"],
          "exports": { "DATABASE_URL": "hello" },
        },
      },
      "env": { "secrets": ["OPENAI_API_KEY"] },
    },
  },
}

image is required and prebuilt — Replicator never builds on the host. forwardPorts plus a label name the ports; the block says which are public, how they are probed, which built-in services to start and which secrets to deliver by name.

Two things are on without a line in this file:

  • Every public name is behind the GitHub sign-in your host runs (step 2’s gateway: block). A port that must answer without a browser sign-in — a webhook, an OAuth callback — says "auth": "none" under its label.
  • The pod’s outbound traffic goes through its egress proxy, which lets through what every pod needs (git and the GitHub APIs, the npm and PyPI registries, the OS package mirrors, the two coding agents, your host’s coordination server) and refuses the rest. A repository that calls anything else lists it under "egress": { "proxy": { "allow": [...] } } — that list then replaces the default one, so repeat what you still need — and a secret named under egress.proxy.secrets never enters the pod at all: the pod holds a token the proxy swaps for the value on the way out.

2. The host spec and the fleet file — where it runs, and what runs there

Both live in the repository of whoever holds the cloud accounts — the host owner. The host spec (Host specs) says where; every credential in it is a secret:NAME into the secrets store the spec names, and no value is ever in the file.

# hosts/acme.yml
id: acme-hel1
namePrefix: acme-dev
compute:
  provider: hetzner
  hetzner: { serverType: cpx22, location: hel1, tokenFrom: secret:hetznerToken }
ingress:
  provider: cloudflare-tunnel
  accountId: <your account id at the DNS and edge provider>
  tokenFrom: secret:cloudflareApiToken
dns:
  zones: [acme.example]
  tokenFrom: secret:cloudflareApiToken
access:
  provider: tailscale
  tailscale:
    tag: tag:dev
    oauthFrom:
      {
        clientId: secret:tailscaleClientId,
        clientSecret: secret:tailscaleClientSecret,
      }
secrets:
  provider: replicator # your host's own encrypted store, kept beside its state
state:
  provider: replicator
  replicator: { accountId: "0123456789abcdef0123456789abcdef", app: acme-host } # your account id at the edge provider
image:
  pull: { tokenFrom: secret:ghcrPullToken } # only if the images your host pulls are private
gateway: # the sign-in every pod name is behind, and the pods' coordination server
  host: api.acme.example # under one of dns.zones
  imageTag: "v1.29.0" # an exact release of the gateway image

That is the reference shape: a server with no public port. It is one of several. replicator init host --profile <own-server|aws|hetzner> --id <id> --zone <zone> writes the file for a server you already run on any cloud, for one cloud account that holds everything, or for the shape above, and prints the secrets it still needs by name; replicator secrets import stores them from a private file (Starting a host).

The gateway: block is not optional for a pod with public names: replicator validate refuses a public port on a host without one (the port would answer anyone), unless that port says "auth": "none". It runs a service on your host that needs two things of yours, each a secret NAME in the store the spec names:

  • a GitHub App installed on the fleet’s repositories with Contents and Workflows read & write (Members read too, if access names an organisation). It is the host’s one GitHub credential: who may sign in (push access to a fleet repository), the short-lived tokens pods clone and push with, and the browser sign-in of the app of step 4 — gatewayAppId, gatewayAppPrivateKey, gatewayGithubClientId, gatewayGithubClientSecret. One command creates it with a single approval in your browser and stores all four: replicator setup github-app --host hosts/acme.yml --org acme (Starting a host). Then install it on the fleet’s repositories;
  • the broker key every pod’s capability is signed with, which you mint: replicator secrets rotate GATEWAY_BROKER_SECRET --host hosts/acme.yml --yes.

Every option is in Host specs, “gateway”.

The fleet file (The fleet file) says which repositories the host runs, at which ref, and who may hold them. With pod: present, every engineer gets ONE pod on the host: a container for each repository that has a dev-container file, a shared clone for each one that does not, one public name and one private-network address for all of them:

# clanker.yml
# yaml-language-server: $schema=https://clanker.net/schema/clanker-fleet.v1.json
version: 1
host: ./hosts/acme.yml
pod: {} # one pod per engineer; the defaults name it dev-<login>
repos:
  - url: https://github.com/acme/hello
    ref: main # a branch, resolved at every sync; or a 40-character sha
    access: { github: { users: [octocat] } } # who may hold a pod with this repo
  - url: https://github.com/acme/docs
    ref: main
    devcontainer: false # no container — a clone at /workspace/docs, seen by every member
    access: { github: { users: [octocat] } }

Staging, production, static sites and storage for a repository are described in its own environments file and run by the same engine; you do not need it to bring a host up.

replicator validate clanker.yml tells you what the files refuse — including what each listed repository’s dev-container file refuses — and replicator plan --user <login> lists every secret NAME the host’s store must hold before the first run. (Both run from a checkout of clankerlabs/CLANKERNET: node packages/engine/bin/replicator.js ….)

3. The workflow — one job that calls ours

In the host owner’s repository. Pin the 40-character SHA on the uses: line and in replicator_ref, and let your dependency bot move both.

name: Replicator
on:
  workflow_dispatch:
    inputs:
      action:
        type: choice
        default: provision
        options:
          [
            provision,
            select,
            sync,
            teardown,
            purge,
            fleet-sync,
            host-up,
            host-converge,
            host-down,
          ]
      entry:
        description: "select: the repository (owner/name) the public name should show"
        default: ""
permissions:
  contents: read
jobs:
  replicator:
    uses: clankerlabs/CLANKERNET/.github/workflows/replicator-deploy.yml@17933491a148e977561a1d025dd2c0c1bfade9a6 # v1
    permissions:
      contents: read
      packages: read
    with:
      action: ${{ inputs.action }}
      entry: ${{ inputs.entry }}
      host: ./hosts/acme.yml
      replicator_ref: 17933491a148e977561a1d025dd2c0c1bfade9a6 # v1
    secrets:
      REPLICATOR_STATE_TOKEN: ${{ secrets.REPLICATOR_STATE_TOKEN }}

4. The app — where people sign in

The web app is the chat: people and the agents in their own pods, in channels, direct messages and threads, with overview and review screens for what the agents changed. It is also the sign-in. A browser that opens a pod name without a sign-in is sent to https://app.<zone>/auth/pod, so the web app has to be served at app.<zone>, talking to your gateway.host. It is a static build plus one edge worker on the same DNS and edge provider your host spec already names; by default the host does not serve it. Once, create the project, attach app.<zone> to it, and set its variable API_ORIGIN to https://<gateway.host>; then, for each engine release you pin, build and publish it from that checkout:

# once
npx wrangler@4.107.0 pages project create acme-app --production-branch main
#   then attach app.acme.example to it and set API_ORIGIN=https://api.acme.example

# per pinned release, from the engine checkout at the SHA you pinned
(cd client && npm ci && npm run build)
cp client/pages/_worker.js client/pages/_headers client/dist/
npx wrangler@4.107.0 pages deploy client/dist --project-name acme-app --branch main

Or let the host serve it and publish nothing: gateway.app.serve: host in the host spec serves the same app from the server image at app.<zone>, once the release you pin carries it (Host spec, “The web app on the host”).

Until it answers, a pod’s names refuse everyone: provision says so with a warning naming app.<zone>.

What each action does

flowchart TB
  subgraph owner["Host owner"]
    hu["host-up<br/>create the host — once"] --> hc["host-converge<br/>after a spec change"]
    fs["fleet-sync<br/>bring every pod up to the fleet file — daily or by hand"]
    hd["host-down<br/>when it is over"]
  end
  subgraph dev["Each engineer"]
    p["provision<br/>my pod — once"] --> s["select<br/>which repo the public name shows"]
    p --> sy["sync<br/>fast-forward every repo"]
    p --> t["teardown<br/>stop; keep my data"]
    t --> pu["purge<br/>remove my data too"]
  end
WhoActionWhat happens
host ownerhost-upCreates the host in your accounts, once. host-converge re-applies the host after a spec change or an image bump; host-down removes it (it refuses while the host still provides a database or bucket)
host ownerfleet-syncConverges every pod on the host to the fleet file: a repository added to the fleet joins every existing pod; a removed one leaves. It never restarts a running container — a change that needs one is reported stale for the engineer’s own provision. Runs daily from sync.schedule, or on demand
engineerprovisionOnce. The person who clicks owns the pod; a repository their login is not allowed is simply left out. The onboarding card — public name, private address, an ssh port per repository, a ready-made ~/.ssh/config block — lands in the job summary
engineerselectMoves the public name (and plain ssh on port 22) to the repository named in entry, in seconds, restarting nothing. The same switch is replicator select <name> from a shell inside the pod
engineersyncFast-forwards every repository in the pod at its ref and re-runs its update command
engineerteardown / purgeStops the pod and keeps its data; purge removes the data too
org adminteardown / purge with user:Offboarding: an admin of the organisation named under access removes a departed member’s pod. The dispatcher’s role is checked, never the target’s membership

Nobody provisions twice; a repository added later reaches every pod through fleet-sync.

What “provision succeeded” actually verifies (2026-09-23): every member’s container booting and its sshd answering is not the same claim as its app working — a member can boot cleanly and never start its own dev stack. So provision also curls every member’s own default port at its declared health path, from inside the pod’s shared network namespace, unconditionally (not just the repository select will show publicly); a member that never answers HTTP 200 fails the run by name, not silently. You can run the same check yourself at any time: curl https://<port-label>-<your-pod>.<zone>/<health-path> for a member’s own labelled port, or https://<your-pod>.<zone>/<health-path> for whichever member is currently selected.

If something the workflow reads is private

A host spec in a private repository, or a private copy of the engine: create a read-only token (Contents: read on those repositories) and pass it as the optional FACTORY_GITHUB_TOKEN secret of the caller. The workflow uses it for the engine checkout and for private host or fleet files, masks it, and never puts it in an argument. A public engine and a host spec in your own repository need none of this.

Rather than keep a token as a repository secret, store a GitHub App’s id and private key as values in your own store and name the stage in the caller’s with: as github_app_from: <app>/<stage> (and the account that holds it as github_app_account). The run reads them with only the state token, mints a one-hour, read-only installation token and uses it for the same purposes. The names default to gatewayAppId and gatewayAppPrivateKey (github_app_id_name, github_app_key_name).

secrets:
  REPLICATOR_STATE_TOKEN: ${{ secrets.REPLICATOR_STATE_TOKEN }}
  FACTORY_GITHUB_TOKEN: ${{ secrets.FACTORY_GITHUB_TOKEN }}

The secrets you hold

Which ones is the host spec’s decision. The workflow’s secrets are all optional, and a missing one is reported by name.

state.provider / secrets.providerCI secrets you holdWhere the values live
replicator / replicatorREPLICATOR_STATE_TOKEN — oneEncrypted in your own account, beside the host’s state; the token opens that store
any / factory-secretsFACTORY_SECRETS, ONE JSON object of name → valueThe JSON secret itself, in your repository settings

REPLICATOR_STATE_TOKEN is an API token for the account that holds your state, scoped to it. replicator secrets set NAME (value on stdin) writes a value into the store the spec names.

Keeping the pins honest

replicator doctor .devcontainer/devcontainer.json --host <id> is the offline skew report: whether your caller’s uses: and replicator_ref: are one 40-character SHA, whether your base image is this engine’s major, and every secret NAME the host references. A float or a disagreement exits 1, so it can gate a CI step.

What runs on your runner, and what we can reach

Everything runs in your CI, under your permissions: the workflow checks out your repository and our engine at the SHA you pinned, validates the files, runs the one action, and reads the host’s four non-secret outputs back. Every secret is an environment variable on the steps that need it, never an argument. It asks for contents: read and packages: read, nothing more. Nothing on our side can reach your host; revoking us is deleting the caller workflow (what we hold).

@v1 — the floating release tag — is what a first try may use. It is not what you run in anger: a float is code you have not read yet.