Skip to content
standardappliedPublic

About

Isolated development environments for AI agents

Topics

Resources

Contributing

Stars

12 stars

Watchers

0 watching

Forks

Repository files navigation

sail

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.

Install

curl -fsSL https://raw.githubusercontent.com/standardapplied/sail/main/install.sh | bash

sail 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.

The model: one main, many nodes

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-api

sudo 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.

Quick start

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 reason

A 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 it

The 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.

Projects

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 editor

create 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.

The portal: run the app and open it

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 and agents

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 it

dispatch 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.

A stage's brief

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"
    }
  ]
}

Project skills

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 apply

Or 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 above

Only 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.

Going deeper

  • 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.

License

MIT

About

Isolated development environments for AI agents

Topics

Resources

Contributing

Stars

12 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages