JSON output and next
The one JSON shape every command prints, the exit codes, and how the next list tells an agent what to run and when to stop.
Add --json to any command and it prints one JSON object on stdout, always with the same eight fields. An agent, or a script of yours, never has to read the human text. It acts on the exit code, the error code and the next list.
The envelope
Section titled “The envelope”This is what feature start printed for a new feature:
{ "schema_version": "0.15", "command": "feature start", "ok": true, "exit_code": 0, "data": { "name": "add-search", "branch": "add-search", "base": "ec0c2b31243a8187ba7129d311bd390c4ba711ee", "parent": "main", "checkout": [ ["git", "fetch", "shadow"], ["git", "switch", "add-search"] ], "checkout_hint": "git fetch shadow && git switch add-search", "preview": null }, "warnings": [], "next": [ { "command": ["ghostbranch", "gate", "add-search", "--repo", "you/project"], "why": "check out with `git fetch shadow && git switch add-search`, commit on add-search, push it with `git push shadow add-search` (the shadow repo, never origin), then gate it", "needs_human": false, "needs_input": [] } ], "error": null}| Field | What it holds |
|---|---|
schema_version |
The version of this JSON shape. It changes when the shape does. |
command |
The command that ran, such as feature start. |
ok |
true if the command did what it was asked. |
exit_code |
The same number the process exits with. |
data |
The command’s result. Its fields depend on the command. On an error it is usually {}. |
warnings |
Things worth knowing that didn’t stop the command. Always a list, empty when there are none. |
next |
The commands to run now. Always a list, empty when there’s nothing to do. |
error |
null on success. On a failure, the code and its details. |
The eight fields are always there, and the lists are never null, so a script can read them without checking first.
Ghostbranch is in beta, so the shape may still change between releases. Check schema_version if your script depends on it.
When a command fails
Section titled “When a command fails”Here an agent ran wait with a job ID for a feature that had no gate job yet:
{ "schema_version": "0.15", "command": "wait", "ok": false, "exit_code": 2, "data": {}, "warnings": [], "next": [ { "command": ["ghostbranch", "gate", "add-search", "--repo", "you/project"], "why": "the feature has no gate job to wait on; gate it", "needs_human": false, "needs_input": [] } ], "error": { "code": "JOB_NOT_FOUND", "message": "feature \"add-search\" has no gate job to wait on: it was never gated, or its last gate stopped before CI; gate it", "field": "job", "retryable": false, "details": { "feature": "add-search" } }}wait printed it on one line, as it prints everything with --json. It’s spread out here to read.
codeis the stable name to act on. Themessageis for people, and its wording may change.fieldnames the flag or input at fault, or isnullwhen no single input is.retryableistruewhen running the same command again may work, such as after a network error.detailsholds anything else the code documents, such asdetails.findingsfor a leak ordetails.plan_idfor an interrupted publish. It’s{}when there’s nothing.
Even a failure has a next, when there’s something to do about it.
Exit codes
Section titled “Exit codes”The exit code says what kind of outcome it was, so an agent knows how to react before it reads anything else:
| Exit | Meaning | What an agent does |
|---|---|---|
| 0 | Success. | Reads data and next. |
| 1 | It failed for another reason: GitHub, the network, a token, a lock. | Reads error.code, and retries if retryable is true. Some of these need a person, and next says so. |
| 2 | It was called wrongly: a bad flag, missing input or an unknown name. | Fixes the input error.field names, and runs it again. |
| 3 | The gate failed: CI failed or didn’t run, or the leak scan found something. | Fixes the feature and gates again. Never works around the scan. |
| 4 | A conflict the command won’t resolve on its own, such as a rebase conflict or a name already taken. | Resolves it, then runs the command again. A diverged shadow branch (SYNC_DIVERGED) goes to a person. |
| 5 | A person has to approve this. | Stops, and gives you the command in next. |
| 6 | The repos changed since the plan was made, so nothing was published. | Makes a new plan, from next. |
next: what to run now
Section titled “next: what to run now”Each entry in next is one command:
commandis the full command as a list of arguments, with--repofilled in where the command needs one. The agent runs it exactly as given. It’s a list, not a shell string, so nothing needs quoting.whysays what it’s for, in a sentence. It often says what to do first, as the start result above does.needs_inputlists flags only the caller can fill in, such as a pull request title. The agent adds them itself.needs_humansays whether a person has to run it.
People see the same list as Next: at the end of the human output. A flag in needs_input shows with a placeholder after the command. This is the end of a gate on a feature with two commits, so the publish needs a title:
With --json that’s one entry, with "needs_input": ["--title"]. The command list ends at you/project, and the agent adds --title "…" with a title it writes.
needs_human
Section titled “needs_human”When an entry has needs_human: true, the agent stops. It tells you the exact command and why, and it doesn’t look for another way round. In the human output, such a line ends with (a person must run this).
The same step can need a person on one machine and not on another. After promote --plan saves a plan, next is the apply that publishes it. Without an agent grant, a person has to run it:
{ "command": ["ghostbranch", "apply", "20261013T032248Z-ea7bcd"], "why": "publish the plan: a person runs this in their own terminal and types the code it shows (or allows agents first with ghostbranch agents allow)", "needs_human": true, "needs_input": []}If a person has allowed agents to publish to this repo from this machine, the same step comes with --yes, and the agent may run it:
{ "command": ["ghostbranch", "apply", "20261013T032248Z-ea7bcd", "--yes"], "why": "a person allowed agents to publish here; publish the plan", "needs_human": false, "needs_input": []}Both entries are built from Ghostbranch’s source, with a sample plan ID. The why texts are the binary’s own.
warnings
Section titled “warnings”A warning is something worth knowing that didn’t stop the command. It has a code, a message, and sometimes details. A warning never changes ok or exit_code. Here agents status ran in an agent’s session that was using a person’s login:
[ { "code": "AGENT_ON_PERSON_LOGIN", "message": "this looks like an agent's session (GHOSTBRANCH_ACTOR names an agent, a coding agent's marker variable is set, or no person is at a console), and it is using the person's stored login, which can publish; give the agent its own token, reaching only the shadow repo, in GHOSTBRANCH_TOKEN (see \"Tokens\" in ghostbranch guide)" }]An agent passes a warning on to you when it matters, such as this one, or a newer release (UPDATE_AVAILABLE). Without --json, warnings go to stderr as lines starting warning:.
stdout and stderr
Section titled “stdout and stderr”With --json, stdout holds only the JSON. Progress lines, such as “Reading … from GitHub”, go to stderr. Keep the two apart: 2>&1 would mix progress into the JSON and break it. Save stdout to a file if you want to keep the JSON.
explain
Section titled “explain”ghostbranch explain looks up any code, with no token and no network:
It takes an error code, a warning code or an exit number. An exit number also lists the codes that exit with it. ghostbranch explain --all --json lists every code, with its summary, causes, what to do and whether a person must act. The error lists on these pages come from that list.
wait streams lines
Section titled “wait streams lines”wait --json prints one JSON object per line while it waits for CI, so an agent can follow along. The last line is the result, the same envelope gate --json prints. The lines before it are events, each with a type:
{"schema_version":"0.15","type":"waiting","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:05Z","candidate":"39a2e1652d1eca0158b5a78537a3d57d5fa48fbe","shadow_pr":22,"shadow_pr_url":"https://github.com/you/project-shadow/pull/22","ci_timeout":"1h0m0s"}{"schema_version":"0.15","type":"check","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:07Z","name":"ci","status":"in_progress"}{"schema_version":"0.15","type":"check","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:03:07Z","name":"ci","status":"completed","conclusion":"success"}{"schema_version":"0.15","type":"settled","job":"gate/add-search/39a2e1652d1e","at":"2026-10-14T14:04:15Z","outcome":"passed"}{"schema_version":"0.15","command":"wait","ok":true,"exit_code":0,"data":{…},"warnings":[…],"next":[…],"error":null}The last line is shortened here: its data is the full gate result, and in this run it had one warning and one next entry.
waiting: CI has started on the gate candidate. It names the candidate commit, the shadow pull request and the time limit.check: one CI check changed, with itsname,statusand, once finished, itsconclusion.settled: CI is done.outcomeispassed,failed,no_ciortimeout, and a failure listsfailed_checks.
A script that only needs the result reads the last line and ignores the rest. With --timeout 10m, wait gives up waiting after ten minutes and reports checks: pending, with exit 0. The job keeps running: run wait again later. Long CI has more on jobs.
Feature states
Section titled “Feature states”status --json and feature list --json give each feature a state. It is one of draft, in_review, gate_passed, published, pr_behind, merged, merged_moved, pr_closed, branch_gone, archived, abandoned, parent_stale, public_pr_needs_update and unreadable. The repo pair says what each one means.
archived means the feature was handed off: its pull request is public, and fixes go straight to the pull request’s branch, not through the shadow repo. Its report also has an archived object, with the archive’s ref, the head it holds and when it was made. Promote and approval explains the hand-off.
What apply returns
Section titled “What apply returns”apply --json has data.after_publish. It says, for each feature it published, what happened to its shadow branch:
archived: handed off. The shadow branch was archived, and the feature’s state is nowarchived.kept: the shadow branch stays, and fixes go through it as before, withgateandpromote.incomplete: the publish worked, but the archive stopped partway. A warningARCHIVE_INCOMPLETEsays where, andnextgivesghostbranch feature archive <name>to finish it.
It is an object keyed by feature name, such as {"add-search": "archived"}, and never null. Under --yes or --non-interactive, apply archives unless you pass --keep.
Errors you may see
Section titled “Errors you may see”BAD_FLAGexit 2A flag, argument or option value is invalid.
Fix the value named in error.field.
INTERRUPTEDexit 1The command was interrupted (Ctrl-C) before it finished.
With error.details.plan_id the plan is saved and the publish may have started: resume it with ghostbranch apply <plan_id> (never promote again; a person runs it unless an agent grant exists). With error.details.job the gate's CI is still running in the shadow repo: run ghostbranch wait <job>. With neither, nothing is pending: run the command again.
JOB_NOT_FOUNDexit 2The feature has no gate job to wait on: it was never gated, or its last gate stopped before CI (a leak or a conflict).
Run ghostbranch gate <feature>, with --detach to get a job id.
JOB_STALEexit 4A newer gate replaced this job: the feature, public main or the overlay changed, and the feature was gated again.
Wait on the current job (error.details.current_job), or, if there is none, gate the feature again.
LOCKEDexit 1Another ghostbranch operation on this pair is running on this machine (error.details.held_by names it).
Wait a few seconds and run the command again (error.retryable is true; next repeats it, or waits on error.details.job). The lock frees itself when that process ends, even if it crashed; there is nothing to delete.
MISSING_INPUTexit 2Required input is missing.
Pass the input named in error.field, for example --title or -m for promote.