# Headless mode

A non-interactive way to drive the agent for automation and RL rollouts. One
call (or one command) runs a prompt to completion in a chosen workdir, bounded
by turns + wall-clock, and returns a single serializable result — final answer,
verifiable structured output, usage, and the complete trajectory — plus an
optional live event stream.

Two entry points, one contract:

- **In-process** — `runHeadless(opts)` from `zidane/headless` (or the root
  barrel). Best for parallel rollouts in one JS/TS process.
- **CLI** — `bun run headless` (`src/headless-cli.ts`). Language-agnostic; spawn
  it per rollout and parse stdout. Emits `stream-json` like `claude -p`.

## In-process

```ts
import { runHeadless } from 'zidane/headless'
import { local } from 'zidane/providers' // vLLM/SGLang/Ollama via LOCAL_LLM_* env

const result = await runHeadless({
  prompt: 'Add a /health route that returns 200.',
  provider: local({ baseURL: 'http://localhost:8000/v1', defaultModel: 'my-27b' }),
  cwd: '/tmp/rollout-42',     // file tools operate here (rollout isolation)
  maxTurns: 30,
  timeoutMs: 600_000,         // wall-clock cap → status 'timeout' on expiry
  thinking: 'low',
  onEvent: e => process.stderr.write(`${e.type}\n`), // live trajectory
})

console.log(result.status)      // 'completed' | 'aborted' | 'error' | 'timeout'
console.log(result.finalText)   // last assistant text
console.log(result.usage)       // { input, output, cacheRead, cacheCreation, cost? }
console.log(result.transcript)  // SessionTurn[] — the training data
```

### Verifiable structured output

Pass a JSON Schema; the enforced object lands on `result.output` — a clean,
rule-checkable reward surface.

```ts
const result = await runHeadless({
  prompt: 'Return the number of failing tests.',
  provider,
  schema: {
    type: 'object',
    properties: { failing: { type: 'number' }, passing: { type: 'number' } },
    required: ['failing', 'passing'],
  },
})
// result.output → { failing: 0, passing: 12 }
```

(Zod users: `schema: zodToJsonSchema(MySchema)` — `zodToJsonSchema` is exported
from the root barrel.)

### `HeadlessResult`

| field | meaning |
|---|---|
| `status` | `'completed' \| 'aborted' \| 'error' \| 'timeout'` |
| `finalText` | concatenated text of the last assistant turn that produced text |
| `output?` | schema-enforced structured output (only when `schema` is set) |
| `usage` | `{ input, output, cacheRead, cacheCreation, cost? }` |
| `turns`, `durationMs`, `numToolCalls`, `finishReason?` | run accounting |
| `error?` | `{ message, type }` — `type` is the typed-error class name |
| `sessionId`, `summary?` | session id + incident postmortem (errors/blocks/budget) |
| `transcript` | raw `session.turns` — lossless (thinking dropped if `includeThinking: false`) |

## CLI

```bash
# one-shot (positional prompt), workdir-isolated
bun run headless "build a TODO API" --cwd /tmp/work --model my-27b --max-turns 30 --timeout 600

# prompt via stdin
echo "fix the failing test" | bun run headless --cwd /tmp/work

# write the trajectory to a known file (SFT data)
bun run headless "..." --transcript /tmp/work/traj.jsonl --transcript-format openai

# dump the agent's exact tool schema (align your renderer's TOOLS to this)
bun run headless print-tools --json
```

Provider defaults to `local()` and reads `LOCAL_LLM_BASE_URL`,
`LOCAL_LLM_API_KEY`, `LOCAL_LLM_DEFAULT_MODEL` — no interactive auth.
`--base-url` / `--model` / `--api-key-env` override env. `--temperature` /
`--seed` are passed through to the local endpoint (vLLM honors both) for
best-of-K diversity and reproducibility.

**Output:** `--output-format json` (default, one final result object) |
`stream-json` (one event per line, ending with the `result` event) | `text`
(assistant text only). **stdout carries only the machine payload; all logs go to
stderr.**

