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:
| 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):
{"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
haltedobject 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:
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
| 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. |
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.