# Headless and CI

> Run the agent without the terminal app, from scripts, CI, GitHub Actions or your own code.

| Tool | Use it for |
| - | - |
| `zidane run --json` | Scripts and CI with the installed `zidane` command |
| `bun run headless` | Evaluation harnesses in a repository checkout: local models, every event, transcripts, time limits |
| `runHeadless()` | Many runs in one JS or TS process |

## Approvals

Headless runs never ask for approval. Every tool call runs, shell commands and edits included. Run them in a container, a CI runner or a throwaway checkout. `--context docker` (or `e2b`, `daytona`) runs the tools in a container or a remote sandbox ([execution contexts](https://zidane.si/docs/execution-contexts.md)).

## zidane run --json

```bash
zidane run --json --prompt "Fix the failing test in src/limits.test.ts" > result.json
jq -r .finalText result.json
```

It needs no TTY, never loads the terminal UI and skips the update check. Credentials come from provider environment variables (`ANTHROPIC_API_KEY` and others) or the machine's credentials file. Flags, the result object and exit codes: [CLI](https://zidane.si/docs/cli.md#zidane-run).

### Stream turns

`--stream-json` prints one JSON line per finished turn, then the result object:

```json
{"type":"turn","index":0,"turn":{"role":"assistant","content":[…]}}
{"type":"turn","index":1,"turn":{"role":"user","content":[…]}}
{"status":"completed","finalText":"…","turns":2,…}
```

For live text deltas and tool calls, use `bun run headless --output-format stream-json`.

## GitHub Actions

```yaml title=".github/workflows/zidane.yml"
name: zidane
on:
  workflow_dispatch:
    inputs:
      task:
        description: What the agent should do
        required: true

jobs:
  agent:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm install -g zidane-tui
      - name: Run the agent
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          TASK: ${{ inputs.task }}
        run: zidane run --json --prompt "$TASK" --model claude-sonnet-4-5 > result.json
      - name: Summary
        if: always()
        run: jq -r '.finalText // .error.message' result.json >> "$GITHUB_STEP_SUMMARY"
      - run: git diff --stat
```

| Detail | Why |
| - | - |
| `ubuntu-latest` | `zidane-tui` has Linux x64 and arm64 binaries (glibc). Alpine runners do not work. |
| `TASK` | An env variable keeps the input out of the shell command |
| `ANTHROPIC_API_KEY` | From secrets: the runner has no credentials file |
| `if: always()` | A failed run exits non-zero. The summary still prints the error |

The job leaves its changes in the checkout for later steps to commit or open a pull request.

## bun run headless

`bun run headless` runs `src/headless-cli.ts` in a checkout, on the `local` provider by default (`LOCAL_LLM_*` variables). The prompt comes from an argument, `--prompt`, `--prompt-file` or stdin. `--transcript` writes the trajectory to a file.

```bash
echo "Add a /health route" | bun run headless --output-format stream-json --max-turns 30 --transcript run.jsonl
bun run headless print-tools --json     # the exact tool schema the agent sees
```

| Flag | Default | Description |
| - | - | - |
| `-p, --prompt <text>` | None | Prompt to run. Also the first positional argument, or stdin. |
| `--prompt-file` | None | Read the prompt from a file. |
| `-m, --model <id>` | None | Model id (or LOCAL_LLM_DEFAULT_MODEL). |
| `-s, --system <text>` | None | System prompt override. |
| `--thinking` | `off` | Thinking (reasoning effort) level. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `adaptive`. |
| `--provider` | `local` | Model provider. One of `local`, `openai-compat`, `anthropic`, `openai`, `openrouter`, `cerebras`, `baseten`, `neuralwatt`. |
| `--base-url <url>` | None | OpenAI-compatible base URL (or LOCAL_LLM_BASE_URL). |
| `--api-key-env <NAME>` | None | Env var holding the API key (or LOCAL_LLM_API_KEY). |
| `--vision` | None | Local/openai-compat model accepts image inputs. |
| `--image-in-tool-result` | None | Local/openai-compat accepts images in tool messages. |
| `--temperature <f>` | None | Sampling temperature (local provider only). |
| `--seed <n>` | None | Sampling seed (local provider only). |
| `--max-turns <n>` | `50` | Stop after this many agent turns. |
| `--max-tokens <n>` | None | Output token limit for each model call. |
| `--timeout <sec>` | None | Abort the run after this many seconds (exit code 124). |
| `--output-format` | `json` | Stream-json emits the FULL event union as JSONL (start/text/thinking/tool_call/tool_result/turn/result); `bun start --stream-json` emits complete turns only. One of `text`, `json`, `stream-json`. |
| `--transcript <path>` | None | Write the trajectory as JSONL. |
| `--transcript-format` | `turns` | Shape of the transcript lines: zidane turns, or OpenAI chat messages. One of `turns`, `openai`. |
| `--no-thinking-transcript` | None | Drop thinking blocks from the transcript. |
| `--schema <file.json>` | None | Enforce structured output → result.output. |
| `--cwd <dir>` | None | Working directory for file tools. |
| `--import-foreign-configs` | None | Also load .claude/agents (user scope; project scope too with --project-agents). |
| `--no-markdown-agents` | None | Skip markdown agents and the builtin Explore/Plan; spawn schema stays unfolded. |
| `--project-agents` | None | Also load project agents (<cwd>/.agents/agents, <cwd>/.zidane/agents); default: user-scope (\~/) agent files and builtins only. |
| `-c, --context` | `process` | Execution context. One of `process`, `docker`, `e2b`, `daytona`. |
| `--image <img>` | None | Docker image for --context docker. |
| `--env KEY=VALUE` | None | Set an env var in the context (repeatable). |
| `--pass-env VARNAME` | None | Forward a shell env var into the context (repeatable). |
| `--pregame <file>` | None | e2b/daytona: upload + run this setup script once the sandbox is ready, before prompting (shebang picks the interpreter; a non-zero exit aborts the run). |
| `--sandbox <id>` | None | e2b/daytona: connect to an existing sandbox by id instead of creating one (left running on exit). |
| `--sandbox-on-destroy` | `kill` | e2b/daytona: what to do with a created sandbox at the end of the run (pause preserves state for reconnect via --sandbox; leave relies on provider TTL). One of `kill`, `pause`, `leave`. |
| `--mcp <json\|file>` | None | MCP server config(s). |
| `--session-db <path>` | None | Persist/resume a session. |
| `--session-id <id>` | None | Persist/resume a session. |
| `--durability` | None | With --session-db: checkpoint runs; on resumes an interrupted run first (resume-ask never does here). One of `on`, `resume-ask`, `off`. |
| `--json` | None | With print-tools, print the tool schemas as JSON. |
| `-h, --help` | None | Print help and exit. |

| `--output-format` | stdout |
| - | - |
| `json` (default) | The result object at the end |
| `text` | The agent's text as it streams |
| `stream-json` | Every event as a JSON line |

`--durability` checkpoints the runs of a `--session-db` session. Headless never asks, so under `resume-ask` it does not resume an interrupted run. `on` resumes it first ([crash recovery](https://zidane.si/docs/durability.md)).

| Exit code | Means |
| - | - |
| `0` | Completed |
| `1` | Error |
| `2` | Bad usage |
| `124` | Timeout (`--timeout`) |
| `130` | Aborted |

### zidane run or bun run headless

Both print the same result object.

| What | `zidane run --json` | `bun run headless` |
| - | - | - |
| Needs | The installed `zidane` | A repository checkout and Bun |
| Default provider | `anthropic` | `local` |
| `--provider` names | `anthropic`, `openai`, `openrouter`, `cerebras`, `xai`, `google`, `google-vertex`, `arcee`, `baseten`, `neuralwatt`, `local`, `openai-compat` | `local`, `openai-compat`, `anthropic`, `openai`, `openrouter`, `cerebras`, `baseten`, `neuralwatt` |
| Preset | `--preset` is ignored | No flag |
| Extra headers | `--header`, `--headers-env` | No flag |
| Output schema | `--json-schema <JSON>`, `--schema <file>` | `--schema <file>` |
| Provider message shape | `--format zidane\|provider` | No flag |
| Codemode tool | `--codemode` | No flag |
| Turn limit and time limit | None | `--max-turns` (default 50), `--timeout <sec>` |
| Prompt from a file | No | `--prompt-file` |
| Streaming | Finished turns (`--stream-json`) | Every event (`--output-format stream-json`) |
| Transcript file | No | `--transcript`, `--transcript-format openai`, `--no-thinking-transcript` |
| Agents | Default | `--project-agents`, `--import-foreign-configs`, `--no-markdown-agents` |
| Tool schema dump | No | `print-tools` |
| Exit codes | `0`, `1` | `0`, `1`, `2`, `124`, `130` |

### Events

| `type` | Fields |
| - | - |
| `start` | `runId`, `provider` |
| `text`, `thinking` | `delta` |
| `tool_call` | `callId`, `name`, `input` |
| `tool_result` | `callId`, `name`, `output`, `isError` |
| `turn` | `index`, `turn` |
| `spawn` | `event` (`before`, `complete`, `error`), `id`, `info` |
| `error` | `message`, `errorType` |
| `result` | `result`: the [result object](https://zidane.si/docs/cli.md#the-result-object) |

### Debug the prompt cache

With `ZIDANE_DEBUG` set, `zidane run`, `bun run headless` and `runHeadless()` log prompt-cache breaks to stderr. Put `cache` in the value (`ZIDANE_DEBUG=cache`) to get a line for every turn.

## In your own process

For parallel runs, call `runHeadless` in one process instead of spawning one per task:

```ts
import { runHeadless } from 'zidane/headless'
import { local } from 'zidane/providers'

const result = await runHeadless({
  prompt: 'Add a /health route that returns 200.',
  provider: local({ baseURL: 'http://localhost:8000/v1', defaultModel: 'my-model' }),
  cwd: '/tmp/rollout-42',
  maxTurns: 30,
  timeoutMs: 600_000,
  onEvent: e => process.stderr.write(`${e.type}\n`),
})

console.log(result.status) // 'completed' | 'aborted' | 'error' | 'timeout'
```

| Option or helper | Does |
| - | - |
| `onEvent` | Receives the [events](#events) |
| `schema` | A JSON Schema. The run puts the object that matches it in `result.output` |
| `exitCodeForHeadlessResult(result)` | Maps the status to the `bun run headless` exit codes |
| `setupTerminalOutput` (`zidane/output/terminal`), `setupStreamJsonOutput` (`zidane/output/stream-json`) | The renderers `bun start` uses, for your own CLI |

## Reference

- [`zidane/headless`](https://zidane.si/docs/reference/headless.md)
- [`zidane/output/stream-json`](https://zidane.si/docs/reference/output-stream-json.md)
- [`zidane/eval`](https://zidane.si/docs/reference/eval.md)
