These guides are primarily for agent readers. They explain how agents use Sails. If you are a person, start at sails.app or Add Sails.

Publish a site

The bash command is ship (not publish). ship create binds one folder to one URL. You run it once. Autosave redeploys that same URL on every checkpoint. There is no build command.

A directory with sails.site.json at the mount root compiles as a static site (.page.html / .page.md). Any other folder is served as files. Tailwind CSS and TypeScript/TSX are transpiled when served.

Done looks like

ship list shows a row whose public_url you can open. For a public site that URL returns 200 without sign-in. After you edit a page, the same URL shows the change. You did not create a second mount.

Worked example: public marketing site

Job: copy a template, put it on a public URL, confirm the URL, then ship a copy change without creating again.

cp -r /templates/static-site-demo ./site
ship create site --policy 644

<name> is required and unique in the repo. When --path is omitted, the folder defaults to <name>, so this ships ./site as mount site.

--policy 644 is public web. The default 600 is owner-only: everyone else is sent to /auth/login, then 403.

Expected output is JSON. Shape (not exact ids):

{
  "id": "…",
  "name": "site",
  "path": "site",
  "policy": "644",
  "public_url": "https://{id}.sails.app/"
}
ship list

Open public_url. That is the preview host. Then edit a page under site/pages/ and stop. Do not run ship create again. Do not run repo save as a ritual. Autosave already checkpointed; the mount tracks branch head.

See Static sites for the folder layout.

Who can ship

Actorcreate / update / delete / restore / promote / stageCustom --hostname / ship verify
Repository ownerAny path, including repo rootYes
write member / agentNon-root path they have Unix write on (and inside an agent jail)No
read memberNoNo

Agents can ship a preview URL for a folder they can write. Binding a custom hostname stays owner-only.

Flags

ship help
CommandWhat it does
ship create <name> [--path PATH] [--branch name] [--hostname host] [--policy MODE] [--mcp] [--stage-of LIVE]Create the mount once. Path defaults to <name>.
ship listJSON of mounts, including public_url
ship update <id> [--name NAME] [--policy MODE] [--stage-of LIVE]Rename or change policy. Cannot change hostname.
ship verify <id>DNS ownership check for a custom domain
ship analytics <id-or-name> [--since 7d] …Page views and custom events (any member can read)
ship stage <live>Copy live folder to {path}-staging (mode 0700) and link with --stage-of
ship promote <id-or-name>Three-way-merge staging folder into live (see .shipignore)
ship delete <id>Tombstone 7 days (ship restore). Avoid --now unless the URL should die
ship restore <id>Restore a tombstoned mount

--policy gates HTTP access to the whole mount (static files and *.api.sh). It does not rewrite per-file modes in the workspace.

PolicyWho can load the URL
644Anyone
640Owner or default-group members (others sign in, then 403)
600Owner only (default)

--mcp turns on Streamable HTTP MCP at /mcp for *.mcp.sh on that mount.

Custom hostname

Platform hosts (*.sails.app, *.localtest.me, *.localhost) include the org slug by default. ship create crm --hostname crm binds {org}-crm.sails.app, not crm.sails.app. Those hosts are auto-verified.

Custom domains do not get the org prefix. The first host on a new domain needs a DNS TXT. After that, this org can ship further hosts on that domain without another TXT.

--hostname host/prefix (example: {org}.sails.app/admin) mounts under a path. The host root must already be shipped by this repository. You cannot hang a prefix on another repo’s host.

ship update cannot change --hostname. To move a custom domain, delete the old mount and ship create the new one. Keep at least one verified host on the domain if you want to skip a new TXT.

Path on a host you already ship

ship create guides --path sites/sails-guides --hostname sails.app/guides --policy 644

Ship the host root first, then the prefix. sails.app/guides is a path on a host this repo already owns.

Custom domain TXT

Do not invent a TXT value. Sails prints a one-time token.

ship create site --path ./site --hostname example.com --policy 644
ship list
ship verify <id>

Until verify succeeds, public_url is still {published-path-id}.sails.app. hostname already shows example.com. Unverified JSON includes hostname_verification_txt as _sails-challenge.example.com = <token>. Copy the token (the part after =).

DNS (zone example.com):

FieldValue
TypeTXT
Name / host_sails-challenge
Valuethe token only

Then point the hostname at Sails: CNAME example.com → cname.sails.app. Apex domains often cannot CNAME; use ALIAS / ANAME / CNAME-flattening. ship verify checks ownership only — it does not wait for the CNAME.

After verify, public_url becomes https://example.com/ and hostname_verification_txt is omitted.

Staging → promote

Use a folder pair, not a hostname swap. Both URLs keep serving their own folders.

ship stage site                 # copy → site-staging (0700) + linked mount
# edit site-staging/; optional .env with KEY=ssec_…
# list .env in site/.shipignore if prod env must not be overwritten
ship promote site               # merge staging → live
# optional: ship delete site-staging && rm -rf site-staging

.shipignore at the live folder root is gitignore syntax and the only ignore source. .shipignore itself is never promoted. Do not default-ignore *.json — sails.site.json must promote. Conflicts fail closed: nothing applied, conflicting paths printed, non-zero exit.

Analytics

HTML page views are recorded automatically. Unique visitors use a first-party sails_vid cookie. Known crawlers are excluded from headline counts.

ship analytics site --since 7d --granularity day

Custom events (name and value, both strings, max 128 characters):

navigator.sendBeacon('/sails/analytics/events', JSON.stringify({ name: 'signup', value: 'pro' }))

On a path-prefixed URL, POST /prefix/sails/analytics/events or POST /sails/analytics/events with a same-origin Referer. Do not count that POST as a page view.

--visitor / --exclude-visitor filter by sails_vid. --visitors-limit 0 omits the visitor list (use that on a public dashboard). In a published *.api.sh, security: definer queries as the publisher; security: invoker needs a signed-in member. In published scripts only ship analytics is allowed — create / update / delete / list / verify stay blocked.

Failure modes

What you seeWhat it meansWhat to do
Visitors hit /auth/login then 403Policy is 600 or 640Public sites need --policy 644. Members-only is 640.
publish: command not foundThe binary is shipRun ship help. Do not invent a publish CLI.
Second ship create for the same folderYou tried to mint a “fresh” URLKeep the first mount. ship list is the URL.
ship delete --now then a new URLYou hard-deleted the old linksPrefer plain ship delete (7-day tombstone) unless the URL must die
Agent cannot --hostnameCustom hosts are owner-onlyOwner binds the host; agent can still ship a preview path
ship create denied for repo rootWrite members / agents cannot ship /Ship a subfolder they can write
Custom domain still shows *.sails.appTXT not verifiedCopy hostname_verification_txt, add the TXT, ship verify <id>
Prefix URL 404s on another repo’s hostPrefix only on a host this repo shipsShip this repo’s host root first
Promote fails closedMerge conflictResolve conflicting paths in staging; promote again
You ran repo save “to deploy”UnnecessaryAutosave already updated the mount

See Publish for the command summary. Forms on the same mount: Put a form on a site. Sign-in: Add sign-in to a site.