Sail turns bare-metal servers into isolated, spec-driven dev environments where AI coding agents do the work. Each project gets a hard-isolated container. The team's specs and project definitions live in a synced database. Agents are dispatched against those specs with rollback safety and cross-agent review.
Sail is not a coding agent. It is the layer around them: the environments they run in, the specs they pick up, the reviews they pass, and the shared state a team works from. It does for coding agents roughly what Kubernetes does for containers, orchestrating the work rather than performing it.
One static binary (Java 25, GraalVM native-image), no runtime dependencies.
curl -fsSL https://raw.githubusercontent.com/standardapplied/sail/main/install.sh | bashsail upgrade replaces /usr/local/bin/sail and converges the database; sail upgrade --binary <file> installs a local build the same way. Linux (amd64) runs a full host. macOS
(arm64) runs as a thin client that drives a remote host over SSH. Upgrade main first, then
nodes: sync refuses a peer on another sync protocol before any data moves and names the remedy.
Sail installs what it runs on and nothing else: Incus from Zabbly's stable repository, and
zfsutils-linux for a zfs pool. It holds the incus packages, so neither apt upgrade nor
unattended-upgrades restarts Incus, which would end every terminal and agent run in every
container. sudo sail host incus upgrade moves Incus when you choose: it lists the sessions and
agent runs the restart ends, then asks. The rest of the OS is yours to keep current; sail never
upgrades it. The needrestart policy sail installs (/etc/needrestart/conf.d/50-sail.conf) keeps
a library update from restarting Incus.
An org runs one main box. It holds the team's specs and project definitions in a SQLite control plane and is the source of truth. Every engineer also has their own box. A box pointed at main is a node: it pulls specs, project definitions and shared files from main, and pushes its own work back.
There is no GitHub in this loop and no separate board. The database is the board, and sail sync moves it over a locked-down SSH gateway using public-key auth, with three-way conflict
resolution so no one's work is overwritten. Compute is never scheduled across boxes. The star
coordinates state, not execution.
Spec bodies and shared files are content-addressed blobs, deduplicated and streamed; sail sync status reports health and sail sync gc compacts history. Three verbs remove work: archive
keeps everything, delete keeps history so sail spec restore brings it back, and prune
erases a spec everywhere, leaving one audit row. Retention is opt-in through a retention block
in main's host.yaml.
Main's database is the org. Back it up on main, and keep the copy off the box:
sudo apt-get install -y sqlite3
sudo sqlite3 /var/lib/sail/sail.db ".backup /root/sail.db.copy"A main set up again from that copy is the same main to every node. On the new main, run sudo sail init --as-main, then put the copy in place:
sudo systemctl stop sail-api
sudo install -m 660 -g sail /root/sail.db.copy /var/lib/sail/sail.db
sudo rm -f /var/lib/sail/sail.db-wal /var/lib/sail/sail.db-shm
sudo sail migrate
sudo systemctl start sail-apisudo sail migrate says "API token re-issued for this box: the database came from another box."
when the copy came from another box, and sudo sail spec list then works on the new main. Each node
then re-pins main's new host key (sudo ssh-keygen -R <main-ip>, then sudo sail init --main <main-ip>, which keeps the node's handle; compare the fingerprint it prints with sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on main), and its next sudo sail sync pushes whatever it
has not pushed yet. Work main took after the copy is lost with it and is not rebuilt from the node:
that sync drops what the node had pulled of it, says so once per type, and keeps each row in its
history (sudo sail spec history <id>). A main restored in place from an older copy is treated the
same way. A main set up empty is a new org: every node refuses to sync with it.
Mast, the desktop client, is a thin client of a box: specs, rooms and reviews over its API and event stream, terminals and files over its pty host.
Stand up the main. One idempotent command provisions the box, installs the control plane and declares it the source of truth:
sudo sail init --as-main --handle ada --name "Ada Lovelace" --email ada@example.com--handle makes you main's operator: the FDE dispatch picks this box's specs for, added to the
roster as admin with --name and --email, or with the git identity the box already has. It is
asked for when left out; a re-run keeps the handle the box already has and refuses another.
--name and --email also become the box's git identity when it has none (they are asked for
when left out, and init stops before any step without them), so the projects it applies commit as
you. An identity the box already has is kept.
Authorize each engineer with the line their sudo sail init --main printed. It carries the
fingerprint of main's host key that their box pinned, and main refuses the line when that is not
its own key:
sudo sail fde add mady --role member --key 'ssh-ed25519 AAAA... sail-sync@madybox' \
--main-host-key SHA256:...Join a node:
sudo sail init --main <main-ip> --name "Mady M" --email mady@example.com
# provision, install sail-api, pin main's SSH host key,
# generate this box's sync key, and print the fde add line
sudo sail sync # pull specs, projects and shared files from main
sudo sail sync status --json # stored health, last success, and any failure reasonA box set up by sudo sail init is root's: sail-api runs as root, whoever invoked sudo. Run
sail there with sudo; another account's plain sail is refused and names the sudo command to
run instead.
Hand a person their box in Mast with one string. On the box, as the account sail-api runs as
(root, through sudo, on a box sudo sail init set up), for the box's own FDE (its sync handle):
sudo sail fde pair ada --host 34.1.2.3 --email ada@example.com # prints a connect code, once
sudo sail fde unpair ada # revokes itThe code is everything Mast needs: where the box is and its host key, a login key made for this
pairing, and an API token for ada. It logs in as the account sail-api runs as, which sees
every project, container and secret on the box, so a box mints it for its own FDE and no other.
It is a secret that opens a shell on the box, so send it as you would a password. It lasts a year
unless --ttl-days or --no-expiry says otherwise, and its login and its token end together.
The pairing's key also becomes the workstation key the box's containers trust, from the moment
it is paired, with no second apply; a container it cannot reach is named with the apply that
fixes it. A box that already has a workstation key refuses unless --as-workstation-key says to
replace it, which takes the old key out of the containers too. Unpairing takes the key back out of
the login, the workstation key and every container.
A client, or any other engineer, gets a box of their own: set it up with
sudo sail init --main <main>, grant it its projects on main (sudo sail fde grant <handle> <project>), and pair on that box. A node pairs its own FDE as main's roster has it, so the
roster options (--name, --email, --role) are main's.
A scope changes in place on main: sudo sail fde update <handle> --scoped --grant <project>
narrows an FDE that joined unscoped, keeping its keys, tokens and sessions, and --unscoped
lets it see every project again. On its next sync its box holds what the new scope admits and
nothing else: a project it no longer holds leaves with its container, its directory and any
unpushed work, and so does the name and email of every FDE outside its projects.
Each project the box applies opens with a conversation already waiting: sudo sail project apply leaves the box's FDE a home room, <project>--<handle>, with the project's agent in it
(agent.member in the definition picks another installed agent, model or mode).
On a Mac or other thin client, sail client <host> points the local CLI at a box. Commands are
forwarded over SSH; no control plane runs locally.
A project is one definition in the catalog: runtimes, services, repos and agent config. The
catalog is the only copy, synced to every box with the project; there is no file to keep beside
it. sail project apply makes the container match the definition from any starting state: an
absent container is provisioned, a stopped one started, a running one converged in place. Run
it again any time.
sail project create web --cpu 4 --memory 12GB --repo https://github.com/acme/web.git
sail project apply web # make it real: create, start or converge, one verb
sail project edit web # the definition in $EDITOR, each changed section saved
sail project config get web agent
sail project connect web # print SSH config for your editorcreate with no flags asks for each field; --definition - reads a whole definition from
stdin. A definition is JSON, one section per key, and only name is required:
{
"name": "web",
"resources": {"cpu": 4, "memory": "12GB", "disk": "150GB"},
"image": "ubuntu/24.04",
"runtimes": {"jdk": 25, "node": "22"},
"repos": [{"url": "https://github.com/acme/web.git", "path": "web"}],
"services": {"postgres": {"image": "postgres:16", "ports": [5432]}},
"processes": {"web": {"command": "npm run dev", "workdir": "web", "port": 3000}},
"agent": {
"type": "claude-code",
"guardrails": {"max_duration": "4h", "max_idle": "20m", "action": "snapshot-and-stop"}
},
"pipeline": {"guardrails": {"max_duration": "45m", "max_idle": "20m", "action": "stop"}}
}Each section is written on its own, over the version it was read from: a section someone else changed meanwhile is refused, never overwritten unseen. A wrong value is refused naming the field.
The definition holds no identity. Every container commits as the git identity of the box that
provisions it (git config --global user.name and user.email; on a box sudo sail init set
up, root's, the one sudo sail init --name … --email … gave it) and trusts only that box's
key. Repos clone over https with the box's forge token; an ssh-style URL is refused naming its
https form.
The container's user is always dev (uid 1000, /home/dev): every agent, shell and hook runs
as it. Apply records each provisioning step for the definition it ran with, so an edit made
after a failed or finished run is provisioned again from the first step, each step installing
only what is missing.
sudo sail project demo spins up a bundled zero-config demo (an Outline wiki) end to end.
A process declares the command that starts the project's app and the port it listens on.
sudo sail project serve web brings the services and processes up, each process a
systemd --user unit in the container, and says what listens where, exiting 0 only when every
port listens; a process that exits says its last line. --stop stops the processes,
--json prints the ports. Mast's Portal button does the same in one click, forwards every
port that listens to the Mac's own localhost over the connection it already holds, and opens
the browser: no ssh -L, nothing installed on the Mac, nothing listening once Mast quits.
Agents get the same through sail's sail-portal skill (scripts/portal up, ports, log,
stop), whatever harness they run in.
Specs are the unit of work: a status, an assignee and dependencies, filterable across the whole
team. sail spec manages them on a box; agents inside a container use an in-container spec
CLI over a bound socket, so there is one source of truth and no sync glue.
sail spec create --project web --title "Stripe webhook" --assignee mady --depends-on oauth
sail spec board --project web # who is on what, and what is ready
sail spec dispatch --project web # pick the next ready spec, launch the agent, watch itdispatch honors dependencies and assignee, snapshots the container for rollback, creates the
work branch and launches the agent with its generated context. When the build stops, the review
loop runs: each stage of the definition's pipeline has a reviewer that checks the branch, a fix
agent addresses its findings, and the loop repeats up to max_iterations before escalating to
a person. Sail is agent-agnostic across claude-code and codex, so one agent can implement and
another review. Every run is held to its lane's guardrails: a wall-clock max_duration, a
max_idle stall window and an action; an agent past a limit is killed in its container and
the spec's room says why. A passing review parks the spec in awaiting_merge. Sail never talks
to the forge: merge the pull request there and close the loop with
sail spec update <id> --status done.
sail agent review <project> shows every attempt's iterations and findings; sail agent logs <project> --review (or --fix) follows the latest reviewer's or fix agent's run live. sail spec create --from-review <spec-id> drafts a follow-up spec from a review's open findings, and
marking that follow-up done resolves them.
Every spec has a room. Agents post progress there, read it before deciding, and ask with
spec comment <id> --question --body <text>, which pages the engineer on the board.
The loop is sail's: a build, then review stages, then a fix when a stage fails. What the
agents are told comes from two prompts sail writes and no project changes: the work prompt,
read by the build and by the fix run (the spec, the room, the findings, commit and push, a
pull request, CI green), and the judge prompt, read by every review stage (the spec, what to
review, the verdict contract). How this codebase is built, tested and verified is the
repository's own AGENTS.md to say; sail writes no context file.
The one agent-facing text a project writes is a stage's brief: a paragraph saying what
that stage judges, in the definition's pipeline section (sail project edit web --section pipeline). A stage with no brief judges
under sail's default (the spec first, then correctness, security and tests). A brief that
orders a skill makes a validation stage without new machinery:
{
"stages": [
{"name": "review"},
{
"name": "validate",
"brief": "Run the project's e2e skill against this branch and report every failure as a finding, with the command and its output as evidence. Pass only on a clean run.",
"gate": "all_clear"
}
]
}A skill is an Agent Skills folder: a SKILL.md whose
front matter names it and says what it does and when to use it, plus any scripts, references
and assets. Sail installs a project's skills whole, for every agent the project installs, at
provisioning, at project apply and before every launch, and syncs them to every box with the
project. A folder that breaks the standard's rules is refused where it is added, naming the
field. Share one from the devbox:
sail project skills add ./e2e --project web # .sail/skills/e2e/<every file>
sail project skills ls --project web # every skill an agent sees: shared, sail's, made by hand
sail project skills show e2e --project web # its SKILL.md
sail project skills rm e2e --project web # the folder goes at the next launch or applyOr from inside the project, where the work happens, with the skill command beside spec:
skill add ~/.agents/skills/java # a skill one agent had, now every agent's and every box's
skill ls # the same rows as aboveOnly a person shares or removes a skill: an agent sail launched is refused and asks you. A
copy of a shared skill made by hand in an agent's folder, holding nothing but the skill's own
files, is taken over by the next install. Sail ships one skill, spec-board, so an interactive
session finds the spec CLI.
- ARCHITECTURE.md: the design and its contracts, each row naming the tests that prove it.
- CONTRIBUTING.md: building, the quality gates, migrations, releases.
- AGENTS.md: the working conventions for people and agents in this repository.
- Every command has
--help; state-mutating commands take--dry-run, and all take--json.