**Exit codes:** `0` ok · `1` run error · `2` usage error · `124` timeout ·
`130` aborted.

Run `bun run headless --help` for the full flag list.

## Reusable output helpers

For a local CLI that behaves like `zidane start`, import the renderers from the
split `zidane/output/*` subpaths instead of `start/*`. The `zidane/output`
barrel remains available as a convenience export when bundle shape does not
matter.

### Human terminal output

```ts
import { setupTerminalOutput } from 'zidane/output/terminal'

await setupTerminalOutput(agent, model, prompt, preset, { title: 'My CLI' })
await agent.run({ model, prompt })
```

`setupTerminalOutput` wires the same stdout hooks for streamed text, thinking,
tool calls, subagents, and the final usage summary. Use `printHeader: false`,
`shouldPrint`, `shouldPrintToolCall`, or `shouldPrintSummary` to trim it.

### `stream-json` output

```ts
import {
  createStreamJsonOutputHandler,
  setupStreamJsonOutput,
  streamJsonResultToJsonl,
} from 'zidane/output/stream-json'

// Direct Agent hook bus:
const uninstall = setupStreamJsonOutput(agent, { format: 'zidane' })
try { await agent.run({ prompt }) }
finally { uninstall() }

// Headless wrapper:
const result = await runHeadless({
  prompt,
  provider,
  onEvent: createStreamJsonOutputHandler({
    format: 'provider',
    providerName: provider.name,
  }),
})
process.stdout.write(streamJsonResultToJsonl(result, {
  format: 'provider',
  providerName: provider.name,
}))
```

`setupStreamJsonOutput` mirrors `start --stream-json`: it writes live turn
JSONL lines only. It does not expose every internal headless event and does not
write the final result line for you; call `streamJsonResultToJsonl` after the
run when you want the same terminal JSONL stream to end with the result object.
Lower-level formatters are also exported: `streamJsonTurnEventToJsonl`,
`streamJsonResultToJsonl`, `formatStreamJsonTurnEvent`, and
`formatStreamJsonResult`, plus the related options and event types.

## Transcript formats

Written to `--transcript <path>` as JSONL (one object per line). See
[`examples/rl/transcript-format.md`](../examples/rl/transcript-format.md) for the
locked schema.

- `turns` (default, lossless) — one `SessionTurn` per line. `content` blocks are
  `text` / `thinking` / `{type:'tool_call', id, name, input}` /
  `{type:'tool_result', callId, output, isError}`.
- `openai` (SFT-ready) — one OpenAI chat message per line: assistant messages
  carry `tool_calls`; each tool result becomes a `role:'tool'` message. Produced
  by `transcriptToOpenAIMessages` (also exported for in-process use).

## RL rollouts

See [`examples/rl/`](../examples/rl/): `rollout.ts` (one task → reward + result)
and `batch.ts` (bounded-concurrency best-of-K → JSONL for a trainer). The
trajectory + reward per sample is everything a GRPO/PPO-style loop needs; group
rollouts by prompt for advantage.

## Known integration checks (real model / vLLM)

These can't be settled with mocks — validate in a 1-task smoke run:

- **Tool-call parsing.** vLLM's `qwen3_coder` parser emits standard OpenAI
  `tool_calls`, which `openaiCompat` already parses — confirm tool calls land as
  `tool_call` blocks in the transcript (not as raw text).
- **Reasoning capture.** `--reasoning-parser qwen3` returns
  `message.reasoning_content`. Confirm it surfaces as `thinking` blocks; if your
  endpoint doesn't echo it in a shape `openaiCompat` reads, reasoning won't
  appear in the transcript (matters for thinking-on SFT, not for reward).
- **Tool-schema alignment.** Your renderer's `TOOLS` must equal
  `print-tools --json`, or render SFT data using zidane's exact tool
  names/schemas so the model learns *these* tools: `shell`, `shell_kill`,
  `read_file`, `write_file`, `list_files`, `edit`, `multi_edit`, `spawn`.
