Skip to content

CLI Reference

craze [flags]
craze prompt [text] [flags]
craze bridge [flags]
craze attach [flags]
craze ps [flags]
craze new [prompt...] [flags]
craze new --wait [prompt...] [--prompt-file <f>] [flags]
craze send <id> [prompt...] [--wait] [--prompt-file <f>] [flags]
craze wait <id> [--from <handle>] [--timeout <d>] [--max-message <n>] [--json]
craze cancel <id>
craze stop <id>
craze serve [flags]
craze auth login [provider] [--no-browser]
craze auth logout <provider>
craze auth list
craze providers [--hub] [--json]
craze version
craze completion bash|zsh|fish|powershell
craze help [command]

The default command is the interactive TUI. It refuses to start on a non-tty.

Flags

These flags apply to craze (the TUI). craze prompt takes most of the session flags (not --continue); see craze prompt.

Flag Description
--workspace Existing workspace directory (default: current directory)
--model Model to start on: an ACP model id, or on native a model alias. Native resolves it against every model it knows — shipped or yours, connected or not — and a model whose provider has no key refuses to start, saying how to give it one. It applies to this start only, and is never remembered as a default; a new native session takes the effort last picked for that model in a session's /model, when the model still offers it (Model memory)
--effort Effort to start at, where the model offers an effort setting: a level's id (high), the id in any case (HIGH), or its name (extra high) — tried in that order, and a step counts only when it matches exactly one level. Set after --model, so against that model's levels, and before the session takes any prompt. A value that matches no level, or more than one, or a model with no effort setting, leaves the model's own effort, with one line in the session's diagnostics (below). Applies to this start only — a --continue or --resume load included — and is never remembered
--fast, --no-fast Start with the model's fast setting on, or off, where the model offers one (cursor's per-model fast). Neither leaves it as the model has it. Set as --effort is; a model without one is a line in the session's diagnostics (below)
--agent-bin Path to the agent binary for this launch's own provider (or CRAZE_AGENT_BIN); a session of another provider uses its own. See Agent binaries
--provider Provider: cursor, grok, gx (ACP agents) or native (runs inside craze). Empty is unset. Unknown id exits 2
--force Spawn the agent with --force / --always-approve (yolo). Default: on
--no-force Disable yolo and handle permission requests
--no-mouse Disable mouse reporting (wheel scroll and clicks)
--no-background Keep the terminal's own background and text colours instead of the theme's. Same as background = false
--no-host-status Report nothing to the herdr pane or roost tab craze runs in, and leave the agent's environment whole. Same as host_status = false. See Host status
--theme TUI theme preset. See Configuration
--ask Set session mode to ask after session/new
--plan Set session mode to plan after session/new
--plugin-dir Extra plugin directory whose commands and skills craze expands (repeatable). Relative to the workspace; a missing directory is a diagnostic (see below), not an error; ignored (with a diagnostic) on grok, gx and native
--continue, -c Load the newest session in this workspace instead of starting a new one, skipping sub-agent sessions. No matching session exits 1
--version, -v Print the craze version and exit, as craze version does. Answered before any argument is checked
--resume, -r Open a picker of the last 10 sessions in this workspace. Empty index exits 1 the same way; Esc in the picker exits 0

--ask and --plan are mutually exclusive. So are --fast and --no-fast, and --continue and --resume (exit 2).

--effort and --fast/--no-fast are set inside the session's start, after --model and --ask/--plan, so no prompt — typed, queued, or sent by another client — reaches the agent before them; that holds whether the session runs in a detached host or, under detach = false, in the TUI's own process. A setting the agent refuses is a line in the session's diagnostics, and the session starts without it; one the agent does not answer within 15 s fails the start. Each one that is skipped or refused is also a diag line in the session's journal (effort_unmatched, fast_unmatched, start_setting_refused).

The session's diagnostics (those lines, and a --plugin-dir one) go to the host's log for the ordinary, detached craze, and to stderr in process (detach = false) or for a craze serve run by hand.

--provider on the TUI skips the startup picker. Without it, $CRAZE_PROVIDER then provider in the config file then cursor is the default, and the picker lets you change it before Start. The picker holds its choice to the same flags --provider is held to: picking native with --agent-bin or CRAZE_AGENT_BIN set shows the message --provider native would exit 2 with, as an error row, and starts nothing (see TUI reference). --agent-bin and CRAZE_AGENT_BIN are the resolved provider's: another provider picked there runs the binary [agents] names for it, or the one on PATH (Agent binaries). craze prompt has no picker; it uses the same precedence. craze frame ignores env and config and defaults to cursor unless --provider is passed.

./bin/craze
./bin/craze --provider grok
./bin/craze --workspace ../proj
./bin/craze --no-force
./bin/craze --theme gruvbox --no-mouse
./bin/craze --continue
./bin/craze --resume
./bin/craze --provider grok --continue

--continue and --resume

Both load a session out of the session index (~/.craze/sessions.jsonl — see Configuration), filtered to the current workspace. An explicit --provider filters the search further; a provider that only came from $CRAZE_PROVIDER or config.toml does not, so a plain --continue finds the newest session for this workspace across every provider, and picker rows show each row's own provider. Whichever row is loaded, its own provider starts the agent — it overrides whatever --provider would otherwise have resolved to, and it is never written back as the persisted default (see Configuration).

No matching row:

craze: no session to continue in /abs/workspace
craze: no session to continue in /abs/workspace for provider grok

