Streams
So
graph plan run report '{"project":"X"}' > report.md captures a clean report while progress stays visible in the terminal.
One sanctioned exception: under GRAPH_EVENTS=github, a failing plan run prints a ::error:: workflow command to stdout (GitHub Actions only parses annotations from stdout). This happens only on failure paths — the nonzero exit code already tells automation that stdout is not a clean deliverable — so a CI step needs no shell wrapper to surface why a gate fired.
Exit codes
For assertion-style automation, put the condition in the plan as an exit gate and branch on exit 4 — that’s how the CI checks work.
An
ask step never introduces a new code. A plan that cannot reach a human runs its declared when_unanswered path: default carries on and exits 0, fail is an ordinary step failure and exits 1. Scripted callers get the unattended behaviour automatically — prompting requires stdin and stderr to be terminals, and is disabled outright under GRAPH_EVENTS=jsonl, which owns stderr.
A non-zero code never truncates what was already written: the command finishes normally and the code is applied on the way out, so a --json envelope on stdout is complete even when the exit code is 1, 3, or 4. MCP servers are shut down on every one of those paths.
JSON envelopes
--json on ask and plan run buffers and emits a machine envelope:
"exit": {status, message, reason?, step} to the envelope.
--json — their stdout is JSON:
Listing envelopes
Every listing command answers--json with the same shape: a named array plus a count that matches it.
count is 0. When there is a reason the list is empty — no servers configured, no threads yet — it arrives as a note field rather than as advice on stderr, so a program gets one parseable thing and a human still gets the hint. Without --json, that hint is all there is, and stdout stays empty.
Authoring envelopes
The plan authoring commands follow the same opt-in rule:--json puts an envelope on stdout, and without it a one-line result goes to stderr with stdout left empty. A program driving these should always pass --json.
plan validate --json reports every layer at once, and separates the fatal from the merely local:
problems is what makes ok false and exits 1. notes never do: a plan whose requires_servers names a server this machine lacks is portable and correct, just not runnable here.
Every mutating command (new, draft, set, unset, step *) reports where it wrote and what remains wrong:
1 with the envelope still on stdout, so the problem list is always machine-readable:
availableSteps (an unknown step id, listing what exists), renamedFrom (an identifier change wrote a new file and left the original), and salvaged / failedStep (draft saved a valid prefix after drafting ran out of retries).
The tool catalog
graph tools list --json is the companion read: what a step’s tool_name may be, resolved against this machine’s actual configuration.
name is the namespaced form a plan step must use; bare names (plan_and_execute) report "source": "(core)". description is whole, not the first line the text listing shows — it is what a caller routes on. readOnly is null when the tool declares no hint. An empty catalog is a valid envelope ("count": 0), not an error.
Schemas are deliberately not included — a few hundred tools would bury the names, which is what a caller enumerates for. hasOutputSchema tells you whether one exists; graph tools show <name> --json is the per-tool read:
inputSchema is what a plan step’s input object must satisfy. Every key is always present and null when absent, so a caller can address .outputSchema without probing for it first. An unknown tool name is an ordinary error — message on stderr, exit 1, no envelope — because it is a bad argument, not a domain rejection.
outputSchema and outputExample are what a tool declares, and most declare nothing. For what a tool has actually been observed to return, read the shape cache with graph shapes show <name>.
Inference cost by invocation
For anything scheduled or scripted, prefer
plan run — determinism and cost move together. The per-construct breakdown (gates, map bodies) is in Execution model.
Event feed: GRAPH_EVENTS=jsonl
With GRAPH_EVENTS=jsonl, stderr switches from human progress to one JSON object per line — the machine-parseable run feed. Answer text still streams to stdout. Events:
Token-level solver deltas are deliberately not emitted.
Environment variables
Data locations
Concurrency
Concurrent graph processes can share onedata_dir: file writes are atomic and message appends are serialized per thread. Use GRAPH_STORAGE=memory when a job should leave no state behind. See Storage for the exact guarantees.