# Running headless (/headless)


The desktop app embeds a local HTTP + WebSocket server. The same server, agent
loop, and tools run from a command line without the window.

Build the CLI from a clone of the repository:

```sh
cargo build -p tidebreak-cli --release
```

The examples below use `cargo run -p tidebreak-cli --` for clarity; substitute
the built binary if you have it on your path.

## Commands

```text
tidebreak serve
tidebreak mcp <workspace>
tidebreak rehome-secrets
tidebreak tui [--chat <id> | --new]
tidebreak -p <prompt> [--chat <id>] [--output-format text|json]
           [--permission-mode ask|auto|allow|plan]

tidebreak output list <chat>
tidebreak output show <chat> <output> [--revision <id>]
tidebreak output revisions <chat> <output>
tidebreak output export <chat> <output> <path> [--revision <id>]
tidebreak attach <chat> <file>

tidebreak provider list
tidebreak provider set-key <kind> [--from-env <var>]
tidebreak provider remove-key <kind>
tidebreak model list
tidebreak model roles
tidebreak model select <key|auto> [--role <role>]
tidebreak settings show
tidebreak settings web-search select <provider|off>
tidebreak settings web-search set-key <provider> [--from-env <var>]
tidebreak settings web-search remove-key <provider>
tidebreak settings exec select <provider|off>
tidebreak settings exec set-key <provider> [--from-env <var>]
tidebreak settings exec remove-key <provider>
tidebreak mcp-server list
tidebreak mcp-server add <name> (--command <cmd> [--arg <a>]… | --url <url>)
tidebreak mcp-server remove <name>
tidebreak chat list
tidebreak chat create
```

A bare `tidebreak` with no arguments runs `serve`.

