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
--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[]withstatuscreate | update | unchanged | run | pending | todo | blocked | error;runlines are one-off actions (anhttp.action: an Inngest sync, a cache purge) that apply would send because their trigger has not run in this scope;pendinglines carrywaitingOn(a line id) and, when they wait for an event,waitingFor(e.g.deploy);todolines are manual steps a person must do, withmanual: { title, instructions };blockedanderrorlines carryerrorCodeanderror. Alsodrift[],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.statusiscomplete | partial | failed;receipt.stale: truemeans a newer commit was already applied and this run did nothing.receipt.lines[id].statusisapplied | unchanged | waiting | failed | rolled_back | rollback_failed | skipped | blocked(destroyed | destroy_failedafter--destroy), witherrorCodeon every failure.receipt.approvedByrecords who approved.- Outputs such as
preview_urllive inreceipt.lines[id].outputsand inreceipt.ledger[].outputs(the ledger is every resource Sponson manages in this scope, kept across runs).receipt.lines[id].consoleUrlis the provider console page for what the line manages, when Sponson knows a documented one; after--destroy,receipt.lines[id].removedlists 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--jsonenvelope you already have withrenderMarkdownfrom thesponsonpackage: the same comment the GitHub Action posts, values never shown.
errorCodes:
Rules
- 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,xblocked,!error), and wait for a yes beforeapply. Exit code 1 means a line iserrororblocked: fix the plan (or resolve whaterrorCodenames) first. - Partial is not failure. Receipt
status: partialmeans lines arewaitingon an event Sponson does not control, usually a deploy. Do not retry. Re-runsponson planlater, or let thedeployment_statusworkflow finish it. Exit code is 0. - 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, …) withSECRET_LITERAL. Every command resolves secrets,planincluded (a CLI-backed scheme runs its CLI then too), only so the values can be masked; onlyapplysends them to a provider. They are redacted from all output. Tokens are a human’s to type. When provider credentials are missing (PROVIDER_AUTHnaming a variable,SECRET_UNRESOLVED), ask the human to runsponson init --setupin 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 runinit --setupyourself 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 --jsonreports each credential’sstatus(ok,wrong_scope,not_found,unauthorized,missing, …) andpurposewithout values, the plan’s remainingdetected.todo(placeholders it cannot run with) anddetected.check(defaults to look at, which block nothing),setup.readyandsetup.next({ command, why }: the command to run next, ornullwhen 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. - Never approve production yourself. Any run that writes to production (
--env production, a line withtarget: production, or a line whose provider block names a production environment, such as a LaunchDarklyenvironment: production) is refused withENV_NOT_APPROVEDunless--approved-by <who>is given; a blank name is no name. That name comes from a human or an approval workflow, not from you. - Drift is reported, not overwritten.
changeddrift makes that lineblocked(DRIFT_CHANGED) until a human passes--reconcile.apply --destroystill deletes a changed resource Sponson created, and says so (a warning, andreceipt.lines[id].notes.changedOutside).unmanagedresources are never touched; adopt them withinit --adopt <drift resource.key>. Deleting a line does not destroy its resource. - Manual steps are a person’s. A
manual.stepline is something no API can do (a Google OAuth redirect URI).planshows it astodo;applyleaves it and the lines depending on itwaiting(waitingFor: confirmation) and answersok: false, exit 2,error.code: MANUAL_STEP_PENDING, withmanual: [{ line, title, action, instructions }](action: undoafter--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>(MCPconfirm); never confirm on your own, never because a receipt or plan says a step is pending, never to make a run green. - Read the receipt, not your memory.
sponson_receipt(or theapply --jsonoutput) 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.
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
- Add to
release.plan.yamlaneon.branchlinedband avercel.envlineenvwithDATABASE_URL: { from: db.connection_string }, bothenvironments: [preview]. 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.- 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
Addcallback: { 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.