Agents
How sail behaves inside an AI coding agent — automatic JSON output, no prompts, idempotent commands with actionable failures, the managed AGENTS.md section and the shipped sail-basics skill.
An agent drives sail blind: no terminal to read, no prompt to answer, no spinner to watch. So every command detects the agent it runs under and switches to a machine contract — JSON on stdout, no prompts, and failures that name the exact command to re-run.
What counts as an agent
Detection is purely environment-based — no heuristics, no config. Sail considers itself inside an agent when any of these variables is set:
| Signal | Agent |
|---|---|
CLAUDECODE=1 | Claude Code |
GEMINI_CLI=1 | Gemini CLI |
GITHUB_COPILOT_CLI_MODE=1 | GitHub Copilot CLI |
WINDSURF_SESSION=1 or TERM_PROGRAM=windsurf | Windsurf |
CODEX_CLI=1 or CODEX_SANDBOX=1 | Codex |
OPENCODE=1 | OpenCode |
CURSOR_AGENT=1 | Cursor |
Every variable must equal the string "1" — true, yes or an empty value
do not count. TERM_PROGRAM is the one exception: it is compared against
windsurf.
Two things follow from that, and they follow different rules:
| JSON output | Interactive prompts | |
|---|---|---|
| Inside an agent | on by default | never |
| Plain terminal | off by default | yes |
| CI, or output piped | off by default | never |
An explicit flag always wins: --json forces JSON outside an agent, and
--no-json forces human text inside one.
The two rules are deliberately different. Prompting also needs a terminal on
stdin, so a CI job — no agent variable, no TTY — behaves like an agent for
prompts and never hangs waiting for input, while still printing human text
unless you ask for --json.
What changes in agent mode
--jsonis the default. Every command with a JSON shape prints it instead of prose:sail:install,sail:up,sail:down,sail:ps,sail:info,sail:logs,sail:sync-env,sail:prune,sail:serve,sail:shareand the one-shot service shells.- No prompts, ever.
sail:installnormally offers a multi-select when it detects nothing; under an agent it fails instead, printing the exact--services=flags to re-run with. - Confirmation flags are their own consent.
sail:down --volumesdestroys the stack's data, so it asks to confirm in an interactive terminal; under an agent (or CI, or a pipe) the explicit flag is the confirmation and the command proceeds instead of stalling. - No ANSI, no spinners. Sail renders no spinners or progress animations, and its human formatter emits plain text on purpose (the CLI UI also drops colour when stdout is not a colour-capable terminal) — nothing to strip before parsing.
- Streaming and REPLs are refused, not degraded.
sail:logs --followwith JSON fails, pointing atsail:logs --tail 100 --json; a baresail:psql/sail:mysql/sail:redisfails under--jsonor without a TTY, pointing at the one-shot form. No command ever blocks forever waiting on a terminal.
Per-flag detail for each command lives in /docs/sail/commands.
The contract an agent can rely on
stdout is one JSON document
In JSON mode a command writes exactly one pretty-printed
(JSON.stringify(payload, null, 2)) object to stdout and nothing else.
Failures use the same channel:
{
"error": "No compose file found at /app/compose.yml",
"hint": "Run \"node ace sail:install\" first to generate it"
}So an agent parses stdout unconditionally and branches on the error key —
there is no second format to handle. In human mode the split is the usual
one: logger.error / logger.fatal go to stderr, while success, info and
warning lines go to stdout.
sail:exec is the deliberate exception: it is a passthrough, so the
container command's captured stdout is printed verbatim (a JSON error
object is appended only when it fails). Use the one-shot shells when you want
the output wrapped in JSON instead.
sail:share --json holds to the same contract: one object
({ url, local, port, basePort, portOffset, resolvedFrom }, plus notice or
warning when the port resolution needed explaining) printed once the tunnel
registers. The process then stays alive until you kill it, with cloudflared's
own logs forwarded to stderr so stdout stays clean.
Several agents on one worktree
A dev agent, a reviewer and a QA agent taking turns on the same branch should
share one running app, not start one each. That is what
sail:serve is for, and the managed AGENTS.md block
says so:
- every agent calls
node ace sail:serve --by <agent>before it needs the app — the first call starts it, every later one getsreused: truewith the samelocalURL and thestartedByof the agent that started it; - calls that race are serialized by a per-worktree lock, so two agents that both found nothing running still end up with a single process;
- nobody runs
node ace serveby hand, and nobody stops an app it did not start — whoever owns the end of the flow callssail:serve --stop; - after a migration, an
.envchange or a dependency bump:--restart.
Exit codes
0 means success, including no-ops (nothing to prune, ports already in sync,
compose file already up to date). Any failure path sets a non-zero code
before printing, so && chaining is safe:
node ace sail:up && node ace migration:run only migrates once the
healthchecks passed.
Sail-level failures (missing compose file, unusable docker, flag conflicts,
refusals) exit 1. When a child process is what failed, its own code comes
back: sail:exec returns the container command's status, the one-shot shells
return the client's (sail:psql failed (exit 3): … exits 3), and sail:up
/ sail:down / sail:logs return docker compose's. So a failing query
fails your script with the status the query produced.
Idempotency
Every state-changing command is safe to retry:
| Command | Re-run behaviour |
|---|---|
sail:install | Append-only: merges missing services into an existing compose.yml, adds only absent env keys and validations, replaces its AGENTS.md section in place. Hand edits survive; the report says already up to date / unchanged. |
sail:up | Silent success on a live stack — the pre-flight port check subtracts the ports your own containers publish, so only a foreign holder fails it. |
sail:down | Stopping an already-stopped stack succeeds; only this worktree's compose project is touched. |
sail:sync-env | Pure file sync, no Docker. Everything outside the # sail:start / # sail:end markers is preserved. |
sail:prune | Only stops projects whose compose file is gone from disk; other apps' projects are never candidates. --dry-run is read-only. |
sail:ps / sail:info / sail:logs | Read-only. sail:info even works before install, falling back to the app scan to preview what install would create. |
sail:sync-env skips an encrypted .env.local instead of corrupting it: the
file is reported with action: "skipped-encrypted" and the command exits 1,
in JSON mode exactly as in human mode. sail:up reports the same skip but
still exits 0 — the stack came up — so there, read the envSync.files array
rather than the exit code.
Failures name the re-run
Sail's error hints are commands, not advice. The pattern is consistent:
{
"error": "Could not detect any service from package.json or config/*.ts",
"hint": "Re-run with explicit services, e.g. node ace sail:install --services=postgres --services=redis (valid: postgres, mysql, redis, mailpit, rustfs, seaweedfs, garage, minio)"
}Other examples: a missing compose file points at sail:install; a port
conflict names the containers holding each port and suggests sail:down in
the other worktree or sail:prune; a disabled service points at
sail:install --services=<name>; a bare REPL request points at the one-shot
form. An agent can usually recover by executing the hint verbatim.
The managed AGENTS.md section
sail:install writes (or refreshes) a section in the app's AGENTS.md,
delimited by <!-- sail:start checksum=… --> and <!-- sail:end -->:
<!-- sail:start checksum=0123abcd4567 -->
## Sail (local dev services)
- Start services with `node ace sail:up` (idempotent, waits for healthchecks); stop with `node ace sail:down`.
- Each git worktree gets isolated containers and shifted host ports. Never hardcode ports: run `node ace sail:info --json` for the actual ports, dashboards and connection env.
- Status and logs: `node ace sail:ps --json`, `node ace sail:logs --tail 100`.
<!-- sail:end -->The content is deliberately generic — no worktree names, no concrete port
numbers. AGENTS.md is committed and shared across machines and worktrees
while ports shift per worktree, so the section teaches the lookup instead
of the values. A unit test asserts the section contains no 4–5 digit number.
Update rules:
- No file, or an empty one: sail creates it containing just the section.
- File without the markers: the section is appended at the end, your content untouched.
- Markers present and the text between them still matches the checksum in the
start marker: sail wrote it and nobody changed it since, so it is refreshed
to the current wording — everything before and after is preserved, and a
re-run with no changes rewrites nothing (install reports
unchanged). - Markers present but the text was edited — translated, reworded, extended:
sail leaves the file byte for byte as it is and reports
kept (edited by hand — …). Your version is yours from then on, markers and all. - Markers without a checksum (written by sail 0.4 and earlier): refreshed only while the text is still the stock wording, kept otherwise.
- To get the stock text back, delete the section, markers included, and re-run install — it is appended fresh.
The sail-basics skill
Sail ships an agent skill in the package itself:
- Source:
packages/core/skills/sail-basics/SKILL.mdin this repo. - Distribution: the package's
filesarray includesskills, so after installation it lives atnode_modules/@adonis-agora/sail/skills/sail-basics/SKILL.md. Nothing copies it into your project — point your agent at that path, or symlink it into your harness's skills directory (for example.claude/skills/sail-basics). - Format: intent-compatible
frontmatter (
name,description,metadata.library,metadata.library_version,sources). CI validates it: theCheck Skillsworkflow runsintent validateon PRs touchingskills/**, and after a releaseintent staleopens a review PR when the skill has drifted from the library.
What it teaches, in one line each:
- Ask sail for ports (
sail:info --json): host ports arebase + worktree offset, so5432is only correct in the main checkout. sail:upwaits for healthchecks —sail:up && migration:runneeds no sleeps — and it syncs the worktree's ports into.env.local(plus.env.test.local), which Adonis loads over.envon its own. Sync before migrating; noevalin the normal flow.- Poll logs with
--tail/--since, never--follow(which refuses JSON). - Use one-shot shells (
sail:psql -- -c '…'), never the REPL: they run over the container network, so port offsets do not matter. - Under varlock, read
.env.schemafor shape (no secrets) andsail:info --jsonfor live values.
Recipes for agents
Boot a stack from scratch, non-interactively:
node ace sail:install --services=postgres --services=redis
node ace sail:up && node ace migration:runRead the live ports, connection env and container state:
node ace sail:info --json | jq '.appEnv'
node ace sail:info --json | jq '.services[] | {name, ports}'
node ace sail:ps --json | jq '.[] | {name, state, health}'Run a query or a command (put --json before --; everything after it
goes to the client):
node ace sail:psql --json -- -c 'select count(*) from users'
node ace sail:mysql --json -- -e 'show tables'
node ace sail:redis --json -- ping
node ace sail:exec redis -- redis-cli ping # exit code propagatesRead recent logs without streaming, and re-sync ports after switching worktrees (no containers touched):
node ace sail:logs --tail 200 --json
node ace sail:logs postgres --since 5m --json | jq -r '.logs'
node ace sail:sync-env --json | jq '.files'Tear down:
node ace sail:down --json # stop this worktree's stack
node ace sail:down --volumes --json # flag is its own confirmation
node ace sail:prune --dry-run --json # orphans from deleted worktreesSee /docs/sail/worktrees for how the offsets are computed, /docs/sail/commands for every flag, and /docs/sail/troubleshooting when a hint is not enough.
Varlock
How sail detects varlock, what it appends to .env.schema (typed, tagged, @public connection coordinates), and how the per-worktree port sync stays compatible with `varlock run --` and encrypted env files.
Commands
Complete reference for every sail:* ace command — arguments, flags, printed output, --json payloads, exit codes and the exact failure messages.