> ## Documentation Index
> Fetch the complete documentation index at: https://graph.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Scripting contract

> Streams, exit codes, JSON envelopes, events, and environment variables

graph is built to be piped. This page is the whole machine-facing contract: what lands on which stream, what exit codes mean, the JSON shapes, the event feed, and the environment variables that control it all.

## Streams

| Stream     | Carries                                                                                                                                                                          |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **stdout** | the deliverable only — streamed answer text, an output-mode plan's JSON, or a `--json` envelope. Nothing else.                                                                   |
| **stderr** | everything human: tool activity, progress markers (`✎ planning…`, `✎ synthesizing…`, `↻ replanning`), solver streaming in chat, hints, and [`ask` step](/plans/ask-step) prompts |

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

| Code | Meaning                                                                                                                                   |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `0`  | success                                                                                                                                   |
| `1`  | failure — step/tool error, config error, unresolvable tools, `empty_data` on output/silent plans                                          |
| `3`  | **needs input** — required plan inputs missing/invalid; the input schema prints to stderr so the caller knows what to send                |
| `4`  | **assertion fired** — an [exit gate](/plans/exit-gates) ended the plan with an error state; infrastructure is fine, the condition is real |

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](/cookbook/ci-checks) work.

An [`ask` step](/plans/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:

```bash theme={null}
graph plan run project_status '{"project":"X"}' --json
```

```json theme={null}
{
  "answer": "…solver report…",     // null for output/silent plans
  "output": { "count": 12, … },    // null for solver plans
  "plan": "project_status",
  "steps_executed": 4
}
```

A fired exit gate adds `"exit": {status, message, reason?, step}` to the envelope.

```bash theme={null}
graph ask "…" --json
```

```json theme={null}
{
  "content": "…",
  "tool_calls_made": 3,
  "usage": { "input_tokens": 2055, "output_tokens": 100 },
  "thread_id": "62dac762e251"
}
```

Output-mode plans don't need `--json` — their stdout **is** JSON:

```bash theme={null}
graph plan run urgent_issues | jq -e '.count > 0' || echo "all clear"
```

### Listing envelopes

Every listing command answers `--json` with the same shape: a named array plus a `count` that matches it.

```bash theme={null}
graph plan list --json     # { plans, skipped, hidden }
graph tools list --json    # { tools, count, sources }
graph threads list --json  # { threads, count }
graph shapes list --json   # { shapes, count }
graph mcp list --json      # { servers, count }
```

An empty listing is a valid answer, never an error: the envelope still parses and `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](/reference/cli#authoring-plans) 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:

```json theme={null}
{
  "plan": "report",
  "steps": 3,
  "ok": false,
  "problems": ["step E2 references E5, which is not `input` or an earlier step"],
  "notes": ["step E1: tool 'linear__list_issues' needs MCP server 'linear', which is not configured under [mcp.linear] (declared in requires_servers)"]
}
```

`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:

```json theme={null}
{
  "ok": true,
  "id": "E2",
  "index": 1,
  "steps": 2,
  "savedTo": "./.graph/plans/report.yaml",
  "preExistingProblems": ["plan has no steps"],
  "note": "edit applied; the plan is still invalid, but only from pre-existing problems …"
}
```

A rejected edit exits `1` with the envelope still on stdout, so the problem list is always machine-readable:

```json theme={null}
{
  "error": "edit rejected — it would introduce new validation problems (the draft is unchanged)",
  "problemsIntroduced": ["step E2 references E1, which is not `input` or an earlier step"]
}
```

Other keys worth branching on: `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.

```json theme={null}
{
  "tools": [
    {
      "name": "linear__list_issues",
      "source": "linear",
      "description": "List issues in a team or project …",
      "readOnly": true,
      "hasOutputSchema": true
    }
  ],
  "count": 1,
  "sources": [{ "source": "linear", "count": 1 }]
}
```

`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:

```json theme={null}
{
  "name": "linear__list_issues",
  "source": "linear",
  "description": "List issues in a team or project …",
  "readOnly": true,
  "inputSchema": { "type": "object", "properties": { "team": { "type": "string" } } },
  "outputSchema": { "type": "object" },
  "outputExample": null
}
```

`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](/tools/shape-cache) with `graph shapes show <name>`.

## Inference cost by invocation

| Invocation                                       | LLM calls                                         |
| ------------------------------------------------ | ------------------------------------------------- |
| `plan run` (output/silent mode)                  | **0**                                             |
| `plan run` (solver mode)                         | **1**                                             |
| `ask` → direct tool calls                        | 2+ (agent rounds)                                 |
| `ask` → plan tool                                | 3 (agent, solver, agent)                          |
| `ask` → [`plan_and_execute`](/plans/the-planner) | 4+ (agent, planner, solver, agent; +1 per replan) |

For anything scheduled or scripted, prefer `plan run` — determinism and cost move together. The per-construct breakdown (gates, map bodies) is in [Execution model](/architecture/execution-model#cost-table).

## 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:

| `event`               | Payload                                | Emitted when                                               |
| --------------------- | -------------------------------------- | ---------------------------------------------------------- |
| `tool_started`        | `tool`, `args`                         | any tool call begins (plan steps, body calls, agent calls) |
| `tool_finished`       | `tool`, `ms`, `is_error`               | that call completes                                        |
| `agent_round`         | `n`                                    | the agent loop enters tool round *n*                       |
| `planning`            | —                                      | the planner starts authoring                               |
| `replanning`          | `attempt`                              | a replan attempt begins                                    |
| `synthesizing`        | —                                      | the solver starts writing                                  |
| `draft_outline`       | `items`                                | drafting produced its outline                              |
| `draft_step_started`  | `index`, `summary`                     | drafting begins a step                                     |
| `draft_step_finished` | `index`, `step`, `problems`, `attempt` | that step was accepted (or retried)                        |

Token-level solver deltas are deliberately not emitted.

## Environment variables

| Variable                                  | Effect                                                                                                                                                                                                             |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GRAPH_STORAGE`                           | `file` \| `memory` — overrides `[storage].backend`. Invalid values error.                                                                                                                                          |
| `GRAPH_EVENTS`                            | `jsonl` — the event feed above. `github` — failure annotations for GitHub Actions (see Streams).                                                                                                                   |
| `GRAPH_LOG`                               | [tracing filter](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) for diagnostics on stderr (e.g. `GRAPH_LOG=debug`, `GRAPH_LOG=graph_mcp=trace`). Overrides `-v` flags. |
| `GRAPH_WORKBENCH_LOG`                     | file path for the [workbench's debug log](/workbench/plan-workbench#debug-logging). Overrides `[workbench].log_path` and the `<data_dir>/workbench.log` default.                                                   |
| anything referenced as `${VAR}` in config | resolved at load. Unset: provider/MCP entries error at first use naming the variable; anywhere else the load itself fails                                                                                          |

## Data locations

| Path                                               | Contents                                                                              |
| -------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `./.graph/`                                        | project config/plans/tools — the default target for `config init` and workbench saves |
| `~/.config/graph/config.toml`                      | global config, overridden by project config                                           |
| `~/.config/graph/plans/`, `~/.config/graph/tools/` | global plans and user tools                                                           |
| `~/.local/share/graph/`                            | threads and shape cache as plain files — `[settings].data_dir`                        |

## Concurrency

Concurrent graph processes can share one `data_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](/architecture/storage) for the exact guarantees.


## Related topics

- [Automation recipes](/cookbook/automation.md)
- [Reporting](/cookbook/reporting.md)
- [CLI reference](/reference/cli.md)
- [Authoring plans](/plans/authoring.md)
- [Plan workbench](/workbench/plan-workbench.md)
