TidebreakDocs

Running headless

The tidebreak CLI — serve, tui, one-shot prompts, and the MCP stdio server.

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:

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

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.

serve

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

ANTHROPIC_API_KEY=sk-... cargo run -p tidebreak-cli -- serve
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.

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.

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:

ModeEffect
askDefault. Uncovered mutating calls park on an approval.
autoWorkspace writes proceed; sensitive calls still ask.
allowNothing asks. Explicit full autonomy for this chat.
planRead-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):

{"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:

{"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

CodeMeaning
0The turn completed.
1The turn failed, was refused, or was cancelled.
2Usage error — a bad flag or argument.
3The turn parked on a plan or question and no driver answered. reason is plan_undriven or questions_undriven.
4Something 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.
130Interrupted (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:

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:

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.

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

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.

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:

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.

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.

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:

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

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

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:

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

VariableEffect
TIDEBREAK_DATA_DIRWhere 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_URLAttach 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_TOKENBearer token for --server, unless --server-token-env <var> names a different variable. Not used with --attach.
TIDEBREAK_PROFILEdesktop (default, SQLite) or self_host (PostgreSQL).
TIDEBREAK_DATABASE_URLRequired for self_host.
TIDEBREAK_AUTH_TOKENS_FILENamed bearer tokens for self_host. Required there.
TIDEBREAK_MCP_CONFIGJSON file of external MCP server definitions to load at boot.
TIDEBREAK_KEYCHAIN_MOCKDebug 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_MODELOverrides the model the process launches with.
TIDEBREAK_CONTAINER_EXECUTION_ENABLEDEnables the container execution backend. Default false.
TIDEBREAK_CONTAINER_IMAGEOverrides the container image.
ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, GEMINI_API_KEY, FIREWORKS_API_KEY, TOGETHER_API_KEYUsed as a credential for that provider when nothing is stored in the credential store.

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.

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.