exit 1, and no TUI is drawn. --continue skips sessions started for a coding agent (craze new --wait's, marked unattended in the index): it loads the newest session that is not one. A workspace holding only such sessions refuses, and says how many there are:

craze: no session to continue in /abs/workspace (3 sub-agent sessions here: craze -r lists them)

With exactly one it reads (1 sub-agent session here: craze -r lists them).

The count is the marked rows --resume would list for that workspace and provider filter. --resume shows them, after the other rows (they fill the picker's ten only when fewer than ten others remain). A session index that exists but craze cannot parse fails the same way, exit 1, naming the file; the index is never rewritten by a failed read. --continue and --resume together is a usage error, exit 2, and --resume on a non-tty gets the ordinary non-tty refusal — checked before either flag is considered, so it fires even for a workspace with no sessions at all.

A load the agent itself refuses (no session/load support, an unknown session id, a working directory Grok or gx reject) is shown as an on-screen error exactly like a failed session/new; the row stays in the index, craze never falls back to starting a new session, and the exit code on quit is 1.

See /rename and Resuming a session for what a restored session looks like in the TUI.

A session already running in another craze

Everything below describes the claim as the in-process path makes it. With detached hosts (the default) the launching craze first attaches directly to a live host that serves the session, and otherwise spawns a host, which makes this same claim and answers held when another craze has it — the launcher then finds the holder and attaches to it. The refusals are worded the same, but the detached launcher prints no line before the TUI for a plain reattach; only when the command line gave flags a new session would take does it print the ignored-flag note (craze: that session is already running (pid N); attached to it (ignored: --model)), after the TUI exits. The stderr line shown below is the in-process path's.

--continue claims the row it loads before anything is built: its own lock under ~/.cache/craze/locks/, independent of CRAZE_HOME and the runtime directory (see Protocol reference). A row that already has a craze id is claimed first, and a session another running craze already holds is attached to instead of refused — the second craze -c becomes craze attach --session <id>, resolved through the holder's host id, after one line on stderr:

craze: that session is already running (pid N); attaching

The flags a new session would take (--model, --effort, --fast/--no-fast, --ask, --plan, --agent-bin, --provider) do not apply to an attach; each one this command line explicitly passed is named in the note — whether the flag was changed on the command line, not what it is worth — e.g. (ignored: --model, --provider). Nothing is built, spawned, bound, or claimed for it. A nonempty, explicit --provider also does one more thing the others don't: it filters the session index before the lookup that finds the row to claim (above), so a --provider that excludes the held session's own provider means that row is never found at all — the ordinary no-match refusal fires (craze: no session to continue … for provider …), and the attach path, note included, is never reached. An empty, or all-whitespace, explicit --provider ('', ' ') does not filter the index — only a value with visible content counts — so the row is still found and the attach path is still reached — and --provider is still named in the note, exactly like the others, because it was still changed on the command line. Two cases keep the plain "session held" refusal instead of attaching:

  • A legacy row (no durable craze id yet, from before crazeId existed) is refused before the claim, whose EnsureCrazeID would otherwise mint and write its id for a load the command line can never start.
  • The spawn flags this command line gives are refused first, once this run holds the claim: craze -c --agent-bin X for a held session still attaches (an attach takes none of the spawn flags), but for a session this run just claimed itself, the same usage errors a new session's flags would give still apply, and the claim is released.

A holder whose live registry entry does not serve the claimed session (or whose socket has not been bound yet) keeps the plain refusal instead, with why appended: — it serves no control socket or — it serves another session. A holder whose lock names no pid yet (pid ?, the instant after its flock) also keeps the plain refusal:

craze: that session is already running (pid N)

exit 1, and no agent is spawned — nothing is built once the row's session is found to be held and cannot be attached to. Two narrower refusals cover the claim mechanism itself, not the session:

craze: the session index is busy — try again
craze: the session index changed — try again

The first is another writer holding the session index past a two-second bound; the second is a legacy row with no durable id that left the index between being read and being given one (another loader may already hold it under an id this one does not know about — trying again re-reads the index).

--resume's picker claims each row the same way, when you press Enter on it — but it never attaches itself: a held row's error names the holder's pid, plus craze attach --session <hostId> when the holder's live entry actually serves that session, or why it cannot be reached instead (— it serves no control socket, — it serves another session); the picker keeps running either way, in place of loading it.

An unreadable lock tree or session index does not fail one way. The initial lookup — --continue's Latest, or --resume's scan for rows — still exits 1 if it cannot be read, the same as no session found at all: there is no row yet to warn about. Once a row is in hand, two things warn on stderr and let the load proceed unclaimed: a legacy row (one with no durable crazeId) whose id cannot be written to the index, and any failure to take the session's claim other than another craze holding it (the lock tree unusable, the lock file unopenable or unwritable) — whether the row already had an id or was just given one. An unclaimed load has no protection against a second craze loading the same session; the lock exists to stop one, not to lock you out of your own session because a filesystem misbehaved. Three things still refuse instead: the session already held by another craze, a busy index, and a legacy row that vanished from the index before it could be given an id.

Native sessions

Native sessions are indexed and resumable on the same terms as cursor's and grok's: --continue and --resume find and load a native row the same way, and --plan/--ask set the resumed session's mode explicitly, overriding whatever mode the transcript's own last mode_change recorded (see Resuming a session).

--agent-bin/CRAZE_AGENT_BIN refuse a native row exactly as they refuse a new native session — it has nothing to spawn — exit 2, before any index write, crazeId mint, or session claim (--continue still reads the index to find the row before the refusal is reached):

craze: --agent-bin cannot be used with provider native, which runs inside craze
craze: CRAZE_AGENT_BIN cannot be used with provider native, which runs inside craze; unset it

A row whose transcript file does not exist yet — its first prompt was cancelled before any output came back — is not a failed load: craze opens an empty session under that same id instead of refusing. This is the one deliberate exception to "a load never falls back to a new session" above — it is still the same session, continuing from nothing, not a different one, so nothing is silently switched out from under you.

A native transcript adds its own lock beneath the claim above: a transcript another craze process already has open refuses the load the same way. A transcript left with a torn or cut-off tail by a crash is trimmed back to its last complete step on open, and the dropped bytes are kept beside it in a <stem>.torn-<UTC stamp> file — the transcript's name with its .jsonl suffix removed, e.g. 20260926T093537Z_<id>.jsonl's torn tail lands in 20260926T093537Z_<id>.torn-20260927T101500Z — rather than discarded; a tail written by a newer craze build — one this version does not recognize — is never trimmed, and the load is refused instead, so nothing unrecognized is thrown away.

Sessions outlive their terminal

The ordinary craze no longer runs the session inside the terminal's process. It spawns a detached host — craze serve, in a session of its own with no terminal — waits for the host to say it is ready, and runs the TUI as that host's client over the control socket. What follows:

  • Closing the terminal keeps the session. A closed terminal (SIGHUP), a SIGTERM to craze, and a dropped ssh connection are all view closes: the TUI goes, the host and its agent stay, and a turn already under way keeps working.
  • craze -c reattaches. --continue (and a --resume choice) of a session a live host serves attaches to that host directly: nothing is spawned, and no note is printed unless the command line gave flags a new session would take (craze: that session is already running (pid N); attached to it (ignored: --model)). craze attach reaches the same host.
  • /exit ends the session, everywhere. /exit, Ctrl+D and the second Ctrl+C ask the host to stop the session (session.stop): the agent is closed, the session stays resumable if it has an index row (it has one once it was prompted), and every other client attached sees it end. The TUI waits at most two seconds for that, in total; a second quit ends the wait at once. A host that cannot stop its session (a TUI-hosted one, or one from an older craze) is detached from instead, with craze: that session runs in an older craze; close it there; any other failure prints craze: the session may still be running: <reason>. Exit 0 either way — except after a failed start, which exits 1.
  • An unattended host does not run for ever. A host with no client attached and nothing in flight exits after host_idle_exit (an hour by default), saving the session like any stop. A host whose session was never prompted has its idle limit capped at five minutes (after the startup grace, and only while nothing keeps it busy); one whose start failed, as soon as its launcher has been told.
  • A quit before the session is up leaves nothing behind. A host this launch spawned whose session never came up in the TUI (quit while starting, a failed start) is stopped; a host that was already running is never stopped that way.
  • A host that could not come up is a start failure. The message names the host's log (~/.cache/craze/host-logs/<hostId>.log, see Host logs) and ends ; CRAZE_DETACH=0 runs sessions inside craze instead; craze exits 1. A host that refused the session asked for (no such session, a held one that cannot be attached) shows the refusal as the in-process path does, from a picker as its error row.

The switches that turn this off (detach = false, CRAZE_DETACH=0, or the control socket switched off) run the session inside the TUI's own process, as before: closing the terminal ends it, and nothing outlives it. See Configuration.

The control socket

Every host binds a control socket in the runtime namespace and serves its session over it: the detached host craze serve by default, or the TUI's own process when detaching is off (see Protocol reference), which is what craze bridge and craze attach reach. A launching craze binds nothing of its own; the in-process path binds only for a run that actually starts the TUI — a --continue that attaches instead (above) or refuses binds nothing and creates no socket of its own.

control_socket = false in config.toml, or CRAZE_CONTROL_SOCKET=0 (or false) for one run, turns it off; see Configuration. The session lock above is still taken either way — it does not depend on the socket.

craze serve

craze serve [flags]
craze serve --load <craze session id | provider:session id> [flags]

Hosts one session with no terminal, over its control socket, until a client ends it (session.stop), the process gets SIGTERM or SIGINT, or it has been idle for too long. It builds the session exactly as the TUI does: the engine, the claim on the session, the journal and the session-index writes are the host's, and craze attach (or the ordinary craze, which is a client of it) joins it. You rarely run it yourself: the ordinary craze spawns one per session (its own session, stdio on /dev/null) and attaches to it. Run by hand it is the same host in the foreground, logging to stderr; SIGHUP is ignored.

It takes the session flags the TUI takes — --workspace, --model, --effort, --fast/--no-fast, --agent-bin, --provider, --force/--no-force, --ask/--plan, --plugin-dir, --continue/-c — with the same refusals in the same words, and:

Flag Description
--load Load a session by id instead of starting one: a craze session id, or <provider>:<session id> for an index row that has no craze id yet. It runs in the row's own workspace: an explicit --workspace naming another directory is exit 2, a row whose directory is gone is exit 1, and no such row is exit 1. --provider filters as it does for -c
--log Write the host's diagnostics, its crash output and the agent's stderr to this file, rotated once at 4 MiB (default: stderr)

--host-id and --no-host-status also exist, hidden: the spawner passes them (the id it minted for the host, and the launching TUI's host-status choice). They are internal, not a user interface.

A session another craze already holds is exit 1 — craze serve: that session is already running (pid N). A control socket switched off is a usage error (exit 2): a headless host is reachable through nothing else. A session that cannot be claimed (an unusable lock tree) is exit 1 with nothing left behind. A host whose start failed (an auth failure, say) stays up, listed and attachable, so its client is told why, and goes as soon as it has none.

The host's log and its retention are under Host logs.

craze prompt

Run a headless ACP turn (and optional follow-ups). Text comes from the argument, or from stdin when it is not a tty.

./bin/craze prompt --json --provider grok --agent-bin ./bin/craze-fake-agent "hello"
echo "hello" | ./bin/craze prompt --json
./bin/craze prompt --json --follow-up "and then this" "start here"
Flag Description
--workspace Existing workspace directory (default: current directory)
--model Model to start on: an ACP model id (session/set_model after session/new), or a native model alias, resolved as the TUI's --model is, at the effort remembered for it. Never remembered. Without it a native run starts where a new TUI session would, on the remembered model (Model memory)
--effort Effort to start at, where the model offers an effort setting: a level's id or name, matched as the TUI's --effort is (see Flags). Set after --model and before the first prompt; applies to this run only
--fast, --no-fast Start with the model's fast setting on, or off, where the model offers one; neither leaves it as the model has it. Both together exit 2
--agent-bin Path to the agent binary for this launch's provider (or CRAZE_AGENT_BIN, then [agents] in config.toml: Agent binaries)
--provider Provider: cursor, grok, gx (ACP agents) or native (runs inside craze)
--follow-up Additional prompt on the same ACP session (repeatable) — the headless queue, see below
--permission-decision Headless permission answer: allow-once or reject-once (repeatable)
--force Spawn the agent with the provider's yolo flag (--force for Cursor, --always-approve for Grok). Default: on
--no-force Disable yolo and handle permission requests
--ask Set session mode to ask after session/new
--plan Set session mode to plan after session/new
--plugin-dir Extra plugin directory whose commands and skills craze expands (repeatable). Relative to the workspace; a missing directory is a diagnostic (see below), not an error; ignored (with a diagnostic) on grok, gx and native
--json Write only JSON events to stdout

Without --json, only the main session's assistant text is written to stdout — a sub-agent's text is never printed, so a piped reply stays the agent's own answer.

The text of each run is a paragraph of its own. A run that starts after a tool call, a thought or the end of a turn is separated from the output so far by a blank line, however the earlier text ended: nothing is added at the start of the output or after a blank line, one newline after text ending in one, and two otherwise (a chunk that begins with newlines supplies its own). A sub-agent's tool calls and thoughts never start a run. --json events are unchanged.

--follow-up is the queue

Every --follow-up is queued before the first turn and sent one per settled turn, in order — the same queue the TUI's Enter fills. It holds 32 messages of up to 32 KiB each; past either bound the run stops with an error rather than sending a truncated message.

The chain still ends the way it always did: a stop reason that is not end_turn, or an error, stops it with exit 1 and no further turn. SIGINT, SIGTERM and SIGHUP (the hangup a closed terminal sends) clear the queue first and then cancel, so nothing starts behind the signal; the run exits 1.

When the agent starts a turn craze did not prompt for — Grok's interject fallback (see Queued messages), or a native session's wake — the next queued message waits for it, up to a minute, and the run exits 1 with a note on stderr if it is still going after that.

JSON events

--json writes one JSON object per line:

type Carries
text Assistant reply chunk; agent names the sub-agent when the chunk belongs to one
thought Reasoning chunk; agent as on text
user A chunk of a sub-agent's prompt (with agent), or a Grok interjection on the main session (with "interjection":true)
command A plugin command or skill craze expanded into the prompt
queue One change to craze's own message queue
foreign_turn A turn the agent started without a craze prompt, bracketed
compaction One end of a native session's compaction of its context; agent as on text
tool Tool call create/update, merged by id; agent as on text; a sub-agent tool also carries task
todos Todo list replace or merge
permission / question / plan Blocking request. A permission is answered by --permission-decision (none left: rejected); a question gets its first option; a plan is accepted
subagent Sub-agent lifecycle
title The title the session took
done / error Turn finished / turn failed

Every line that came from the session's event stream carries seq, an integer, as its second key — right after type. It is the number the session's event log gave that event, the same one the session journal records for it, so a headless caller can line the two up.

seq is increasing but not contiguous. The JSON above is a projection: events it has nothing to say about (a mode change, a title-less metadata update) are dropped, and a dropped event still took its number. Rely on the order of two lines; never on the arithmetic between them.

The lines craze writes itself carry no seq at all, and craze never invents one: a startup failure (an error line written before any session existed) is the one left. The queue removed line for a row that left the queue just as a signal landed used to be one too; it now carries a seq like any other queue line, because taking a row and starting its turn are one transaction in the engine (session control S1b). An absent seq means craze said it, not the agent.

agent is the sub-agent's own session id (grok) and appears only on lines that belong to one; main-session lines have no agent field. Grok streams a sub-agent's session on the same connection, so its prompt, thoughts, tool calls and replies arrive as tagged user, thought, tool and text lines.

subagent is the lifecycle, {"type":"subagent","event":"spawned|progress|finished","id":…}, with attemptId, parentId, status, description, subagentType, model, toolCallId, durationMs, toolCalls, turns, tokens, output and error alongside, each omitted when empty, plus transcript (whether the provider streams one), which is always present — true or false. Grok's lines come from its own lifecycle notifications; cursor sends none, so craze synthesizes the same lines from the cursor/task receipt. tool.task carries description, model, agentId, durationMs and status (running, completed, failed, cancelled).

command is one plugin command or skill craze found on disk and expanded into the prompt (see Slash commands): {"type":"command","name":"watch-pr","qualified":"git-commands:watch-pr", "plugin":"git-commands","kind":"command","path":"/home/…/watch-pr.md", "text":"…"}. name is the entry's own name and qualified its plugin:name spelling — the same row under two names when the bare one was free; text is the block exactly as it went out on the wire, the only way a headless caller can see what the agent was actually given. Plain mode prints none of this.

queue is {"type":"queue","event":"queued|edited|removed|sent","id":"q-1", "position":0,"version":0,"text":"…"}. position is the row's index when the change happened, not where it sits now — so a sent line from the drain carries 0 (the head was position 0 when it went), every line of a clear carries 0 for the same reason, and a sent line for the TUI's send now carries whatever row the user picked. --follow-up produces a queued line per follow-up before the first turn and a sent line before each turn after it. Plain (non---json) mode prints none of this.

foreign_turn is {"type":"foreign_turn","event":"started|ended","id":"…", "text":"…"}; the id is Grok's interject-fallback-… prompt id, or wake-<n> for a native session delivering a background sub-agent's result with no turn running. Nothing drains between the two lines.

compaction is {"type":"compaction","phase":"started|ended", "reason":"auto|manual|overflow","tokensBefore":…,"tokensAfter":…, "error":"…"}, with agent for a sub-agent's: the native session summarizing its conversation to free context, on its own near the model's window, after a request the window refused, or for /compact [focus] sent as the prompt (manual). A started line is followed by its ended one; the counts are the context's estimated size before and after, 0 on a started line and for the after of one that failed, which carries error instead. agent and error are omitted when empty.

done is not EOF: it says the turn ended, not the sub-agents. Lifecycle lines for a still-running sub-agent may follow the turn's done — grok sends subagent_finished after prompt_complete when a turn is cancelled — and a sub-agent spawned in one turn may report during the next. Before every exit, craze prompt drains the stream: when no sub-agent was ever spawned nothing is added; otherwise events keep being read and written until every sub-agent is terminal, then 250 ms pass with no event, or 1.5 s elapse — whichever comes first.

craze bridge

./bin/craze bridge
./bin/craze bridge --session 01a0bbe5-69b4-79de-b75c-483a15b73d78

A pure byte pump for an SSH client: resolves the one running craze session — or --session's — dials its control socket, and relays stdin/stdout to it verbatim. It speaks no protocol itself; hello is the client's job, not this command's.

Flag Description
--session A craze session id, a provider session id, or a host id, or a short form of one (short ids) (default: the one running session)
--hub Relay to this machine's hub instead, starting it when none runs. Not with --session
--no-start With --hub only: attach to a hub that is already running and never start one. With none running it exits 3 (craze bridge: no hub is running). Without --hub it is a bridge error, exit 1 (craze bridge: --no-start needs --hub)

--session's id is resolved against the registry under the bridge process's own $HOME (~/.cache/craze/hosts/*.json; see Protocol reference) — HOME wins over the account database, so neither CRAZE_HOME nor $XDG_RUNTIME_DIR in an SSH login's own environment can hide or misdirect it, but the SSH exec is assumed to run with the same HOME as the tab that started the host: true for an ordinary SSH login as the same user, not guaranteed in general. With no --session, exactly one live host in total is the target; zero or several is an error listing them on one line.

Short session ids

craze bridge --session and craze attach --session take the short id craze ps prints, not only a whole id. The tiers are tried in this order, and the first tier that matches decides:

  1. an exact match on any field of any live session: its craze session id, its provider session id or its host id;
  2. a craze session id's end (craze ps's SESSION is its last eight characters);
  3. a craze session id's beginning;
  4. a host id's beginning.

Within the deciding tier, two different sessions are ambiguous and refused (the error lists them, as an exact clash always was); one session matching by several fields counts once. A provider session id must be given whole: its shape is the agent's. A suffix or a prefix needs at least four characters; a shorter one matches nothing (no session <id>). The hub's session.connect stays exact: the roster gives it whole ids. craze new --load resolves a saved session's id against the session index by tiers of the same kind.

The pump (roost's bridge rules):

  • stdout carries only bytes read off the socket, flushed as they arrive.
  • stdin's EOF half-closes the socket's write side (CloseWrite) and keeps reading the socket — the session may still have plenty left to send.
  • From stdin's EOF the bridge looks at its stdout about once a second: a reader gone entirely (a pipe whose reader closed, a Unix stream socket whose peer closed, as when an SSH connection drops) closes the socket and exits 1, craze bridge: its reader went away (stdout closed), since a quiet session would never write the byte whose failure says so. The look starts at stdin's EOF; a reader lost while stdin is still open is found by the next failed write instead. A stdout that is neither a pipe nor a Unix stream socket (a file, a terminal, a TCP socket) is not looked at.
  • The socket's own EOF ends the pump and exits 0.

The error contract: every failure is one line on stderr, exit 1 (the one exception, --hub --no-start with no hub running, exits 3; see below). That covers every failure before the pump starts — resolving the target, dialing, the peer check — and "nothing on stdout" is true only for those: flag parsing is included, so a stray removed config-file environment variable (see Configuration) in an SSH environment still reads as a bridge error in this same shape. Once the pump is running, stdout carries socket bytes only (see below); a failure mid-stream still exits 1 with the same one stderr line, but after whatever socket bytes were already relayed to stdout:

craze bridge: no session running
craze bridge: no session <id>
craze bridge: 2 sessions running; pass --session <id>: <id> (grok, /a), <id> (cursor, /b)
craze bridge: session <id> is unreachable: <reason>

craze bridge: no session <id> is the contract line for an unknown --session: a device reading it back knows the id it asked for is not (or no longer) running here, not that something else broke.

--hub relays to the hub instead of one session's host, starting it when none runs (found, like a host, under the bridge process's own $HOME). The same pump and the same error contract apply; the client says hello to the hub and then reads the roster or splices itself to a session with session.connect. Plain craze bridge is unchanged. --hub --no-start attaches to a running hub only and never starts one, so a caller asking "is there a hub to reach?" does not create the very hub it is asking about. With a hub it splices exactly as --hub does; with none it prints craze bridge: no hub is running and exits 3, the one bridge failure that is not 1. A hub that is running but does not answer (wedged) is ended, as everywhere, and nothing replaces it, so the answer is the same: no hub is running. A wedged hub that cannot be ended (its start token unreadable, or the signal refused) is a failure like any other: exit 1. Its own failures:

craze bridge: --hub and --session cannot be used together: the hub's session.connect names the session
craze bridge: no hub: <reason>
craze bridge: no hub is running
craze bridge: --no-start needs --hub
craze bridge: the hub is unreachable: <reason>
Code When
0 The socket closed cleanly (the session ended, or the far side hung up)
1 Every failure above but no hub is running: no session, an ambiguous --session, an unreachable socket, a read or write error. Never 255 (ssh's own exit code for a failed connection)
3 --hub --no-start and no hub is running (a wedged hub, ended, counts as none)
127 Not craze bridge's own exit: what an SSH client sees when the far end's shell falls off the published binary ladder (see Protocol reference) without ever reaching a craze to exec

craze attach

./bin/craze attach
./bin/craze attach --session 01a0bbe5-69b4-79de-b75c-483a15b73d78

Runs the full TUI over a running craze session's control socket: the session another craze process in this directory is hosting, or --session's. Nothing is spawned, nothing binds a socket, no session claim is taken, and no index row is written. Quitting it (/exit, Ctrl+D, Ctrl+C when idle) ends the session on a host that can stop it, as it does in every client; only a closed terminal or SIGTERM leaves it running (see Quitting vs. the session ending).

Flag Description
--session A craze session id, a provider session id, or a host id, or a short form of one (short ids) (default: the one session running in this directory)
--theme TUI theme preset. See Configuration
--no-mouse Disable mouse reporting (wheel scroll and clicks)
--no-background Keep the terminal's own background and text colours instead of the theme's

--continue/-c, --resume/-r, --provider and --model are refused — an attach joins a running session rather than starting or loading one:

craze attach: --continue does not apply: attach joins a running session

Resolution

With an explicit --session, the one entry it names (matched the way craze bridge matches: by craze session id, provider session id or host id, or a short id); an invalid --session — whatever its value, "" included — is a usage error, exit 2, before a registry read builds a path. No match is exit 1:

craze attach: no session <id>

and several matches is exit 2, listing them (ids do not collide in practice, but nothing here assumes it).

With no --session, the live sessions whose workspace is the current directory (both sides made absolute, cleaned and symlink-resolved, so /tmp/x and macOS's /private/tmp/x are one directory): exactly one is the target. Several is exit 2:

craze: 2 running craze sessions in /abs/workspace:
  01a0bbe5…  /abs/workspace  My session
  01a0bbe6…  /abs/workspace
attach to one with: craze attach --session <id>

None in this directory is exit 1, naming the sessions running elsewhere (id, workspace, and title when the session index has one) plus the same hint — or just the first line alone when nothing runs anywhere:

craze: no running craze session in /abs/workspace
  01a0bbe7…  /abs/other      Other session
attach to one with: craze attach --session <id>

A listing never connects to a session just to describe it: the workspace and title come from the registry and the session index alone.

Resolution runs before the terminal is checked, so a usage error or an unambiguous exit 1/2 above is reported whatever stdout is; only once a target is resolved does attach refuse a non-tty the same way the TUI does (a real termios check per OS, so craze attach >/dev/null is refused too):

craze attach: refusing to start TUI on a non-tty

A resolved target that cannot be dialled — the socket is gone, or the peer check fails — is exit 1:

craze attach: session <id> is unreachable: <reason>

Viewer mode

The pickers, provider persistence, session swaps, host status reporting (the herdr pane or roost tab belongs to the host) and session-index writes are all the host's; an attach does none of them. Shell mode (!) still runs locally, in the session's own workspace. The permission chip shows the host's permission mode (its --force/--no-force) when the host advertises it, and falls back to craze's own default (yolo) for an older host that does not.

Keys

Identical to the host TUI's: the session — its queue and its turn — is shared, so the first Ctrl+C while a turn works cancels the turn and clears the queue for every attached client, exactly as it does in the host TUI. Ctrl+C when idle, a second Ctrl+C, Ctrl+D and /exit all end the session (below).

Quitting vs. the session ending

Quitting craze attach is the same act as quitting the host's own TUI: Ctrl+C when idle, a second Ctrl+C, Ctrl+D and /exit ask the host to stop the session (session.stop). It ends for every client attached and stays resumable if it has an index row; the attach exits 0 and prints nothing more (1 after a failed start). Two things do not end it, and are view closes — the attach detaches, exits 0, and the session goes on running on its host: SIGTERM, and a closed terminal (SIGHUP).

A host that cannot stop its session — a TUI-hosted one (the in-process opt-out), or one from an older craze — is detached from instead, and one line on stderr says so:

craze: that session runs in an older craze; close it there

A stop that went unanswered (the host stopped reading, or the two-second deadline passed) is exit 0 too, saying so:

craze: the session may still be running: <reason>

An attach TUI whose session ended some other way — another client's stop, the host's own quit, the host's idle exit — prints one line on stderr once its own screen is restored, and exits 0:

craze: session ended

A transport failure the client's redials could not recover from — the session may still be running, but this client lost it — is exit 1 instead:

craze: lost the session: <reason>

craze -c of a running session

See A session already running in another craze: --continue/-c (and a --resume choice) of a session already running in another craze attaches to it through this same command, rather than refusing.

craze ps

./bin/craze ps
./bin/craze ps --json
./bin/craze ps --no-hub
./bin/craze ps --saved [-C <dir>] [--json] [--no-hub]

Lists every running craze session of this user on this machine — every live host in the registry under $HOME that has a craze session id, whichever terminal or CRAZE_HOME started it — one row each, from the hub's roster:

SESSION   STATE        PROVIDER  MODEL                       DIR       SINCE  TITLE
0000a001  needs you    cursor    sonnet-4.6                  ~/proj-a  2m     fix the flaky test
0000c003  starting     cursor    -                           ~         3h     add the ps command
0000e007  idle         native    muse-spark-1.3-contributor  ~/newer   5m     -
Column
SESSION The last eight characters of the session's craze id: its random end. A craze id is a UUIDv7, whose first characters are its clock's, the same for every session started within a minute or so. --json, craze attach --session and craze bridge --session take the whole id
STATE The session list's state: needs you (an ask is open), working, starting (its host has not answered yet), failed (its start or its last turn failed), idle, or unreachable (listed, and its socket does not answer)
PROVIDER The session's provider
MODEL The session's current model id, as --model takes it (its row's model), cut with … to 32 cells; - when its host does not say (a craze from before plan 035) or has not answered yet. --json carries the whole id
DIR The session's working directory, ~ for $HOME
SINCE How long the session has been in its state, in one unit (42s, 5m, 3h, 6d); - when its host does not say (an older craze)
TITLE The session's title, else its first prompt (the session index's title), else -: one line, cut with … to what is left of the terminal's width, or to 100 cells when stdout is not a terminal

Rows are ordered by state, in the order above, then newest in its state first. On a terminal no line is wider than it: when fewer than 10 cells are left for the title, DIR gives up cells first (down to 10), its paths cut from the left so their ends stay, and a terminal too narrow even for the other columns has every line cut. With nothing running it prints no sessions running.

Flag Description
--json Print the hub's roster (its sessions.list result, the protocol's roster rows) on one line instead of the table; with --saved, the sessions.saved result
--no-hub Read each session's host directly, without the hub; with --saved, the session index
--saved List the saved sessions instead — not running, ready to reopen — newest first (below)
-C, --dir <dir> With --saved, only the sessions that ran in this directory (made absolute and cleaned, then matched exactly). Without --saved it is a usage error, exit 2

--no-hub — and any failure to have a hub (none could be started, or it did not answer within 10 s) — reads each host itself, exactly as the hub would (one round of the same poll; the same rows), and says so on stderr:

craze ps: --no-hub: reading each session's host directly
craze ps: the hub is not available (<reason>); reading each session's host directly

With --json, that roster's epoch is "" and its cursor 0: it is no hub's. craze ps exits 0 unless that read fails too — the registry cannot be read — which is exit 1 with the reason on one line.

Saved sessions

craze ps --saved lists this craze directory's saved sessions: those in its session index that no running host serves — what the session list's saved rows are, chosen by the same rule — newest first, at most 200:

SESSION   PROVIDER  DIR       AGE  TITLE
4c1d7e90  cursor    ~/proj-a  2h   fix the flaky test
legacy01  grok      ~/proj-b  3d   -
Column
SESSION The last eight characters of the session's craze id, as craze ps shows a running one; a row from a craze before craze ids shows its provider session id's last eight
PROVIDER The session's provider
DIR The directory it ran in, ~ for $HOME; cut from the left on a narrow terminal, as craze ps's is
AGE How long since it was last active, in one unit (42s, 5m, 3h, 6d)
TITLE Its title, else -: one line, cut with … to the terminal's width (100 cells when stdout is not a terminal)

One row per session, its newest — the row a reopen loads — listed only when its provider is one this craze can resume (a session whose newest row is a newer craze's, of a provider this one does not know, is not listed at all) and the row is well-formed (a hand-edited row whose craze id is not one a load takes — [A-Za-z0-9._-], at most 128 bytes — is not: bounds), and sub-agents' sessions are left out (the session list shows them, tagged). A session whose host is stopping is listed at once. A session whose directory has gone is still listed: nothing is stat'ed. With nothing saved it prints no saved sessions; with more than 200 (the index holds up to 500), stderr says craze ps: more saved sessions than these 200: only the newest are listed.

It lists through a running hub's sessions.saved and never starts one: the index is this craze directory's to read too. It asks only a hub whose hello says reopen — the capability that says a hub can list saved sessions and reopen them. With no hub running, with --no-hub, or when the hub cannot answer — one from an older craze, whose hello lacks reopen — it reads the index itself, through the same function the hub answers with, and says so on stderr:

craze ps: no hub is running; reading the session index directly
craze ps: --no-hub: reading the session index directly
craze ps: the hub cannot list saved sessions (an older craze's); reading the session index directly
craze ps: the hub is not available (<reason>); reading the session index directly

--json prints the sessions.saved result on one line — {"sessions":[…], "more":…}, each session's id what reopens it — byte for byte the same through the hub or from the index. An index that cannot be read lists nothing, exit 0 — the hub's log says why, or stderr when craze ps reads the index itself; a registry that cannot be read, or one holding a live host's entry that cannot be read, is exit 1 (a running session could not be told from a saved one).

The hub

The hub is one process per user and craze directory (CRAZE_HOME), craze hub: it serves the roster of every running session and a splice to any one of them, on a socket of its own in the runtime namespace. Nothing needs starting by hand: craze ps, craze new, craze bridge --hub and the TUI's session list start it when none runs — re-executed, in a session of its own, its stdio on /dev/null — and the next one finds it. A hub that does not answer is replaced.

It exits by itself 60 seconds after the last host has gone and the last client has disconnected — one nobody ever connected to included — and while any session runs it stays. Killing it loses nothing: hosts run on without it, and the next craze ps starts another. A hub that outlives an upgrade which removed its craze (Linuxbrew) cannot start sessions: its refusal names its pid to kill (for an older craze's hub, craze new adds it), and it serves on until then (Upgrading while sessions run).

Its log is ~/.cache/craze/host-logs/hub-<ns>.log, beside the host logs (under the process's own $HOME; <ns> names the craze directory), rotated once at 4 MiB like a host's. On macOS it says, as the hub starts, which login session the hub runs in — macOS login session: GUI (audit flags 0x2030), or not GUI for one first started over ssh — because every session it creates inherits it (craze new says why that matters).

craze new

./bin/craze new "fix the flaky test"
./bin/craze new -C ~/projects/lumen --provider grok --effort high "add the ps command"
./bin/craze new --title "ps command" "add the ps command"
./bin/craze new --json
./bin/craze new --load 4c1d7e90

Starts a session in the background — in its own host, through the hub's session.create — in the current directory or -C's, and returns once the session has started, its first prompt (the arguments, joined by spaces) sent to it when there is one:

started 8327352e in ~/projects/lumen

The id is the session's short id, as craze ps shows it. When the prompt was sent and its answer lost, a second line says the prompt may not have reached it: the session exists, and may be working on it. The session runs on as any detached session does — craze attach, the session list (←) and craze ps find it — until its host's idle exit.

Flag Description
-C, --dir <dir> Start the session in this directory (default: the current one)
--provider <id> The session's provider. Without it the hub's configured default — provider in config.toml, the one the last session to start persisted — and with none configured the create is refused (plain mode: craze new: no default provider yet; pass --provider cursor, grok, gx or native, exit 1; --json passes the hub's refusal through). An unknown id is a usage error, exit 2, plain even with --json
--model <id> The model to start on. Without it the session uses the agent's own default: craze keeps no default model of its own for an ACP provider
--effort <value> The effort to start at, where the model offers one (as the session flag)
--fast / --no-fast The fast setting to start with, where the model offers one
--no-force The session's agent asks for permission, and a client answers (the default is a plain launch's: --force's bypass — config.toml has no permission setting)
--title <title> Name the session from its start, pinned as /rename pins a title: every row that describes it carries it from the first one — in craze ps, the session list and the hub's roster (a roster subscription opened before the create may first receive a connecting placeholder with no row yet, before the titled reachable row), and no title the agent or the first prompt would give it replaces it (a later /rename does). Normalized as /rename normalizes one, to exactly what the session stores and the index holds — escape sequences, control characters, the zero-width space, word joiner and byte-order mark, and bidirectional embeddings, overrides and isolates dropped (ZWJ and ZWNJ kept), invalid UTF-8 as U+FFFD, one line with whitespace collapsed, at most 120 characters, the ends trimmed (session.create lists it exactly); one that comes to nothing (--title "", blank, only such characters) is a usage error, exit 2. Needs a hub whose create takes a title (below). With --wait the host this starts is named the same way
--json Print the hub's answer (session.create's result: the session as the roster lists it, and the prompt's outcome; with --load, session.reopen's) on one line
--wait Start an unattended session without the hub, send it the prompt and wait for its turn's result (below)
--load <id> Reopen a saved session through the hub instead of starting one (below): an id craze ps --saved prints, or a whole one

--timeout, --max-message, --prompt-file, --remove-prompt-file, --plan and --ask are --wait's alone: without it each is a usage error (craze new: --plan needs --wait, exit 2).

The session's host finds its agent binary as a session the list starts for another provider does — [agents] in config.toml, then PATH: a hub never hands a host the launch's --agent-bin or CRAZE_AGENT_BIN. It persists its provider as the next plain launch's default, as every new session does.

A refusal is the hub's own words on one line, exit 1 — a session whose start failed (the session did not start: …, its host's error), a directory that does not exist, no provider. When a start failed because the agent exited with a non-zero status or a signal after craze sent it initialize, the error ends with the agent's own last words: its last non-blank stderr lines, from what craze had captured when the failed start collected them — first waiting up to half a second for its copy of that stderr to finish — sanitized and folded onto the line with /, at most 512 bytes, and not redacted — acp: agent exited: exit status 1: Error: Your macOS login keychain is locked. / Run security unlock-keychain and try again. for cursor on a locked keychain. An agent that exits before craze sends initialize, or exits 0, gets craze's own words only. Everything the agent printed is in the host's log. So is a session that started and refused its first prompt: it runs on, idle, and the line says why. A hub from before craze new says this hub (craze <v>) cannot create sessions; it exits once no session runs and no client is connected, or stop it now with kill <pid> (no session ends): it is not replaced, and the next craze new after it has gone starts this craze's. Likewise --title against a running hub from before titles (its hello has no createTitle) is this hub (craze <v>) cannot set a title at create; it exits once no session runs and no client is connected, or stop it now with kill <pid> (no session ends), exit 1 (with --json, that message as the error object's): nothing is sent to it, and no session is created without its title — run it again once that hub has exited or been stopped, or create without --title and /rename the session.

A hub whose craze an upgrade removed (Linuxbrew deletes the old version's directory) cannot start the session's host, and says so: this hub's craze (<path>) was replaced by an upgrade: stop this hub with kill <pid> (no session ends) and run this again; it goes on serving until you do. A hub of another craze version than this one that cannot start the host — an older hub says only the session host cannot be started — gets the way out added after its words, from its hello: ; the hub (pid <pid>) runs craze <v> and this is craze <this>: if craze was upgraded, stop that hub with kill <pid> (no session ends) and run this again. That addition is the stderr line's alone: with --json the object is the hub's error as it sent it. See Upgrading while sessions run.

Each run creates under a request id of its own, and a connection to the hub that ends before the answer (the hub restarted mid-create) is tried once more under the same id, so the new hub answers the session the first one started rather than start a second.

--json failures. With --json, every failure other than a usage error (exit 2, plain) also prints one JSON object on stdout before the exit; the stderr line and exit 1 are as without --json. The one exception is stdout itself failing: a result that cannot be written (stdout closed, a full disk) exits 1 with the write's error on stderr, and stdout may hold nothing or part of the object. A refusal by the hub is the wire's error, whole, under one key, so a script reads .error.data.code, .error.data.reason and .error.data.cause where it reads them on the wire:

{"error":{"code":-32000,"message":"the session did not start: acp: agent exited: exit status 1: Error: KEYCHAIN LOCKED / Run unlock and retry.","data":{"code":"not_accepting","reason":"start_failed","cause":"acp: agent exited: exit status 1: Error: KEYCHAIN LOCKED / Run unlock and retry."}}}

Any other failure (no hub to be had, a -C that is no directory, an answer that does not decode) is the message alone:

{"error":{"message":"/no/such/dir is not a directory"}}

A success has no error key, and a session that started but refused its first prompt is still the success object (exit 1, as before). Check the exit code, then .error.

On macOS a session the hub creates runs in the hub's login (security) session, and a hub keeps the session of whichever command first started it for as long as it runs — and it runs while any session does. The login keychain, which cursor needs, is unlocked only in the GUI login session, so start craze, or its hub, from a terminal in your Mac's login session — a Roost tab, Terminal.app — for agents that need it. A hub first started over ssh cannot start a cursor session: the start fails, and its error ends with a hint saying so and naming the hub's pid, …; this session runs outside your macOS login session (its launcher was started over ssh): if the agent needs the login keychain, as cursor does, stop the hub with kill 4242 (no session ends) and run this again from a terminal in your Mac's login session (a Roost tab, Terminal.app). kill <hub pid> ends only the hub: every session runs on, and the next craze new starts a hub where it runs. The hint is added only when the agent exited, and it says "if": being outside the login session does not prove the keychain is what stopped the agent.

So over ssh to a Mac, cursor needs the GUI login session: run craze from a terminal on the Mac, or through shed-host-agent, not from the ssh login. A craze or craze prompt run over ssh that starts cursor itself (an explicit --provider cursor, or --continue/--resume of a cursor session) now names the reason while it starts instead of sitting at starting… with nothing to say. craze prompt writes one line on stderr and carries on:

craze prompt: cursor may not start here: this craze runs outside the macOS login session (over ssh), where cursor may not reach the login keychain; run craze from a terminal on the Mac

and the TUI draws the same words as a note in the transcript (cursor may not start here: …). It is a warning, never a refusal: nothing is stopped, and a cursor that can reach the keychain some other way starts as usual.

craze new --load

./bin/craze ps --saved
./bin/craze new --load 4c1d7e90
./bin/craze new --load 0190ab12-cd34-7ef0-8123-456789abcdef --json

Brings back a saved session — one whose host has gone (an idle exit, a reboot, a closed terminal) — through the hub's session.reopen, and returns once its start is done:

reopened 4c1d7e90 in ~/projects/lumen

The session is loaded in a host of its own (craze serve --load), in the directory it ran in and with the provider it ran with, as the session list resumes one. When a host serves the session already — another client reopened it, or it never stopped — the hub joins that host instead, starts nothing and says so:

already running 4c1d7e90 in ~/projects/lumen

Either way the session then runs as any detached session does: craze attach, craze send, the session list and craze ps find it, and with nobody attached its host idles out (within 5 minutes while it has had no new prompt). It sends no prompt: craze send it one.

The id. It is resolved against this craze directory's session index — running sessions included, so a reopen of one says already running — with the short session ids' tiers, the first that matches deciding:

  1. an exact id: a craze session id, a provider session id, or <provider>:<providerSessionId> (the id craze ps --saved --json prints for a row from a craze before craze ids);
  2. a craze session id's end — craze ps --saved's SESSION — or, for a row with no craze id, its provider session id's end (what SESSION shows for one);
  3. a craze session id's beginning.

A suffix or a prefix needs at least four characters. Two sessions in the deciding tier are refused, exit 1, naming them (craze new: 2 saved sessions match 1111: aaaa1111 (cursor, /w), bbbb1111 (grok, /v)); none is craze new: no saved session <id> (craze ps --saved lists them), exit 1. The whole id goes to the hub.

--json is the only other flag it takes: it prints session.reopen's result — {"session": <roster row>, "joined": …} — on one line. Like plain craze new, it never reads stdin. A prompt, and --prompt-file, --provider, --model, --effort, --fast, --no-fast, --no-force, --title, -C/--dir, --wait, --plan, --ask, --timeout and --max-message, are usage errors, exit 2 (craze new: --load does not take --provider: a reopened session keeps its own): a reopened session keeps its own directory, provider and settings.

It waits up to 4 minutes for the hub's answer (the hub's own bound is 180 s). A refusal is the hub's words on one line, exit 1: a session whose directory has gone (that session ran in <dir>, which is no longer a directory), one whose start failed (the session did not start: …, its host's error), one still held by a host that has not finished stopping (that session is held by host <id> (pid <pid>), which has not finished stopping; try again). With --json each failure is one JSON object, as plain craze new's are. A connection to the hub that ends before the answer is tried once more with the same id: a reopen is safe to send again, and ends on whatever host serves the session then.

A running hub from before reopen (its hello has no reopen) is this hub (craze <v>) cannot reopen a saved session; it exits once no session runs and no client is connected, or stop it now with kill <pid> (no session ends), exit 1: nothing is sent to it. A spawn_failed from a hub of another craze version gets the same upgrade remedy as plain craze new's.

craze new --wait

./bin/craze new --wait --provider native --model chatgpt/gpt-6.1-sol "fix the flaky test in stats/"
./bin/craze new --wait --timeout 9m --max-message 20000 --provider grok --prompt-file /tmp/task.md --remove-prompt-file
./bin/craze new --wait --plan --provider cursor --json "how would you split core.py?"

Hands a task to a new session and prints its result: what a coding agent runs to delegate work to a model craze can drive. It is another path than plain craze new's — no hub: the session's host is started here, as the TUI starts its own, and runs unattended:

  • Questions the agent asks are answered with their first option, as craze prompt answers them, and a plan it presents ends the turn there: it is the result (plan), never accepted on the agent's behalf — so the agent cannot go on to carry it out unread (each is listed in the result, auto-answered:, a plan as returned). Permissions are allowed under the default bypass; with --no-force a permission request stays open for a person to answer (needs_input, exit 4).
  • No background work: a native session runs every sub-agent and every shell command in the foreground, so a turn is whole when it ends.

The command starts the host, sends it the prompt and follows the turn the prompt became to its end, printing the result exactly as craze wait prints one — the turn's last message and a footer, or one JSON object with --json — and exiting with its status's code (Waiting commands): 0 completed, 3 failed or cancelled, 4 needs_input, 5 running at --timeout. The session runs on once the turn has ended, until its host's idle exit: craze send gives it the next prompt, with the context of the first; craze attach, craze ps and the session list find it like any session.

The host is detached from the command: in a session of its own, so it outlives the shell and the process group that ran it, and out of the command's process tree — craze starts it through a short-lived process that exits at once. So stopping the command — Ctrl+C, SIGTERM, or a tree kill such as Claude Code's TaskStop, which signals every process descended from the one it stops — never ends the host: the command cancels its own turn (Signals, below) and the session goes on. The host is handed the environment a session the hub creates is handed: none of CRAZE_PROVIDER, CRAZE_AGENT_BIN, TMUX, HERDR_* or ROOST_*. So it finds its agent binary in [agents] in config.toml, then on PATH, as such a session does. Its session index rows carry unattended, the settings it started with.

Flag Description
-C, --dir <dir> The session's directory (default: the current one)
--provider <id> The session's provider; without it provider in config.toml, and with none configured exit 1 (no default provider yet; pass --provider …)
--model, --effort, --fast / --no-fast, --no-force As plain craze new's
--title <title> As plain craze new's: the session's name from its start, pinned, normalized the same way (one that comes to nothing is a usage error). No hub is asked, so no hub's createTitle is needed: the host this starts is named directly
--plan, --ask Start in plan or ask mode — for a native session, --ask refuses edits and the shell, and --plan refuses edits but the agent's own plan file and lets the shell through; a task that says "read-only" is a request, not a rule. The turn ends at the plan, on every provider: the result is completed with the plan (plan), the session still in plan mode. A native session accepts its own plan and stops; on cursor and grok craze accepts nothing — it answers the plan cancelled and ends the turn (its stop reason cancelled, the plan auto-answered returned) — since an ACP agent told its plan is accepted goes on to carry it out. A plain craze wait afterwards reports such a turn the same way while its host still holds the turn's events, and cancelled once it does not; craze wait --from <handle> — the handle craze send prints — recognises it whenever its replay is honoured
--prompt-file <path> Read the prompt from this file, - for stdin (The prompt)
--remove-prompt-file Delete --prompt-file's file once it has been read
--timeout <duration> Stop waiting after this long, counted from the command's start — the host's start included: running, exit 5, with a handle. Before the prompt was sent, the session is stopped instead, exit 1, timed out before the prompt was sent
--max-message <n> As craze wait's
--json Print the result as one JSON object; a failure as {"error":{"message":…}}

Signals. The prompt is the command's own, as a handle's turn is craze wait --from's: Ctrl+C, SIGTERM or SIGHUP remove it while it is queued (dropped) or cancel its turn by its id (cancelled), exit 3, within 2 seconds; a second signal exits at once. The signal is the command's alone — the host, out of its process tree, never receives it — so the session runs on for craze send. One that comes before the prompt was sent — while the host starts, or the session does — stops the session the command started: cancelled, exit 3, its error interrupted before the prompt was sent; the session it started was stopped — or, when a second signal already came, … was told to stop: the stop is sent, and the command does not wait for the host to go. A prompt whose answer was lost, or cannot be read, may have reached the session, which is then never stopped.

A provider that cannot start is exit 1 with the host's own words, and the session is stopped:

craze new: session 8327352e did not start: agent binary not found: /opt/cursor/agent; install it, or set [agents].cursor in ~/.craze/config.toml; the session it started was stopped

A prompt whose answer was lost with its connection is never sent again: exit 1, the prompt may not have reached it; craze wait <short> follows the session.

craze send

./bin/craze send 8327352e "now add a test for it"
./bin/craze send 8327352e --wait --timeout 9m --max-message 20000 --prompt-file /tmp/next.md --remove-prompt-file
./bin/craze send 8327352e --json "and the docs"

Sends a prompt to a session — any id craze ps prints, or the index's for one that has ended — queued behind any turn it is running, as a prompt typed in its TUI is. Without --wait it prints where the prompt went and the command that follows it, and exits 0:

queued q-2 (position 1)
next: craze wait 8327352e --from 0199c0de-1111-7000-8000-397e175bfe79.41.row-q-2

started turn-3 when the session was idle. position counts from the next prompt to run; it is taken from the stream, or else from the session's queue as it stands, and is left out — queued q-2 alone — when the prompt has already left the queue by then. A turn whose start the stream did not show before the command gave up looking prints next: craze wait <short>, with no handle. With --json, one object: status (started or queued), session, short, provider, turn or row and position (absent when unknown), handle, attach.

With --wait it follows this prompt to its turn's end — not a turn ahead of it in the queue — and prints its result as craze wait does, with the same statuses, exit codes and signals as craze new --wait: a signal removes the prompt while it waits in the queue (dropped) or cancels its turn (cancelled), and one before the prompt was sent is cancelled too — the session left as it was.

A session that has ended is loaded again. When no running session has the id, it is looked up in the session index — the newest row of each session, by the same short, prefix and whole-id rules (a row written before craze ids existed is not addressable; a session of a provider craze cannot load again has no row: no session <id>; several match: exit 1, naming them) — and a host is started to load it, as craze new --wait starts one. A session craze new --wait started is loaded unattended again, with the mode, permission mode, model and effort it started with (its row's unattended); a session of your own is loaded as the ordinary interactive session --resume would give you, so craze send is also a way to message a saved session. The same happens when a running session's host is closing it — stopped, or idling out: craze waits, up to 10 seconds, for that host to go (or to keep the session) and loads it in its own; a host that is gone by the time craze dials it is looked past the same way. Two craze sends loading one session at once end up in one host, each prompt its own turn. A session whose directory is gone, or that its agent refuses to load, is exit 1 with the host's own words. The host a reload starts is never stopped by craze send, whatever becomes of its prompt — another craze send may have found it and prompted it meanwhile — not even when craze send gives up while that host is still starting (a signal, --timeout): it stays up like any session's host (an interactive one idles out; an unattended one until craze stop).

Flag Description
--wait Wait for the prompt's turn to end and print its result
--prompt-file <path> Read the prompt from this file, - for stdin (The prompt)
--remove-prompt-file Delete --prompt-file's file once it has been read
--timeout <duration> Give up after this long, counted from the command's start (a reload included). With --wait, running at its end, exit 5, with a handle
--max-message <n> With --wait, as craze wait's (a usage error without it)
--json One JSON object: the result with --wait, where the prompt went without it; a failure as {"error":{"message":…}}

The prompt

craze new --wait and craze send take their prompt from exactly one source:

  • --prompt-file <path> — a file, or stdin for -;
  • the words after the command's arguments, joined by spaces;
  • stdin, when neither is given and stdin is not a terminal (/dev/null counts as one).

Words and --prompt-file together are a usage error (exit 2), and so are no prompt at all, a prompt that is empty once the whitespace around it is gone, and one over 32 KiB — the most a queued prompt may be — each refused before anything is started or sent. --remove-prompt-file deletes the file once it has been read, whatever the prompt turns out to be (empty, too long), and never a file it could not read; a file it cannot delete is said on stderr, and the command goes on. The prompt is read under the command's --timeout and its signals: a source that does not end — a pipe held open, a FIFO nobody writes — ends the command, exit 1, timed out reading the prompt from stdin or interrupted while reading the prompt from <path>, before anything is started. Plain craze new keeps its own rule: its words, or no prompt — it never reads stdin.

craze wait

./bin/craze wait 8327352e
./bin/craze wait 8327352e --timeout 9m --max-message 20000
./bin/craze wait 8327352e --from 0199c0de-1111-7000-8000-00000000a001.41.turn-3 --json

Follows one turn of a running session and prints its result. The id is any form craze ps prints, resolved as short session ids are; the session's whole id is what goes on the wire.

  • craze wait <id> follows the turn the session is working on — one craze started, or one the agent runs on its own — from where it is when the command attaches, and with nothing running reports the last turn at once. A session that has had no turn is idle. Its result is partial: what happened before the attach was not seen, so its tool calls, files and time count from what the session still holds, and an idle session's last turn has no stop reason and no usage — the session's last usage report need not be that turn's (a turn withdrawn before it opened reports none). It never touches the session: a signal (Ctrl+C, SIGTERM, SIGHUP) exits 1, and the turn goes on.
  • --from <handle> follows exactly the turn or queued prompt a handle names, from the handle's place in the session's stream, so a turn that has ended since is still reported: whole when the handle's place is before the turn began — a queued prompt's, or a turn's that its own command started — and partial when the handle was taken mid-turn (plain craze wait's). A running or needs_input result prints one (next:, or .handle with --json). A handle is <incarnation>.<seq>.<target> — the session's host incarnation, a place in its stream, and turn-<n>, turn-<n>.row-<id> (a turn that came from a queued prompt), row-<id> (a queued prompt) or foreign-<id> (a turn the agent ran on its own, its id base64url-encoded) — letters, digits, ., _ and - only, so it needs no quoting. A prompt that was queued keeps its row-<id> handle once its turn runs: followed from it, the wait follows the prompt into its turn — and on into its next run, should the agent's own turn refuse it and put it back. A turn met mid-way — plain craze wait's, or one picked up again from where the session stands after a reconnect the host could not replay — is named by its turn-<n>, since a handle names only what its place in the stream can follow; when that turn came from a queued prompt, the prompt is named beside it (turn-<n>.row-<id>), and a wait from the handle still follows the prompt into its next run should the turn be refused. A handle is ownership: a signal while --from waits cancels its own work and nothing else — a queued prompt is removed (dropped), its running turn cancelled by its id (cancelled, or whatever it ended as first) — within 2 seconds, or exit 1, cancel sent, not confirmed; a second signal exits at once, without waiting even for its detach from the session. A turn the agent runs on its own is never cancelled this way, nor is anything in a host that is not the one the turn ran in: a cancel that would land in a restarted host is not sent (exit 1, the session's host changed …).
  • While the command waits for the session to start (an attach to a session still coming up), a signal ends it at once — exit 1, interrupted before session <short> answered, the session untouched — as --timeout's end does (timed out before session <short> answered).
  • As the command exits it detaches from the session, waiting at most a few seconds for the host's answer (after a signal, what is left of its 2 seconds); a signal then ends the detach at once.
The flaky test raced the cache's eviction; I made eviction wait for readers.
---
completed · 27s · 5 tool calls · $0.04 · context 16.6k/272k
files: stats/core.py
session 175bfe79 · craze attach --session 175bfe79

The message is the turn's last assistant message — the reply after its last tool call, thought or plan — and (no final message) when the turn wrote none: never an earlier turn's. A plan the turn presented follows it, under plan:. The footer's first line is the status, the turn's time and its tool calls, then — for a native session — what the turn cost (unpriced when none of it had a price; + when some did not) and how full the context is. Then, each when there is one: error: (a failed turn), stopped: (a stop other than end_turn), files: (the paths the turn's edits touched), one auto-answered: line per question or plan craze's approval policy answered, ask: and blocked by: (needs_input), the session, and next: — the exact command to run next for a running or needs_input result.

Status Exit
completed 0 The turn ended end_turn, with no error
idle 0 The session has had no turn
failed 3 The turn ended with an error, or with a stop other than end_turn
cancelled 3 The turn was cancelled
dropped 3 The queued prompt was removed before it ran
needs_input 4 An ask is open that nothing here answers — a permission under --no-force, a question or a plan on an interactive host — the turn's own, or, for a queued prompt, the one holding up the turn ahead of it (blocked by:). Answer it in craze attach, or craze cancel or craze stop the session; the session is left as it is
running 5 --timeout passed; the turn goes on, and craze wait <id> --from <handle> follows it. Nothing looked at once --timeout has passed — an ask the turn opened among them — changes this answer
Flag Description
--from <handle> Follow exactly the turn or queued prompt the handle names, from its place in the stream; a signal then cancels it
--timeout <duration> Stop waiting after this long, counted from the command's start (9m, 30s): running, exit 5, with a handle. Default: no limit
--max-message <n> Print at most n characters of the message and the plan together, never more: the message first, then the plan with what is left — cut back to the end of its last whole line when one ends within that, and left out when nothing is left. When anything is cut or left out, the plain output ends its message and plan with [… cut: full result in <path>], and the whole result — the message, plan and footer, or the JSON — is written to ~/.cache/craze/results/<short>-<inc>-<turn>.md (0600), where <inc> is the last 8 characters of the incarnation the turn ran in. A reloaded session numbers its turns from turn-1 again, so this keeps each turn's file its own. If that file cannot be written, the output is cut all the same: the marker reads [… cut: the full result could not be saved: <reason>], stderr says so, and --json names no messageFile. Default: no limit
--json Print the result as one JSON object on one line

With --json, one object. These keys are always there: status, session (the whole id), short, provider, model and attach; and with a turn or a queued prompt, turn and/or row.

Key
message, toolCalls, durationMs, files On completed, failed, cancelled and dropped, always: "", 0 and [] when there is none
stopReason, error How the turn ended; stopReason is absent when unknown (a partial result for an idle session)
plan The text of a plan the turn presented, accepted automatically or not
usage The last usage report before the turn's end (native sessions), as the stream's usage section carries it: {contextTokens, contextWindow, turn, session}. A partial result settled from where the session stands carries only what was seen of the turn itself — none for an idle session's last turn
autoAnswered [{kind, summary, choice}]: each question or plan craze's approval policy answered
ask, blockedBy needs_input: the open ask, {id, kind, summary}, and for a queued prompt the turn holding it up
handle running, needs_input, and a failure with one
partial true when some of the turn's events were not seen
messageCut true when the message was longer than 1 MiB: its end is kept
messageFile --max-message's file, holding the whole result, when it cut or left out the message or the plan; message and plan are then what was kept

A key that does not apply is absent, never null or false:

{"status":"completed","session":"0199c0de-1111-7000-8000-397e175bfe79","short":"175bfe79","provider":"native","model":"chatgpt/gpt-6.1-sol","turn":"turn-2","message":"The flaky test raced …","stopReason":"end_turn","toolCalls":5,"durationMs":27100,"files":["stats/core.py"],"usage":{"contextTokens":16590,"contextWindow":272000,"turn":{…},"session":{…}},"attach":"craze attach --session 175bfe79"}

A dropped connection is redialled and the stream picked up where it was; a host that can no longer replay that far answers with where the session stands, and the result is then partial. A failure is exit 1 with one line, and with --json one {"error":{"message":…}} object on stdout first — handle beside it when there is one — as craze new's failures are; a usage error stays plain (exit 2):

craze wait: no session <id>
craze wait: the session has ended (craze send <short> <prompt> reloads it)
craze wait: interrupted before session <short> answered
craze wait: timed out before session <short> answered
craze wait: timed out before the turn could be followed; craze wait <short> --from <handle> follows it
craze wait: the session ended (craze send <short> <prompt> reloads it) (handle <handle>)
craze wait: the turn's ending was not observed (handle <handle>)
craze wait: the session's host changed before the cancel reached it; the turn's ending was not observed (handle <handle>)
craze wait: lost the connection to session <short>: <reason>; the turn runs on in its host; craze wait <short> --from <handle> follows it
craze wait: cancel sent, not confirmed; craze wait <short> --from <handle> follows it

has ended is an id no running session has, which the session index still holds; with --from, the handle is named beside it, as it is with every failure once there is one. ended is the session ending while the command waited — a turn the session's close cut short included (its stop reason closing is the session's end, not the turn failing) — and its handle still names the turn. not observed is a turn whose end cannot be learnt: its host restarted (turn ids start again in a new host, so nothing the new host says is taken for the turn's), or the turn ended and others ran since, out of the host's reach.

craze cancel

./bin/craze cancel 8327352e

Cancels the turn the session is working on — whoever started it, a turn the agent runs on its own included — and prints cancelled turn-3 (cancelled alone when no turn of craze's ran: the agent's own turn, or an ask open between turns). With nothing running it prints nothing to cancel, exit 0. The session goes on, and so does its queue: the next queued prompt starts. The id is resolved as craze wait's; a failure is exit 1 with one line.

craze stop

./bin/craze stop 8327352e

Ends the session — its turn cancelled, its queue cleared, its host gone — and prints stopped 8327352e once its host has taken the stop. It stays in the session index, to resume. A session a craze TUI hosts cannot be stopped from here (exit 1): close it there. The id is resolved as craze wait's.

craze auth

craze auth login [provider] [--no-browser]
craze auth logout <provider>
craze auth list

Manages the API keys of the native provider's model providers, and the ChatGPT plan's sign-in. A key is stored as its provider's api_key in native/providers.toml in the craze directory (~/.craze, or $CRAZE_HOME), written at 0600; a provider's environment variable, when it holds a usable key, is used before the stored one. No key is checked with its provider when it is stored — a wrong one shows on first use — and for a key none of the three commands makes a network request, but for one: a key stored for Anthropic is followed by the account's model list, fetched with it (below). What they do to the file is under Keys.

The ChatGPT plan (chatgpt) takes no key: it is funded by signing in with ChatGPT, so for it login signs in, logout signs out, and list shows the sign-in. Those talk to OpenAI's sign-in service, and nothing else does.

A provider is named by its id or its display name, in any case: fireworks, Fireworks, "z.ai coding plan". The providers are the catalog's and any of your own in providers.toml (a directory whose models.toml says catalog = false has only its own). All three exit 1 when there is no craze directory (neither HOME nor CRAZE_HOME is set). An argument too many, or a flag a command does not take, is exit 2, and what was typed is not repeated back: it may be a key.

craze auth login

Stores a provider's key. On a terminal, craze asks for it with a prompt that does not echo (Fireworks API key:) — after a numbered list of the providers to pick from when none is named, the connected ones marked (connected). The echo is off from before the first prompt is drawn until the key is read, so nothing typed at either prompt shows, however quickly it comes: at the list, only a number on it is written back, so a key pasted there by mistake is never displayed. Ctrl-C at either prompt leaves the terminal's echo on. When stdin is not a terminal, the key is stdin's first line (at most 8 KiB), so a script can pipe it in. Surrounding whitespace is trimmed.

craze auth login fireworks        # prompts; the key is not shown as you type
craze auth login                  # pick from the list, then the prompt
printf '%s\n' "$FIREWORKS_KEY" | craze auth login fireworks
Saved the Fireworks key in /home/you/.craze/native/providers.toml.
FIREWORKS_API_KEY is set in this environment; craze uses it before the stored key.

The second line only when that variable is set. Nothing is saved, and craze exits 1, when the key is empty, shorter than 8 bytes, or overlaps craze's redaction marker — the error names the rule, never the key.

craze auth login anthropic then fetches the Anthropic account's model list (GET /v1/models, at most 30 seconds) with the key that funds Anthropic — the one just saved, or ANTHROPIC_API_KEY when that variable is set — so the next native session offers the account's models from its start:

Saved the Anthropic key in /home/you/.craze/native/providers.toml.
Fetched 13 Anthropic models.

A list that cannot be fetched — a key Anthropic rejects, no network — is one note: on stderr instead, naming the status and never the key; the key stays saved, craze exits 0, and a native session fetches the list when it opens. No provider named without a terminal, or a name that matches none, is exit 2; the name is not repeated back, in case what was typed there was the key. Another provider's stored key that cannot be used is kept as it is and named in a note on stderr, so it can be replaced or removed next.

Signing in to the ChatGPT plan

craze auth login chatgpt — or ChatGPT plan picked from the list — signs in with ChatGPT instead of asking for a key: no key is read, or asked for, for the plan. craze prints an address to open in a browser, where you sign in to ChatGPT and approve craze, and waits for the browser to come back to http://127.0.0.1:1455/auth/callback, a listener craze runs for the length of the sign-in. On a Linux desktop (DISPLAY or WAYLAND_DISPLAY set) or on macOS, and not over SSH, it opens the address in your browser itself.

When the browser is on another machine, the page it is sent back to does not load there: copy that page's whole address from the browser's address bar and paste it at craze's Redirect address: prompt. Like the key prompt, it does not echo — the terminal's echo is off from before the address is printed until the sign-in ends — so a key pasted there by mistake is never displayed. An address craze accepts is confirmed by where it leads alone, never its query (the one-time code):

Redirect address:
Received the redirect to http://127.0.0.1:1455/auth/callback; signing in.

--no-browser opens no browser and runs no listener, so the pasted address is the only way in: the way to sign in from an SSH session or a container. With stdin not a terminal, each line of it is taken as a pasted address.

The address is printed plain, on one line — no box, no escape sequences — so any terminal can select it whole, and one that finds links in text can open it. It is the fallback for a terminal where /connect's box gets the address to neither the clipboard nor a link (see the TUI reference).

craze auth login chatgpt                # opens the browser on a desktop, else prints the address
craze auth login chatgpt --no-browser   # paste the redirect address from any machine
Sign in with ChatGPT to use your ChatGPT plan in craze. Open this address in a browser and approve craze:

  https://auth.openai.com/api/accounts/authorize?client_id=…

craze is waiting for the browser to come back to http://127.0.0.1:1455/auth/callback.
If the browser is on another machine, that page will not load there: copy its whole address from the address bar and paste it here.
Redirect address:
Signed in to ChatGPT as you@example.com.
You're using your ChatGPT plan.
Eligible usage in this app uses your ChatGPT plan. Manage usage in your ChatGPT settings: https://chatgpt.com/settings/usage
ChatGPT plan models: chatgpt/gpt-6.1-sol, chatgpt/gpt-6-astra, chatgpt/gpt-6-sol, chatgpt/gpt-6-luna, chatgpt/gpt-5.6-sol, …

The two notice lines appear once, the first time an account signs in with plan usage; a later sign-in prints the account and the models alone. The models are the account's own list, fetched then and there (a native session fetches it again when it is a day old); a list that cannot be fetched is a note on stderr, and the sign-in stands. Signing in again reuses craze's registration with ChatGPT, so the browser asks only which account to use.

A pasted line that is not this sign-in's address is refused — never repeated back — and craze goes on waiting: one that is not an address at all (a key, say) is told the plan takes none, and the address of an earlier sign-in's redirect is told That is the redirect of an earlier sign-in attempt. Use the address shown above. When the browser comes back to craze's listener from a different sign-in attempt — an earlier one's approval, say — craze says so, once: The browser came back from a different sign-in attempt. Use the address shown above. When craze could not listen at all, it says why before what to paste: craze is not listening for the browser: another program is using 127.0.0.1:1455. With --no-browser, or when stdin is not a terminal and no listener runs, the end of stdin with no address accepted is exit 1. Ctrl-C cancels the sign-in and closes the listener, exit 130, and changes nothing; it, SIGTERM and SIGHUP leave the terminal's echo on. Declining in the browser is exit 1, as is any error from ChatGPT's sign-in service, which is named by its step, its HTTP status and its OAuth error code — never a token, which craze never prints. A step that runs out of time is named too: craze auth login: exchange: timed out after 30s.

An account that signs in without allowing craze to use its plan — the permission ChatGPT asks for alongside the sign-in — is signed in with plan usage off: craze keeps no tokens, its models cannot be used, and the next craze auth login chatgpt asks ChatGPT to show the permission again:

Signed in to ChatGPT as you@example.com, but ChatGPT plan usage is off: the account did not allow craze to use its ChatGPT plan, so the plan's models cannot be used.
To turn it on, run craze auth login chatgpt again and allow ChatGPT plan usage when ChatGPT asks.

The sign-in log

Every sign-in — craze auth login chatgpt and the TUI's /connect alike — is recorded in the craze directory's native/logs/signin.log, one JSON line per thing that happened, from the attempt's start to its one outcome and the model list fetched after it: the place to look when a sign-in did not finish. The logs directory is 0700 and the file 0600. craze rotates the file once, to signin.log.1 (replacing the one before), when a line would take it past 1 MiB, so a log of craze's own writing stays near 2 MiB across the two. A file something else grew past that size is rotated whole when craze next writes, so the two can then hold more. Several craze processes may write it at once; each line is appended whole, under a lock.

The log is value-free: a record holds the time, which surface wrote it (cli or tui), the attempt's id — 8 random hex digits, the same on every record of one attempt — the event, and the fields below: fixed words, HTTP statuses, a port, counts and milliseconds. It never holds an address or its query, a pasted line, an authorization code, a state, a nonce, a PKCE value, the host id, a token, an email, the account's id, the client id, or any text a server sent. craze checks every field as it writes it, and writes invalid for a value outside its fixed set, so the log is safe to share when asking for help.

{"time":"2026-10-03T12:34:56.789Z","surface":"cli","attempt":"0a1b2c3d","event":"begin","mode":"paste_only","reason":"port_busy","port":1455,"registration":"new","elapsed_ms":3}
{"time":"2026-10-03T12:35:26.789Z","surface":"cli","attempt":"0a1b2c3d","event":"paste_refused","refusal":"mismatch","part":"state","elapsed_ms":30000}
{"time":"2026-10-03T12:35:41.789Z","surface":"cli","attempt":"0a1b2c3d","event":"redirect_received","via":"paste","elapsed_ms":45000}
{"time":"2026-10-03T12:35:42.789Z","surface":"cli","attempt":"0a1b2c3d","event":"signed_in","usage":"plan","registration":"new","elapsed_ms":46000}
{"time":"2026-10-03T12:35:43.589Z","surface":"cli","attempt":"0a1b2c3d","event":"models_fetched","models":8,"client_version":"0.160.0","elapsed_ms":800}
Event What happened Its fields
begin A sign-in started. mode: listening (craze waits on 127.0.0.1 for the browser) or paste_only. reason, when it does not listen on 1455: requested (--no-browser), port_busy (another program has the port), listen_failed. port. registration: new (craze registers with ChatGPT) or reused.
browser_opened, browser_failed craze opened the address in a browser, or could not. —
address_copied A click on the address, or Ctrl+Y, in /connect copied it; whether a clipboard took it is not known. —
listener_refused The listener refused a request on its callback path, the first of its kind in the attempt (the rest are counted). A request to any other path — a browser's favicon — is never recorded. refusal: other_attempt (the browser came back from a different sign-in attempt), not_ours (another request without the sign-in's state), over (the sign-in already had its redirect). status: 400, or 410 for over.
listener_refusals At the attempt's end, how many times one kind was refused, when it was more than once. refusal, status, count.
paste_refused A pasted line was refused. refusal: too_long, not_address (an API key pasted out of habit, say), mismatch, over. part, of a mismatch: scheme, userinfo, host, port, path, or state (the redirect of an earlier attempt).
redirect_received The browser's redirect arrived. via: listener or paste.
signed_in The sign-in finished. usage: plan, or off (plan usage not allowed). registration.
declined The sign-in was declined in the browser. step authorize, code access_denied.
failed The sign-in failed. step: begin, authorize, redirect, exchange, discovery, jwks, id_token or install. class: refused (ChatGPT said no), timeout (the step ran out of time, or waited too long for another craze's lock), network, dns, tls, http_redirect, bad_reply, bad_redirect, id_token, filesystem, cancelled, signed_out, sign_in_again, plan_usage_off, usage_limited, config or other. For a refusal, status and code (an OAuth error code craze knows, or unrecognised); for an id_token, the check that failed (signature, iss, aud, exp, nonce, subject, …).
cancelled The sign-in was stopped before it finished. reason: signal (Ctrl-C, SIGTERM, SIGHUP), no_input (stdin ended), esc, dialog (the box closed another way, or the session was switched), shutdown (craze quit, or the session ended), context, done.
models_fetched, models_empty, models_failed The plan's model list after a sign-in: fetched (models, how many), an empty reply that kept the list before, or the failure (step, status, code, class). client_version: the version craze asks for the list with.

Every attempt has exactly one of signed_in, declined, failed and cancelled. A record may also carry elapsed_ms and dropped. elapsed_ms is the time since its attempt began on an attempt's own events (begin, redirect_received, signed_in, failed and the rest), but on the model events (models_fetched, models_empty, models_failed) it is how long that fetch itself took, which is not measured from the attempt's start. dropped is how many records were lost just before it because the log was busy. A log craze cannot keep — its directory a symbolic link, writable by other users or another user's, or the file not a plain file of its own — is one note: on stderr as the command ends, and the sign-in goes on without it. A sign-in never waits on the log: it is written beside the sign-in, and at the end craze waits at most a second for what is still to be written; records it could not write in that second are lost, and are the same one note:.

craze auth logout

Removes a provider's stored key, and only the key: whatever else its entry sets stays. Exit 0 whether or not there was one.

Removed the stored Fireworks key.
Fireworks is still connected through FIREWORKS_API_KEY.

With nothing stored the first line is No stored Fireworks key.; the second is there only while a variable still funds the provider. No provider named is exit 2. craze auth logout anthropic keeps the account's model list: it holds no key, and without one it funds nothing.

craze auth logout chatgpt signs out of the ChatGPT plan: craze asks ChatGPT to revoke the sign-in (waiting at most 10 seconds) and deletes its tokens from this machine whatever the answer, keeping its registration so the next sign-in reuses it:

Signed out of ChatGPT: the sign-in is revoked, and its tokens are deleted from this machine.
An access token already issued may keep working for up to an hour, until it expires.

When ChatGPT does not confirm the revocation, the first line says remote revocation not confirmed and that craze can be disconnected in ChatGPT's settings, under Apps. Not signed in, it prints Not signed in to ChatGPT. A key someone wrote for the plan in providers.toml, which it never uses, is removed as well.

craze auth list

One row per provider, by display name: its name, its id, and how it is connected — env <VAR>, stored key or not connected, in the order a session tries them. The ChatGPT plan's row is its sign-in: signed in as <email> · ChatGPT plan · renews automatically (its tokens are renewed as they near expiry, with no action from you), plan usage disabled — run craze auth login chatgpt, or not signed in. It is read from the registration and the token file's presence; no token is read, and none is ever printed.

Anthropic         anthropic        env ANTHROPIC_API_KEY
ChatGPT plan      chatgpt          signed in as you@example.com · ChatGPT plan · renews automatically
Fireworks         fireworks        env FIREWORKS_API_KEY
Meta              meta             stored key
OpenRouter        openrouter       not connected
Z.AI Coding Plan  zai-coding-plan  not connected

Then notes, on stderr, each starting note:: a stored key that cannot be used, an entry of yours that repeats what craze ships (delete it to follow craze's updates), the warnings a native session prints when it starts, and — when the model table does not load, a broken models.toml say — why. The rows need only providers.toml and the catalog, so they are listed either way. No key, nor any part of one, is ever printed.

craze providers

./bin/craze providers
./bin/craze providers --json
./bin/craze providers --hub
./bin/craze providers --hub --json

Lists the agent providers craze can start a session of — cursor, grok, gx when it is installed, and native — with each one's state on this machine and, for a provider that is not ready, why and what to do about it. These are agent providers, what --provider picks; craze auth list lists the native provider's model providers and their keys.

PROVIDER  STATE        REASON                          FIX
cursor    unavailable  cursor-agent not found on PATH  install cursor-agent, or set [agents].cursor in /home/you/.craze/config.toml
grok      ready        -                               -
native    needs setup  no model provider has a key     craze auth login (an API key, or "craze auth login chatgpt" for a ChatGPT plan)
State When
ready It can start here
needs setup Native, with no model whose provider has a key: none is stored or exported, or the ChatGPT plan is signed in with plan usage off. craze auth login connects one
unavailable It cannot start here, and nothing in craze can fix that: the binary is missing, cursor is outside the Mac's login session, or native's model files cannot be read

How each is judged:

  • cursor, grok and gx are ready when their agent binary resolves the way a session of theirs would find it (Agent binaries): $CRAZE_AGENT_BIN for the provider a plain craze would start ($CRAZE_PROVIDER, else provider in config.toml, else cursor), then [agents].<provider>, then the provider's own name on PATH. A binary that is not found makes it unavailable, and the reason names where craze looked: cursor-agent not found on PATH, or the override and its path, [agents].cursor /opt/x/cursor-agent not found (the fix then says to point that override at an existing binary). gx is a fork few machines have, so when it is not found it is not listed at all.
  • cursor, on macOS, is also unavailable when this craze runs outside the Mac's login session — started over ssh — where cursor may not reach the login keychain (this craze runs outside the macOS login session (over ssh), …; fix: run craze from a terminal on the Mac). A missing binary is reported first. Elsewhere the session is not known, and nothing is marked for it.
  • native is ready when its model table loads and a model's provider has a key — what a native session would start on. With none it needs setup. providers.toml or models.toml that cannot be read makes it unavailable; the reason names the file and nothing from it, since the error can quote a pasted key's name, and craze auth list shows the error. So does a machine with no home directory (no home directory for native's keys).

A reason and a fix are each one line of at most 200 cells. Nothing is cached: each run reads config.toml, looks the binaries up and reads native's files again, with no network request and no keychain access. The TUI's provider dialog and the session list's /provider show the same states and refuse a provider that cannot start when it is picked there (Provider states); everywhere else the states only report: an explicit --provider <id> (like --resume and --continue) starts a provider whatever its state, with the same start error as ever when it cannot start. $CRAZE_PROVIDER (like config.toml's provider) only chooses the provider dialog's default, which the check gates like any other row.

Flag Description
--json Print the providers on one line, as JSON, instead of the table
--hub Ask the running hub instead: its own view, its default provider and its recent directories (below). It never starts a hub
{"providers":[{"id":"cursor","label":"cursor","state":"unavailable","reason":"cursor-agent not found on PATH","fix":"install cursor-agent, or set [agents].cursor in /home/you/.craze/config.toml"},{"id":"grok","label":"grok","state":"ready"},{"id":"native","label":"native","state":"needs_setup","reason":"no model provider has a key","fix":"craze auth login (an API key, or \"craze auth login chatgpt\" for a ChatGPT plan)"}]}

Each entry has the provider's id (what --provider takes), its label (what the TUI shows), its state — ready, needs_setup or unavailable — and a reason and a fix, present exactly when the state is not ready: the objects the hub's sessions.createOptions answers with.

--hub

--hub asks the running per-machine hub — what craze new and a phone create through — instead of answering from this process. The hub judges each provider as a session it creates would start: its binary from [agents] in the hub's config.toml, then the hub's PATH, never $CRAZE_AGENT_BIN; native from the hub's craze directory; and, on macOS, cursor from the hub's login session, so a hub first started over ssh shows cursor unavailable even when this terminal is in the Mac's login session (the fix names the hub's pid to stop). After the table come the provider a craze new without --provider starts and the directories sessions last ran in, newest first:

PROVIDER  STATE        REASON                          FIX
cursor    unavailable  cursor-agent not found on PATH  install cursor-agent, or set [agents].cursor in /home/you/.craze/config.toml
grok      ready        -                               -
native    needs setup  no model provider has a key     craze auth login (an API key, or "craze auth login chatgpt" for a ChatGPT plan)
default: grok
recent: /home/you/projects/lumen
recent: /home/you/projects/shed

default: - is no default (none set, or one craze does not know) and recent: - no directory. --hub --json prints the hub's sessions.createOptions result as the hub wrote it: providers, defaultProvider when there is one, and recentDirs (dir, usedAt).

--hub finds a running hub and never starts one — over ssh, a started hub would be the very ssh-born hub it is asking about. With none running it says no hub is running and exits 1 (craze ps starts one). A hub from an older craze, which cannot answer, is reported (this hub (craze …) cannot list what it can create; it exits once no session runs and no client is connected, or stop it now with kill <pid> (no session ends)), exit 1, and left running.

craze providers exits 0 whenever it answered, whatever the states say; 1 on an error, --hub with no hub to ask included; and 2 for an argument or a flag it does not take.

craze version

Print the craze version and exit.

./bin/craze version

craze completion

craze completion bash|zsh|fish|powershell

Prints a shell completion script for craze, generated by cobra, the command library craze is built on. Load it the way your shell loads any completion script; craze completion <shell> --help says how for that shell. craze help [command] is the same text as craze [command] --help.

Exit codes

Code When
0 Session started; quitting after a usable session, including an error during the session; also Esc on the --resume picker (no session was ever started)
1 Session never started (no login, agent would not come up — a binary that is missing says agent binary not found: cursor-agent is not on PATH (also tried agent); install it, or set [agents].cursor in <config> — or a load the agent refused), a runtime failure, or nothing to --continue/--resume — no matching session, or a session index craze could not read
2 Usage error: an unknown or malformed flag, a stray argument, an unknown command or provider or theme, a flag value or combination craze refuses (--ask with --plan, --continue with --resume), missing prompt text, or a non-tty TUI. The non-tty refusal is checked before --continue/--resume are considered. craze bridge is the exception: it exits 1 for every failure, usage errors included

A usage error never echoes what was typed (a mistyped word may be a pasted key): craze: unknown command; see craze --help (with (did you mean "ps"?) when a command name is close), craze ps: takes no arguments; see craze ps --help, craze prompt: unknown or malformed flag; see craze prompt --help. A flag with an invalid value keeps its own message.

craze frame uses 2 for a bad key script and 3 for a wait timeout; that command is hidden. See Testing.

Waiting commands

craze wait, craze new --wait and craze send --wait exit with their result's status, so a script can branch on the code alone:

Code Status When
0 completed, idle The turn ended end_turn with no error; wait on a session that has had no turn
1 — craze failed: no such session, the session has ended, unreachable, a lost connection or an ending not observed (with the handle), the session ending while it waited, a cancel not confirmed after a signal; for new --wait and send, a session that could not start or load, --timeout before the prompt was sent, and a prompt whose answer was lost (the prompt may not have reached it, never sent again)
2 — Usage error — for new --wait and send, no prompt, two prompts, an empty one or one over 32 KiB too
3 failed, cancelled, dropped The turn ended without completing; the queued prompt was removed before it ran; a new --wait or send --wait interrupted before its prompt was sent
4 needs_input An ask is open that nothing here answers
5 running --timeout passed; the turn goes on (craze wait <id> --from <handle> follows it)

craze cancel and craze stop exit 0 when they did what they say — nothing to cancel included — 1 on a failure, and 2 for a usage error.