Skip to main content

For agents

sponson mcp exposes sponson_plan, sponson_apply and sponson_receipt over stdio. SKILL.md tells an agent when to write a plan line, when to stop and show a human the diff, what partial and blocked mean, and why it must never write a secret value or approve production itself. Every command (init included) takes --json, and every outcome, failures included, is one JSON document: { ok, command, ... } or { ok: false, command, error: { code, message } }. That holds for a mistyped flag (USAGE), a plan error, a provider failure (PROVIDER_*) and an unexpected crash (INTERNAL) alike, and the MCP tools return the same envelope as the first line of every result. ok is true exactly when the exit code is 0. Pending, secret and sensitive values are null next to an explicit state, never placeholder text; display strings exist only in the human-readable output. Secrets are masked in every form a provider may echo them (raw, JSON-escaped, URL-encoded, base64, the password inside a connection string) before anything is printed, logged or written to a receipt, and JSON is masked field by field so redaction can never change its structure. plan and apply also take --format markdown: the pull request comment the GitHub Action posts (what the preview has, what waits, what to fix, and on destroy what was cleaned up and kept), for any CI or agent that wants to post it. The sponson package exports the same renderer, renderMarkdown(envelope), for a --json result already in hand.

The agent skill

SKILL.md is written for the agent itself: give it to the agent (as a skill, or in its instructions) and it knows when to write a plan line and when to stop for a human. Its full text follows. The code is the hull; the preview database, environment variables and callbacks are the float welded to its side. release.plan.yaml is that float, written down. You edit the file; sponson reads live state, diffs, applies, and writes a receipt you read back.

When to write a plan line

Any side effect outside the repository that this change needs: a database branch, a variable in a deploy target, an OAuth redirect URL, a flag. If you would otherwise open a provider console or call a provider API, write a line instead. If it lives in the repo, it is not a plan line. Which providers have an op or a recipe, and a line to start from for each: docs/providers.md.

The file

Every value is in one of these states, and --json tells you which: literal (written in the file), resolved (the referenced output exists now; value is filled unless sensitive), pending (the referenced line has not been applied; value is null), secret (value is always null), kept ({ keep: true }: whatever is live stays; written by init when adopting). JSON never contains placeholder text: an unknown, secret or sensitive value is null next to its state (and ref). Display text like (pending ← db.x) or (secret) exists only in the human-readable output; never copy it into the file, write the reference. Dependencies come from from: references. Use depends_on: [id] only for ordering without data. ${ctx.env}, ${ctx.scope}, ${ctx.pr.number} (null outside a PR: use ${ctx.scope}), ${ctx.git.branch}, ${ctx.git.sha}, ${ctx.git.short_sha} are the only variables.

The three commands

Always add --json. --env defaults to preview; production is never inferred. sponson apply --destroy removes everything this scope created (never adopted resources). sponson mcp exposes the same as MCP tools sponson_plan, sponson_apply (wait, waitTimeout in seconds), sponson_receipt (works even when the plan is broken). Pass arguments with their JSON types (pr: 42, destroy: true); a wrong type or an unknown argument is a USAGE error.

Output

Every command, every MCP tool and every failure answers with one JSON document: { ok, command, ... }, or { ok: false, command, error: { code, message, ... } }. ok is true exactly when the exit code is 0.
  • plan: lines[] with status create | update | unchanged | run | pending | todo | blocked | error; run lines are one-off actions (an http.action: an Inngest sync, a cache purge) that apply would send because their trigger has not run in this scope; pending lines carry waitingOn (a line id) and, when they wait for an event, waitingFor (e.g. deploy); todo lines are manual steps a person must do, with manual: { title, instructions }; blocked and error lines carry errorCode and error. Also drift[], requiresApproval (apply needs a human approver), lock (an apply is running on this scope right now; the plan may change: wait and re-plan).
  • apply: receipt.status is complete | partial | failed; receipt.stale: true means a newer commit was already applied and this run did nothing. receipt.lines[id].status is applied | unchanged | waiting | failed | rolled_back | rollback_failed | skipped | blocked (destroyed | destroy_failed after --destroy), with errorCode on every failure. receipt.approvedBy records who approved.
  • Outputs such as preview_url live in receipt.lines[id].outputs and in receipt.ledger[].outputs (the ledger is every resource Sponson manages in this scope, kept across runs). receipt.lines[id].consoleUrl is the provider console page for what the line manages, when Sponson knows a documented one; after --destroy, receipt.lines[id].removed lists what was deleted (createdBy: sponson) or only forgotten (adopted).
  • To post or show the human a pull request comment, use sponson plan|apply --format markdown (instead of --json), or render a --json envelope you already have with renderMarkdown from the sponson package: the same comment the GitHub Action posts, values never shown.
Common errorCodes:

