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.

Store a secret

API keys, signing secrets, and database URLs belong in encrypted platform storage. You keep an opaque ssec_… token in commands and config. The plaintext never goes in git, in a page, or in chat if you can avoid it.

Every secret is bound to a web origin (scheme + host, for example https://api.stripe.com). HTTP substitution decrypts the token only when the request URL matches that origin. The same token sent to a different host fails closed and does not leak the value.

upload / download are the wrong tool. Those links write ordinary workspace files.

Only repository owners can secret open / secret set. Members can secret list tokens they are allowed to see.

Done looks like

secret poll (or secret set) prints a token that starts with ssec_. A later curl or MCP header that contains that exact token succeeds only against the origin you set. secret list shows the token and its allowed origin.

Worked example: GitHub token for curl

Job: call GitHub as the owner without putting a PAT in the repo.

--origin is required when you create a secret. Use scheme + host only (and a non-default port if any). No path, query, or wildcards.

secret open --origin https://api.github.com github-token

Expected output (shape):

Open this URL to submit the secret:
  https://…/secrets/intake/…
Intake: 8c1e…
  1. Open the URL. Sign in as a repository owner on that intake page. A different user is rejected.
  2. Paste the PAT. Submit.
  3. Back in the shell:
secret poll 8c1e…
# prints: ssec_a1b2c3d4...

Use the token. Exact ssec_… is replaced at call time when the destination matches the origin:

curl -s -H "Authorization: Bearer ssec_a1b2c3d4..." https://api.github.com/user
# curl to a different host with the same token fails closed

Store ssec_a1b2c3d4... in MCP headers, webhook --secret, or a .db connection line, not ghp_….

Do not pass --wait from bash_exec. Same buffering problem as upload --wait: you never see the URL. Print, then secret poll INTAKE_ID.

Scripts: set from the shell

Plaintext goes through the session. Prefer secret open when a human is present.

secret set --origin https://api.example.com "sk-live-..."
# prints: ssec_…

# or
printf '%s' "$KEY" | secret set --origin https://api.example.com --stdin

Who can use the token

Default visibility is owner (the creator only).

secret list
secret chmod ssec_a1b2c3d4... --visibility members
secret chmod ssec_a1b2c3d4... --visibility group --group writers
secret unset ssec_a1b2c3d4...
VisibilityWho can use it
owner (default)Creator only
groupCreator + --group NAME
membersAny repository member

secret chmod / secret unset: creator or repo owner.

To rotate plaintext without changing the id (so MCP headers keep working), use Admin → repo Settings → Secrets → Set value on that token.

Origin binding

Create always takes --origin. Existing tokens that have no origin cannot be used in HTTP until you set one:

secret chmod ssec_a1b2c3d4... --origin https://api.example.com

HTTP substitution fails closed when the destination origin does not match, the secret has no origin, or you lack access to the token. Errors never include the secret value.

SSH identity keys (ssh -i ssec_…) and foreign Postgres DSNs (createdb --url ssec_…) decrypt without an HTTP origin check (they never go over curl). The origin still blocks using that same token in HTTP to another host.

Where substitution happens

Exact ssec_… matches are replaced when the platform makes the outbound call:

  • curl URL (whole-token only), -H values, and -d / --data bodies
  • MCP server headers / auth and tool --json arguments (destination = MCP server URL)
  • git HTTPS --token / URL userinfo (destination = remote URL)
  • Webhook --secret and similar config
  • SSH identity: ssh user@host -i ssec_…
  • Foreign Postgres: createdb app.db --url ssec_…

Handlers also auto-source a folder .env of KEY=ssec_… (or plain values). Tokens stay unsubstituted until an outbound call. repo env secret refs stay as ssec_… in the process environment so curl -H "Authorization: $KEY" still goes through origin checks.

OAuth MCP (mcp connect) and integration connect store their own tokens. You do not secret set those.

Common uses

TaskExample
MCP server with API keysecret set --origin https://mcp.example.com "…" then mcp add … --header "x-api-key=ssec_…"
Stripe webhook signing secretsecret set --origin https://api.stripe.com "whsec_…" then webhook add … --secret ssec_…
GitHub HTTPS PATsecret set --origin https://github.com "ghp_…" then git clone … --token ssec_…
Any outbound HTTP from bashPass ssec_… in headers or body; curl resolves it only for the allowed origin

Flags

secret help
CommandWhoWhat it does
secret open --origin URL [LABEL]ownerPrint intake URL immediately
secret poll INTAKE_IDownerWait for the browser submit; print ssec_…
secret open --origin URL --wait [LABEL]ownerBlock until submitted. Avoid in bash_exec
secret set --origin URL VALUEownerStore from the shell; print ssec_…
secret set --origin URL --stdinownerRead the value from stdin
secret listmemberTokens you can see (includes allowed origin)
secret chmod ssec_… --origin URLcreator or ownerSet or replace the allowed origin
secret chmod ssec_… --visibility …creator or ownerChange who can use it
secret unset ssec_…creator or ownerRevoke

--timeout SECS applies to secret open / secret poll.

Failure modes

What you seeWhat it meansWhat to do
Create rejected: missing --originOrigin is requiredsecret open --origin https://api.example.com LABEL
curl / MCP fails closed after createDestination host ≠ secret originMatch --origin to the real API host, or secret chmod ssec_… --origin …
Token has no originCreated before origin bindingsecret chmod ssec_… --origin https://api.example.com
Intake page rejects youIntake needs a repository ownerSign in as an owner on the printed URL
secret open deniedOwner-onlyAn owner runs it, or elevate that goal
secret poll times outNobody submitted, or timeout elapsedsecret open again
You cannot use that ssec_…Visibility or missing accesssecret list; secret chmod if you should share it
You committed sk- / whsec_ / a DSNWrong storesecret set --origin …, put ssec_… in config, rotate the leaked value
You uploaded a .envWorkspace file, not a secretsecret open --origin …; delete the file

See Send a file when the job is a CSV or zip, not a credential. See SSH to a host when the secret is an SSH identity key.