`tui`, `-p`, `output`, `attach`, and the setup commands additionally take
`--server <url>` / `--server-token-env <var>`, or `--attach` — see
[Attaching to a running server](#attaching-to-a-running-server).

### `serve`

Starts the local API on an ephemeral loopback port and prints the address and a
freshly minted per-launch bearer token:

```sh
ANTHROPIC_API_KEY=sk-... cargo run -p tidebreak-cli -- serve
```

```text
tidebreak: listening on http://127.0.0.1:53421
tidebreak: token 7f2c…
```

The token is a secret. Capture the process's stdout directly rather than
running it under a logging supervisor.

The API is not exposed on a public interface. There is no generated OpenAPI
document — the route table and handler documentation in `tidebreak-server` are
the specification.

### `tui`

An interactive terminal chat. It boots the same in-process server and drives it
over the loopback API.

```sh
cargo run -p tidebreak-cli -- tui
cargo run -p tidebreak-cli -- tui --chat <chat-uuid>
cargo run -p tidebreak-cli -- tui --new
```

With no flags it opens a picker over the existing chats — enter resumes the
selected one, `n` starts a new chat, `esc` leaves. On a profile with no chats
yet there is nothing to pick, so it goes straight into a new chat. `--new`
always skips the picker; `--chat` resumes one by id.

Inside a session, `/chats` (or `ctrl+o`) reopens the same switcher and `/model`
picks the model for the current chat.

### `-p` (one-shot)

Runs a single turn without a terminal. stdout carries the assistant's text, and
the exit status says how the turn ended.

```sh
cargo run -p tidebreak-cli -- -p "summarize the CSV in output/"
cargo run -p tidebreak-cli -- -p "…" --output-format json
cargo run -p tidebreak-cli -- -p "…" --permission-mode allow
cargo run -p tidebreak-cli -- -p "…" --model anthropic::claude-haiku-4-5-20251001
```

`--output-format json` emits the turn's event stream as NDJSON instead of plain
text. `--chat <id>` continues an existing conversation. `--model <key>` pins the
chat's model (a catalog key such as `anthropic::claude-haiku-4-5-20251001`)
before the turn starts; an unavailable selection fails the process instead of
falling back silently.

`--permission-mode` sets the chat's permission mode for the run — the same four
modes the desktop offers, applied before the turn starts:

| Mode | Effect |
| --- | --- |
| `ask` | Default. Uncovered mutating calls park on an approval. |
| `auto` | Workspace writes proceed; sensitive calls still ask. |
| `allow` | Nothing asks. Explicit full autonomy for this chat. |
| `plan` | Read-only exploration; the agent proposes a plan instead of acting. |

The mode is stored on the chat, so `--chat <id>` on a later run inherits
whatever the last run set unless it passes `--permission-mode` again.

#### Driving a turn

A turn can reach a point only someone else can settle: an approval, a proposed
plan, a question. Attach a **driver** — a process holding this one's stdin —
and it answers them over a line protocol. Driving is on when
`--output-format json` is set and stdin is not a terminal; a terminal on stdin
means a person ran the command by hand, and no decision lines are coming.

Requests are emitted on stdout, mixed into the NDJSON event stream. Every line
the CLI authors carries an `tidebreak` version tag, which is what tells it apart
from a journal frame (those carry `seq` and `event`):

```json
{"tidebreak":"v1","type":"approval_request","call_id":"…","action":"exec","approval":"exec_may_run_networked_command","grant_rungs":["exact_action",{"command_prefix":{"tokens":1}}],"preview":{…}}
{"tidebreak":"v1","type":"plan_proposal","call_id":"…","title":"Rewrite the importer","plan":"1. …"}
{"tidebreak":"v1","type":"questions_asked","call_id":"…","questions":[{"id":"target","question":"Which environment?","header":"","question_type":"single","allow_free_form":true,"options":[{"id":"staging","label":"Staging"}]}]}
{"tidebreak":"v1","type":"error","message":"a plan decision does not answer the pending approval request"}
{"tidebreak":"v1","type":"halted","reason":"plan_undriven","exit_code":3,"call_id":"…","message":"…"}
```

The driver answers by writing one JSON object per line to stdin:

```json
{"type":"approval","call_id":"…","decision":"approve","grant":"exact_action"}
{"type":"approval","call_id":"…","decision":"reject","reason":"not in this run"}
{"type":"plan","call_id":"…","decision":"accept","permission_mode":"auto"}
{"type":"plan","call_id":"…","decision":"reject","feedback":"split it in two"}
{"type":"questions","call_id":"…","answers":[{"question_id":"target","selected_option_ids":["staging"],"custom_answer":"canary first"}]}
```

`call_id` may be omitted — only one interaction is ever outstanding — but
naming a *different* call is an error, not a redirect. `reason` (approval
rejections), `feedback` and `permission_mode` (plan), `grant` (approval), and
`custom_answer` are optional. Unknown fields are ignored so a newer driver can
send more; an unknown `type` or verdict is refused with an `error` event and
the next line is read, so one malformed line never decides anything by itself.

#### Undriven defaults

With no driver — text output, a terminal on stdin, or an input stream that ends
without answering — the standing policy applies:

- **Approvals are rejected**, with the reason `non-interactive print mode`.
  The model sees the rejection and can choose another route rather than hang.
- **Plans and questions end the run.** There is no answer to invent, so the
  turn is cancelled *and reported*: a `halted` object naming the reason, and a
  distinct exit status.

The `halted` object goes to stdout under `--output-format json` and to stderr
under text output, so an undriven run is always machine-diagnosable.

#### Exit codes

| Code | Meaning |
| --- | --- |
| `0` | The turn completed. |
| `1` | The turn failed, was refused, or was cancelled. |
| `2` | Usage error — a bad flag or argument. |
| `3` | The turn parked on a plan or question and no driver answered. `reason` is `plan_undriven` or `questions_undriven`. |
| `4` | Something the run had to settle could not be carried through. `reason` is `decision_failed` when a decision was made and the server refused it, `pending_lookup_failed` when the parked request itself could not be read, or `folder_decline_failed` when a `request_folder_access` call was left parked and the refusal it needs could not be delivered. |
| `130` | Interrupted (SIGINT). The turn is cancelled, and a `halted` object names the reason `interrupted`. |

#### A dev harness

To drive Tidebreak from a script or a coding agent, give it its own data
directory so it cannot touch the desktop's chats, and let it act without
parking:

```sh
export TIDEBREAK_DATA_DIR="$(mktemp -d)/tidebreak"
export TIDEBREAK_KEYCHAIN_MOCK=1   # debug builds only — see below
export ANTHROPIC_API_KEY=sk-...

cargo run -p tidebreak-cli -- -p "run the tests and summarize what failed" \
  --permission-mode allow --output-format json < /dev/null
```

`TIDEBREAK_KEYCHAIN_MOCK=1` keeps credentials in memory instead of the OS
keychain, which is what stops a headless run prompting for keychain access. It
is honored by debug builds only; a release binary always uses the real
keychain. Redirecting stdin from `/dev/null` states plainly that nothing is
driving, so the run takes the undriven defaults instead of waiting.

To drive it instead, hold both pipes — read stdout line by line, and write a
decision line when a request arrives:

```sh
cargo run -p tidebreak-cli -- -p "plan the migration" \
  --permission-mode plan --output-format json < decisions.pipe
```

### Setup commands

`provider`, `model`, `settings`, `mcp-server`, and `chat` configure a profile
without the desktop app. Each one boots the same in-process server the other
commands do, calls the route the desktop's settings pages call, and prints the
answer — they hold no configuration logic of their own, so both surfaces stay in
step. All of them accept `--output-format text|json`, and `json` prints the
route's response object on one line.

```sh
# Providers and credentials
cargo run -p tidebreak-cli -- provider list
cargo run -p tidebreak-cli -- provider set-key anthropic < key.txt
ANTHROPIC_KEY=sk-... cargo run -p tidebreak-cli -- \
  provider set-key anthropic --from-env ANTHROPIC_KEY
cargo run -p tidebreak-cli -- provider remove-key anthropic

# Models
cargo run -p tidebreak-cli -- model list
cargo run -p tidebreak-cli -- model roles
cargo run -p tidebreak-cli -- model select anthropic::claude-opus-5
cargo run -p tidebreak-cli -- model select auto --role utility

# Web search and code execution
cargo run -p tidebreak-cli -- settings show
cargo run -p tidebreak-cli -- settings web-search select exa
cargo run -p tidebreak-cli -- settings web-search set-key exa --from-env EXA_KEY
cargo run -p tidebreak-cli -- settings exec select e2b
cargo run -p tidebreak-cli -- settings exec set-key e2b --from-env E2B_KEY

# MCP servers
cargo run -p tidebreak-cli -- mcp-server list
cargo run -p tidebreak-cli -- mcp-server add docs \
  --command npx --arg -y --arg @acme/docs-mcp --env-from ACME_TOKEN
cargo run -p tidebreak-cli -- mcp-server remove docs

# Chats
cargo run -p tidebreak-cli -- chat list
CHAT=$(cargo run -p tidebreak-cli -- chat create)
cargo run -p tidebreak-cli -- -p "…" --chat "$CHAT"
```

**A key is never a command-line argument.** `set-key` reads it from stdin, or
from the environment variable named by `--from-env`. Arguments are visible to
every process on the machine through the process list and land in shell
history; a pipe and an exported variable do not. Stored keys go to the same
place the desktop puts them — the OS keychain on a desktop profile — and no
command here ever prints one back.

Storing a provider key also enables that provider for routing, matching what
saving a key in the app does. `model select` writes the same role selections
the settings page does: a catalog key pins the role, and `auto` returns it to
automatic resolution.

`settings show` reports the runtime settings alongside web-search and
code-execution selection, availability, and which credential slots are filled.

`mcp-server add` and `remove` edit the *user-configured* server set. Servers
that come from an installed plugin appear in `list` and are managed by turning
that plugin on or off — the server rebuilds them, so the config route refuses
to edit them. `--command` (with repeatable `--arg`), `--url`, and
`--gateway-endpoint` are the three transports, and exactly one is required.
Secret material is named, not given: `--env-from` forwards a variable from the
parent environment, and `--bearer-token-env` names the variable holding a
remote server's token. Both are resolved when the server connects.

### `output`

A turn's files land as conversation outputs. These read them, and write one to
a path — the same routes the desktop's Outputs panel uses, so a file produced
in the app is readable here and the other way round.

```sh
cargo run -p tidebreak-cli -- output list <chat-uuid>
cargo run -p tidebreak-cli -- output show <chat-uuid> <output-uuid>
cargo run -p tidebreak-cli -- output revisions <chat-uuid> <output-uuid>
cargo run -p tidebreak-cli -- output export <chat-uuid> <output-uuid> ./report.md
```

`list` prints one tab-separated row per output, starting with the id the other
subcommands take. `show` writes the text preview to stdout and names the
revision it read on stderr, so `tidebreak output show … > draft.md` is exactly
the text. `revisions` lists an output's version history — every version keeps
its own id and bytes, including after a restore, because history is
append-only. `export` writes the complete bytes of a revision, which is the
only way to get a binary artifact (a chart, a spreadsheet) out.

`--revision <id>` names an exact version instead of the current one on `show`
and `export`.

### `attach`

Puts a local file into a conversation, the same way the app's attach button
does. What the bytes are decides where they go: an image is published as an
image attachment, everything else is ingested as a source document. The id it
prints on stdout is what a later turn references.

```sh
cargo run -p tidebreak-cli -- attach <chat-uuid> ./contract.pdf
```

### `mcp`

Serves Tidebreak's own read-only filesystem tools over MCP stdio, confined to
one explicit workspace directory:

```sh
cargo run -p tidebreak-cli -- mcp /absolute/path/to/workspace
```

It exposes `read_file` and `list_dir`, and only after a proper MCP initialize
handshake. This is Tidebreak acting as an MCP *server* for some other client —
it is unrelated to [connecting MCP servers to Tidebreak](/mcp-servers).

### `rehome-secrets`

macOS ties keychain approvals to a binary's code signature, so credentials
stored by an earlier build keep prompting. This rewrites each stored credential
under the running binary's signature.

```sh
cargo run -p tidebreak-cli -- rehome-secrets
```

## Attaching to a running server

**One data directory, one server.** A server claims its data directory when it
starts and holds the claim for as long as the process lives. A second process
that tries to start its own server over the same directory is refused outright,
because two engines writing one database race each other in ways nothing else
would catch:

```text
tidebreak: configuration error: another Tidebreak process is already running on
the data directory /Users/me/.tidebreak. Connect to it instead …
```

The claim is an OS advisory lock on `tidebreak.lock` inside the directory, so it
is honest across a crash: a killed process releases it the moment it dies, and
the leftover file never has to be cleaned up by hand.

The way in is to *attach* — to be a client of the server that is already there
rather than a second one. Prefer `--attach`, which reads the
`listen.json` the running server wrote into the data directory (mode `0600`,
bearer only — never the native executor credential):

```sh
# Same data directory the desktop or `serve` owns
export TIDEBREAK_DATA_DIR="$HOME/Library/Application Support/io.brightwave.tidebreak.dev"
cargo run -p tidebreak-cli -- --attach provider list
cargo run -p tidebreak-cli -- -p "what changed today?" --attach
cargo run -p tidebreak-cli -- tui --attach
```

On a release desktop build, the directory is `io.brightwave.tidebreak` (no
`.dev`). `--attach` and `--server` conflict; pick one.

You can still pass an explicit URL when you already have the pair (for example
from `serve`'s stdout):

```sh
export TIDEBREAK_SERVER_TOKEN=<the token that server printed>
cargo run -p tidebreak-cli -- --server http://127.0.0.1:53421 provider list
```

`--server` / `--attach` work on `tui`, `-p`, and every setup command.
`TIDEBREAK_SERVER_URL` sets the same thing as `--server` for a whole shell, and
the flag wins over it. After the endpoint is resolved, an attached command
writes no log file and touches no keychain — it is only an HTTP+WebSocket
client. With `--attach`, `TIDEBREAK_DATA_DIR` is used solely to find
`listen.json`; with `--server`, the local data directory is ignored.

`serve`, `mcp`, and `rehome-secrets` reject `--server` / `--attach`: the first
two *are* servers, and the third rewrites local keychain items. (They ignore
`TIDEBREAK_SERVER_URL`, so a shell that exports it can still start a daemon.)

### Getting a token

The bearer token is per-launch and is full authority over the profile, so it is
never a command-line argument — every process on the machine can read those, and
they land in shell history. `--attach` loads it from `listen.json`. With
`--server` it comes from `TIDEBREAK_SERVER_TOKEN`, or from the variable
`--server-token-env <var>` names.

**From `tidebreak serve` or the desktop app:** both write
`{data_dir}/listen.json` on bind. Point `TIDEBREAK_DATA_DIR` at that directory
and use `--attach`. `serve` still prints the URL and token on stdout if you
prefer to capture them:

```sh
cargo run -p tidebreak-cli -- serve > server.log &
export TIDEBREAK_SERVER_URL=http://$(grep -m1 'listening on http://' server.log | sed 's|.*http://||')
export TIDEBREAK_SERVER_TOKEN=$(grep -m1 'token ' server.log | sed 's|.*token ||')
```

## Environment

| Variable | Effect |
| --- | --- |
| `TIDEBREAK_DATA_DIR` | Where the database, blobs, logs, and `listen.json` live. Defaults to `./.tidebreak` under the current directory. With `--attach`, used only to locate `listen.json`; with `--server`, ignored. |
| `TIDEBREAK_SERVER_URL` | Attach to the server at this base URL instead of embedding one. `--server <url>` overrides it; conflicts with `--attach`. `serve`/`mcp`/`rehome-secrets` ignore it. |
| `TIDEBREAK_SERVER_TOKEN` | Bearer token for `--server`, unless `--server-token-env <var>` names a different variable. Not used with `--attach`. |
| `TIDEBREAK_PROFILE` | `desktop` (default, SQLite) or `self_host` (PostgreSQL). |
| `TIDEBREAK_DATABASE_URL` | Required for `self_host`. |
| `TIDEBREAK_AUTH_TOKENS_FILE` | Named bearer tokens for `self_host`. Required there. |
| `TIDEBREAK_MCP_CONFIG` | JSON file of external MCP server definitions to load at boot. |
| `TIDEBREAK_KEYCHAIN_MOCK` | **Debug builds only.** Any non-empty value routes credentials to an in-memory store instead of the OS keychain, so a headless run never triggers a keychain prompt. Secrets do not survive the process. Release binaries ignore it. |
| `TIDEBREAK_MODEL` | Overrides the model the process launches with. |
| `TIDEBREAK_CONTAINER_EXECUTION_ENABLED` | Enables the container execution backend. Default `false`. |
| `TIDEBREAK_CONTAINER_IMAGE` | Overrides the container image. |
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, `GEMINI_API_KEY`, `FIREWORKS_API_KEY`, `TOGETHER_API_KEY` | Used as a credential for that provider when nothing is stored in the credential store. |

<Callout>
`tidebreak serve` defaults to `./.tidebreak` in your current directory, which is
**not** the desktop app's data directory. Point `TIDEBREAK_DATA_DIR` at the
desktop's application data directory (debug:
`~/Library/Application Support/io.brightwave.tidebreak.dev`) and use `--attach`
to drive the same chats while the app is running. Embedding a second server
over that directory is still refused. See
[Attaching to a running server](#attaching-to-a-running-server).
</Callout>

Logs are written to `logs/tidebreak.log` under the data directory.

## Self-hosting

A `self_host` profile backed by PostgreSQL exists. It authenticates with named
bearer tokens from an operator file, and every chat, project, and document
query is scoped to the authenticated principal.

One thing to know before using it: **settings are deployment-scoped, not
per-user.** Enabled providers, credentials, model roles, and web-search and
code-execution configuration are shared by everyone on the deployment, and
anyone who can call the API can change them. The profile is for mutually
trusting users of one operator's deployment — a household or a small team. It
is not multi-tenant isolation.

Document and blob storage on PostgreSQL, remote secret custody, object storage,
and multi-process ownership are not finished. Treat self-hosting as
experimental.