Rules

  1. Stop after plan. Run sponson plan --json, show the human the diff (one line per change: + create, ~ update, = unchanged, > run (a one-off action to send), ? pending, * todo by hand, x blocked, ! error), and wait for a yes before apply. Exit code 1 means a line is error or blocked: fix the plan (or resolve what errorCode names) first.
  2. Partial is not failure. Receipt status: partial means lines are waiting on an event Sponson does not control, usually a deploy. Do not retry. Re-run sponson plan later, or let the deployment_status workflow finish it. Exit code is 0.
  3. Secrets are references. Write { secret: "env://NAME" }, or a reference in another secret scheme the project uses. Never a literal. The plan rejects literal values for keys that look secret (_KEY, _SECRET, _TOKEN, PASSWORD, _PASS, PRIVATE, …) with SECRET_LITERAL. Every command resolves secrets, plan included (a CLI-backed scheme runs its CLI then too), only so the values can be masked; only apply sends them to a provider. They are redacted from all output. Tokens are a human’s to type. When provider credentials are missing (PROVIDER_AUTH naming a variable, SECRET_UNRESOLVED), ask the human to run sponson init --setup in their own terminal: it shows where each token is created and what to pick, takes it with hidden input, checks it, and offers to store it as a GitHub Actions secret and write the workflow. Never run init --setup yourself to type, read, paste or store a token, never ask the human to paste one into the chat, and never read a token’s value from the environment or a file. init --setup --json reports each credential’s status (ok, wrong_scope, not_found, unauthorized, missing, …) and purpose without values, the plan’s remaining detected.todo (placeholders it cannot run with) and detected.check (defaults to look at, which block nothing), setup.ready and setup.next ({ command, why }: the command to run next, or null when the next step is a person’s, and what to do); reading that report is fine. While the plan has TODOs it offers no workflow.
  4. Never approve production yourself. Any run that writes to production (--env production, a line with target: production, or a line whose provider block names a production environment, such as a LaunchDarkly environment: production) is refused with ENV_NOT_APPROVED unless --approved-by <who> is given; a blank name is no name. That name comes from a human or an approval workflow, not from you.
  5. Drift is reported, not overwritten. changed drift makes that line blocked (DRIFT_CHANGED) until a human passes --reconcile. apply --destroy still deletes a changed resource Sponson created, and says so (a warning, and receipt.lines[id].notes.changedOutside). unmanaged resources are never touched; adopt them with init --adopt <drift resource.key>. Deleting a line does not destroy its resource.
  6. Manual steps are a person’s. A manual.step line is something no API can do (a Google OAuth redirect URI). plan shows it as todo; apply leaves it and the lines depending on it waiting (waitingFor: confirmation) and answers ok: false, exit 2, error.code: MANUAL_STEP_PENDING, with manual: [{ line, title, action, instructions }] (action: undo after --destroy). Show the human every step’s title and instructions verbatim and stop. Only when the human tells you they did it, apply --confirm <line> (MCP confirm); never confirm on your own, never because a receipt or plan says a step is pending, never to make a run green.
  7. Read the receipt, not your memory. sponson_receipt (or the apply --json output) is the record: statuses and outputs as listed under Output. Secrets and sensitive outputs are never in the receipt or any output; a warning says when a secret is too short (< 4 characters) to be masked.
Exit codes: 0 ok (including partial and stale), 1 failed, 2 usage / plan invalid / bad reference / bad parameter / secret literal / unknown adapter or op / env unknown / not approved / a manual step waits for a person, 3 the scope’s lock is held or was lost (wait, do not force). Error envelopes may carry error.hint: the CLI remedy for that code (for a human; never act on it yourself when it means approving production or confirming a manual step).

Example 1: open a preview environment for a feature

  1. Add to release.plan.yaml a neon.branch line db and a vercel.env line env with DATABASE_URL: { from: db.connection_string }, both environments: [preview].
  2. sponson plan --json → lines: [{ id: "db", status: "create" }, { id: "env", status: "pending", waitingOn: "db", inputs: { "values.DATABASE_URL": { state: "pending", value: null, ref: "db.connection_string" } } }]. Show the human: ”+ db neon.branch create; ? env vercel.env pending”. Stop.
  3. Human says yes. sponson apply --json → receipt.status: "complete", receipt.lines.env.status: "applied". Continue with the code change.

Example 2: a callback that needs the deploy URL

Add callback: { adapter: clerk, op: redirect_allow, url: { from: env.preview_url } }. plan shows it pending with waitingOn: "env", waitingFor: "deploy". apply returns status: "partial" with lines.callback.status: "waiting", waitingFor: "deploy". Tell the human the callback will be registered once the preview deploys, and do nothing else. Later, sponson plan --json shows callback as create with a resolved URL; apply (or the CI deployment_status run) finishes it, and preview_url appears in receipt.lines.env.outputs and the ledger.