Agora

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:

SignalAgent
CLAUDECODE=1Claude Code
GEMINI_CLI=1Gemini CLI
GITHUB_COPILOT_CLI_MODE=1GitHub Copilot CLI
WINDSURF_SESSION=1 or TERM_PROGRAM=windsurfWindsurf
CODEX_CLI=1 or CODEX_SANDBOX=1Codex
OPENCODE=1OpenCode
CURSOR_AGENT=1Cursor

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 outputInteractive prompts
Inside an agenton by defaultnever
Plain terminaloff by defaultyes
CI, or output pipedoff by defaultnever

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

  • --json is 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:share and the one-shot service shells.
  • No prompts, ever. sail:install normally 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 --volumes destroys 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 --follow with JSON fails, pointing at sail:logs --tail 100 --json; a bare sail:psql / sail:mysql / sail:redis fails under --json or 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 gets reused: true with the same local URL and the startedBy of 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 serve by hand, and nobody stops an app it did not start — whoever owns the end of the flow calls sail:serve --stop;
  • after a migration, an .env change 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:

CommandRe-run behaviour
sail:installAppend-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:upSilent 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:downStopping an already-stopped stack succeeds; only this worktree's compose project is touched.
sail:sync-envPure file sync, no Docker. Everything outside the # sail:start / # sail:end markers is preserved.
sail:pruneOnly 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:logsRead-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.md in this repo.
  • Distribution: the package's files array includes skills, so after installation it lives at node_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: the Check Skills workflow runs intent validate on PRs touching skills/**, and after a release intent stale opens 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 are base + worktree offset, so 5432 is only correct in the main checkout.
  • sail:up waits for healthchecks — sail:up && migration:run needs no sleeps — and it syncs the worktree's ports into .env.local (plus .env.test.local), which Adonis loads over .env on its own. Sync before migrating; no eval in 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.schema for shape (no secrets) and sail:info --json for 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:run

Read 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 propagates

Read 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 worktrees

See /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.

On this page

Partner

Your product as the official integration for Sail, right here.

Become a partner →