zerostack

Configuration

zerostack reads an optional config file. It supports TOML, YAML and JSON formats. The file is resolved by priority:

If a config.toml exists at a higher priority, it is used. If none exists at any priority, a default config.toml is created in the lowest-priority directory (~/.local/share/zerostack/). On macOS the XDG config path above resolves to ~/Library/Application Support/zerostack/.

Project-local override: if .zerostack/config.toml exists in the current working directory, it is merged over the global config at startup. Any subset of keys may be set — tables (e.g. mcp_servers, quick_models, api_keys) merge per key, scalars and arrays replace the global value, and keys absent from the local file keep their global values. A startup note is printed whenever an override is applied.

# .zerostack/config.toml
model = "anthropic/claude-sonnet-4-5"
show_reasoning = true

[mcp_servers.local-fs]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]

The local file is trusted exactly like the global config — it can enable yolo/accept-all, add permission rules, or spawn MCP server processes — so review .zerostack/config.toml when working in untrusted checkouts.

Prompts and themes are loaded from multiple sources, with later sources overriding earlier ones for same-named files:

Prompts (priority low to high):

  1. Embedded at compile time
  2. ~/.local/share/zerostack/prompts/ (global, user-level)
  3. prompts/ (project-local, relative to CWD)
  4. .zerostack/prompts/ (project-level config, highest priority)

Themes (priority low to high):

  1. Embedded at compile time
  2. ~/.local/share/zerostack/themes/ (global, user-level)
  3. themes/ (project-local, relative to CWD)

If ZS_CONFIG_DIR is set, it overrides the data directory for the config file location only (prompts and themes still use ZS_DATA_DIR / the default data dir). Set ZS_CONFIG_DIR when you want the config in a separate path from the data files.

All config keys are optional. CLI flags and their environment-backed values (such as ZS_PROVIDER and ZS_MODEL) take precedence where both exist. Unknown top-level keys (typos, or keys gated behind a cargo feature that is not compiled in) produce a warning at startup — they are otherwise ignored.

Example (YAML):

provider: openrouter
model: deepseek/deepseek-v4-flash
max_tokens: 16384
temperature: 0.7
context_window: 128000
reserve_tokens: 8192
keep_recent_tokens: 10000
compact_enabled: true
mid_turn_compact_threshold: 0.80
deny_repeated_reads: false
default_prompt: code
default_permission_mode: standard
permission-modes: ["guarded", "standard", "yolo"]
show_tool_details: 3
sandbox: false

quick_models:
  fast:
    provider: openai
    model: gpt-4o-mini
custom_providers:
  local-vllm:
    provider_type: openai
    base_url: http://localhost:8000/v1
    api_key_env: VLLM_API_KEY
    model: gemma4
  company-gateway:
    provider_type: openai
    base_url: https://gateway.example.com/v1
    api_key_env: GATEWAY_API_KEY
    api_style: completions
    headers:
      cf-access-client-id: "${CF_ACCESS_CLIENT_ID}"
      cf-access-client-secret: "${CF_ACCESS_CLIENT_SECRET}"
    danger_accept_invalid_certs: false
    timeout_secs: 60
permission:
  "*": ask
  read: allow
  write:
    "**/*.rs": allow
    "**": ask
  bash:
    "cargo test": allow
    "rm **": deny
  external_directory:
    "/tmp/**": allow
    "/**": ask
  doom_loop: ask

The same config in TOML:

provider = "openrouter"
model = "deepseek-v4-flash"
max_tokens = 16384
temperature = 0.7
context_window = 128000
reserve_tokens = 8192
keep_recent_tokens = 10000
compact_enabled = true
mid_turn_compact_threshold = 0.80
edit_system = "similarity"
default_prompt = "code"
default_permission_mode = "standard"
permission-modes = ["guarded", "standard", "yolo"]
show_tool_details = 3

[quick_models.fast]
provider = "openai"
model = "gpt-4o-mini"

[custom_providers.local-vllm]
provider_type = "openai"
base_url = "http://localhost:8000/v1"
api_key_env = "VLLM_API_KEY"

[permission]
"*" = "ask"
read = "allow"

[permission.write]
"**/*.rs" = "allow"
"**" = "ask"

[permission.bash]
"cargo test" = "allow"
"rm **" = "deny"

[permission.external_directory]
"/tmp/**" = "allow"
"/**" = "ask"

permission.doom_loop = "ask"

Accepted top-level keys:

Key Type Description
provider string Provider name. Built-ins are openrouter, openai, anthropic, gemini/google, and ollama; custom provider aliases are also accepted. Default: openrouter.
model string Model name. Default: deepseek/deepseek-v4-flash.
max_tokens integer Maximum response tokens. Default: 16384.
max_agent_turns integer Maximum agent turns per response. Default: 200.
temperature number Model temperature value. Only configurable via the --temperature CLI flag (0.0 to 2.0). Config-file value is parsed but not currently applied.
extra_body object Provider-specific JSON shallow-merged into every completion request body as a global default (e.g. OpenRouter plugins routing presets). A matching quick_models entry’s extra_body overrides this. See Provider-specific request body parameters below.
no_tools boolean Disable all tools. Default: false.
no_context_files boolean Disable loading global/project AGENTS.md, CLAUDE.md, and ARCHITECTURE.md (if archmd feature enabled) context files. Default: false.
context_window integer Session context-window size used for status and auto-compaction. When unset, auto-detected from the selected model’s catalog entry or, for custom providers, live from GET {base_url}/models when it reports context_length; falls back to 128000 otherwise. A value of 0 disables auto-compaction.
reserve_tokens integer Tokens to reserve before compaction is triggered. When unset globally, falls back to the active quick model’s reserve_tokens field, then to the hardcoded default of 8192.
keep_recent_tokens integer Approximate recent-token budget kept verbatim during compaction. Default: 10000.
max_text_file_size integer Maximum allowed file size in bytes for read/write tool operations. Default: 1048576 (1 MB).
deny_repeated_reads boolean Block repeated reads of the same file section within a session until the file is edited or written. Default: true. Set to false to allow re-reading.
show_cost_always boolean Show the session cost in the status bar even when it is $0.0000 (for example when the model has no per-token pricing configured). Default: false, which hides the cost until it is above zero.
compact_enabled boolean Master switch for all automatic conversation compaction (both between-turn and mid-turn). Default: false. When false, nothing is ever compacted automatically.
mid_turn_compact_threshold number Opt-in mid-turn compaction. Fraction of the context window (0.01.0) of real provider prompt pressure at which to compact during a turn, not just between turns. Unset by default, meaning no mid-turn compaction. Honored only when compact_enabled is true. Recommended starting value: 0.80. See Mid-turn compaction below.
always_show_welcome boolean Always show the welcome banner on startup, bypassing the one-shot marker file. Default: false.
auto-update-prompts boolean When true, update prompt files that changed in the new version without asking. When false, never update. When unset, asks interactively. Nothing happens when the installed prompts already match the embedded defaults.
auto-update-themes boolean When true, update theme files that changed in the new version without asking. When false, never update. When unset, asks interactively. Nothing happens when the installed themes already match the embedded defaults.
edit_system string Edit system mode: "similarity" (SEARCH/REPLACE with fuzzy matching, default) or "hashedit" (CRC-32 tag-based CAS edits). See Edit System Modes below.
custom_providers object Map of provider aliases to { "provider_type", "base_url", "api_key_env", "api_style", "headers", "danger_accept_invalid_certs", "timeout_secs" }. provider_type must resolve to a built-in provider type; api_key_env is optional. For OpenAI providers, api_style selects "responses" or "completions", headers sets custom HTTP headers (values support ${ENV_VAR} expansion), and timeout_secs overrides the HTTP timeout. danger_accept_invalid_certs disables TLS verification. See the OpenAI API styles section below.
permission object Permission rules using glob patterns; see the permission config notes below.
permission-regex object Same structure as permission but patterns are interpreted as regex instead of glob.
permission-allow object Map of tool names to lists of glob patterns to allow. Works alongside the permission field. See below.
permission-ask object Map of tool names to lists of glob patterns to prompt on. Works alongside the permission field. See below.
permission-deny object Map of tool names to lists of glob patterns to deny. Works alongside the permission field. See below.
restrictive boolean Select restrictive permission mode (ask for every operation). Overridden by accept_all/yolo if those are also true.
accept_all boolean Select standard permission mode with auto-allow within CWD (equivalent to default_permission_mode = "standard"). Overridden by yolo if true.
yolo boolean Select yolo mode (allow all, ask for destructive bash commands).
permission-modes array List of mode names that apply config-based rules. Default: ["guarded", "standard", "yolo"]. Modes excluded from this list skip config rule matching entirely.
sandbox boolean Run bash commands in the sandbox. Best effort: if the selected backend binary is missing, commands still run unsandboxed and a warning is logged. See SECURITY.md in the repository for what the sandbox does and does not protect against. Default: false.
sandbox-backend string Sandbox backend: bwrap (default, Linux only) or zerobox (macOS and Linux).
sandbox-required boolean Refuse to run bash commands when the sandbox backend is unavailable, instead of falling back to running them unsandboxed. Implies sandbox. Default: false.
sandbox-expose array Bwrap backend only. Restores read-only access to a masked credential directory or a subpath of one. ~ and $HOME expansion supported. Repeatable CLI flag --sandbox-expose <path>; when passed, it replaces this list wholesale. Values that are not a masked entry or a subpath of one, and values containing .., are warned about once at startup and ignored. Default: [] (nothing exposed). See Sandbox credential masking below.
sandbox-network boolean Bwrap backend only. When false, each sandboxed bash command runs in a fresh network namespace with only its own private loopback: a server it starts and uses within that one command still works on 127.0.0.1, while the internet, the LAN, and anything listening on the host’s loopback are unreachable. CLI flag --sandbox-network[=true\|false], which wins over this key in both directions. Does not imply sandbox: with the sandbox off, the setting has no effect and a warning is logged once at startup. Default: true (network open). See Sandbox network isolation below.
default_permission_mode string Permission mode when no mode boolean/CLI flag is set. Accepts: standard (default), restrictive, readonly, guarded, yolo.
show_tool_details boolean or integer Show tool-result previews in the TUI. false hides output, true shows all lines, an integer limits to that many lines (e.g. 3). Default: 3.
show_reasoning boolean Show streamed reasoning text in the TUI. Can still be toggled at runtime with Ctrl+R or /reasoning. Default: false.
statusline table Configurable status bar (up to 3 lines of colored segments). When absent, a built-in default layout is used. See Status bar below.
chat_left_margin integer Left padding (columns) for the chat area only; input and status rows are unaffected. Default: 0.
mouse_capture boolean When true (default), the TUI captures mouse events so zerostack can handle scrolling, click-to-place-cursor, and drag-to-select/copy. When false, mouse selection/copy/paste is handled by your terminal emulator instead, but in-app mouse features — including mouse-wheel scrolling — are disabled. Use PgUp/PgDn, Ctrl+Up/Ctrl+Down, or Home/End to scroll when mouse capture is off.
default_prompt string Prompt name to activate on startup. Default: code. If the prompt file has a %%mode=<mode> first-line directive, the security mode is set automatically (see Prompt directives below).
editor string Editor command for Ctrl+G (default: $EDITOR env var, then editor, then nano).
api_keys object Map of provider names to API keys (e.g. "openai": "sk-..."). Used as fallback when the corresponding env var is not set.
quick_models object Map of quick-model names to { "provider", "model", "reserve_tokens"?, "input_token_cost"?, "output_token_cost"?, "temperature"?, "extra_body"? }. Can be switched with /models <name> or --quick-model=<name>. See Provider-specific request body parameters below for extra_body.
prompt_to_model object Map of prompt names to quick-model names (e.g. plan = "glm-52"). When switching to a prompt, zerostack automatically switches to the corresponding quick model. Empty-string values are treated as “no change”. See Prompt-to-model switching below.
mcp_servers object MCP server map when compiled with the mcp feature. When omitted, recommended MCPs are auto-configured (see below).
enable-exa-mcp boolean Auto-configure the Exa Web Search MCP server. Default: true.
enable-context7-mcp boolean Auto-configure the Context7 MCP server. Default: false.
enable-grepapp-mcp boolean Auto-configure the Grep.app MCP server. Default: false.
allow_all_mcp_calls boolean When true, permission checks are skipped for all MCP tool calls. Default: false.
acp_servers object ACP server config map when compiled with the acp feature. See the ACP section below.
acp_host string TCP bind host for ACP server mode (equivalent to --acp-host).
acp_port integer TCP bind port for ACP server mode (equivalent to --acp-port, default: 7243).
colors object Background color overrides for the TUI. See the colors section below.

Sandbox credential masking

On the bwrap backend, nine well-known credential directories are masked with a tmpfs by default, so sandboxed bash commands see each one as empty rather than reading your keys and tokens (only entries that exist on the host are masked):

Directory Why it is masked
~/.ssh SSH private keys and known_hosts
~/.aws AWS credentials and config
~/.gnupg GPG keyrings and the gpg-agent SSH socket
~/.kube Kubernetes cluster credentials
~/.docker Docker registry auth tokens
~/.config/gh GitHub CLI OAuth token
~/.config/gcloud Google Cloud credentials
~/.config/op 1Password CLI session state
~/.config/sops/age sops/age decryption keys

The last four are relative to the XDG config base: when $XDG_CONFIG_HOME is set to an absolute path, they are $XDG_CONFIG_HOME/gh, .../gcloud, .../op and .../sops/age instead, since that is where those tools read their credentials.

The tmpfs is a normal writable filesystem, not a read-only view: a command that writes into a masked directory (ssh-keygen -f ~/.ssh/id_x, gh auth login, aws configure) succeeds and exits 0, then loses whatever it wrote when the sandbox exits. A read-only mask is possible (bwrap supports --remount-ro), but it is not the default, since it would make those same tools fail hard instead of silently losing their output.

Use sandbox-expose (config key, string array, ~ and $HOME expansion) or the repeatable --sandbox-expose <path> CLI flag to restore read-only access to a masked entry or a subpath of one (for example ~/.ssh/known_hosts without the private keys). When --sandbox-expose is passed at least once on the command line, it replaces the config list wholesale, following the usual CLI-over-config convention; otherwise the config list is used. A value is valid only when, after expansion, it equals one of the entries above or is a subpath of one, and contains no .. component (~/.ssh/.. names the whole home directory, not a masked entry); anything else is warned about once at startup, quoting the value, and ignored. Because the restore is a read-only bind on top of the mask, sandbox-expose can never grant write access. Like any other config key, sandbox-expose can be set from a project-local .zerostack/config.toml, trusted exactly like the global config, so review it in untrusted checkouts as noted above (see Project-local override). Masking, the ssh-agent cutoff, and sandbox-expose all apply to the bwrap backend only; see SECURITY.md for the full gap list, including what remains readable and the ssh-agent recovery paths.

Sandbox network isolation

On the bwrap backend, sandboxed bash commands share the host network by default, which is the historical behavior. Set sandbox-network = false (or pass --sandbox-network=false) to run them in their own network namespace instead:

sandbox = true
sandbox-network = false

What “no network” means here

Each bash call gets a fresh network namespace containing nothing but its own private loopback device, which bubblewrap brings up for it. Two consequences, and they pull in opposite directions:

The namespace lasts exactly as long as the command. A server backgrounded by one bash call is unreachable from the next one, which also takes away the “start it in one step, curl it in the next” pattern.

Those last two points are why the default is true: turning the network off breaks host dev servers, private registries, and any workflow split across several bash calls, and that is too much to change under people silently.

The CLI flag takes an optional value (bare --sandbox-network for true, or --sandbox-network=false) rather than being a plain on/off switch, because the key defaults to true and a plain switch could not express turning the network back on for a single run over a config that turns it off.

sandbox-network is a modifier, not an enforcer. Unlike sandbox-required, it never switches the sandbox on: with sandbox = false there is nothing to cut the network from, so the setting has no effect and a warning is logged once at startup. Pairing it with sandbox-required is what turns the no-network promise into a guarantee, since a missing backend otherwise falls back to running commands bare, with the network intact.

Like sandbox-expose, this key can be set from a project-local .zerostack/config.toml, which is trusted exactly like the global config. A checkout can therefore set sandbox-network = true and undo a global false, so review that file in untrusted checkouts (see Project-local override). Passing --sandbox-network=false on the command line settles the question for that run, since the CLI wins over both config files.

When the network is off and a command fails with a resolver or unreachable network error, the tool result gets one extra line telling the model the sandbox cut the network and to ask you whether it should be enabled, so it stops retrying the command instead. The hint reads stderr, so a command that folds stderr into stdout with 2>&1 simply does not get one. On systemd-resolved hosts, /etc/resolv.conf points at 127.0.0.53, which inside the fresh namespace is the sandbox’s own loopback with nothing listening, so DNS failures there surface as connection refused rather than network unreachable, which is one reason “connection refused” is in the hint pattern list.

Like masking and sandbox-expose, this applies to the bwrap backend only. The zerobox backend denies network access under its own policy regardless of this key; see SECURITY.md.

System Prompt Suffix (SUFFIX.md)

You can append custom text to every system prompt by creating a SUFFIX.md file in the config directory (same location as config.toml):

If the file exists and contains non-whitespace content, its contents are appended at the very end of the system prompt preamble — after AGENTS.md, ARCHITECTURE.md, the active prompt, working directory, /added files, memory, and everything else. A --- separator is inserted automatically.

The suffix applies to all agent contexts:

If the file is missing, empty, or whitespace-only, nothing is appended.

Use cases: inject persistent rules, style preferences, team-wide policies, or provider-specific output formatting that should always be present regardless of which prompt or mode is active.

Hooks

Requires the hooks Cargo feature, which is default-off — a prebuilt binary or package must have been compiled with --features hooks (or --all-features) for any of this to apply. When the feature isn’t compiled in, none of the flags, files, or /hooks/--hooks-test commands below exist.

Hooks let external commands observe or gate agent behavior at defined points (a tool call, a user prompt, the agent finishing a turn, a session starting/ending, a subagent starting/stopping), using the same settings.json shape, stdin envelope, and exit-code/stdout-JSON contract as Claude Code, so an existing CC hooks setup is largely compatible (see the $CLAUDE_PROJECT_DIR caveat below for the one script-level change some setups need).

Config file locations and precedence

Hook config lives in a settings.json (JSON, not config.toml/.yaml) at up to three locations, loaded and merged in this order:

Location Trust
~/.config/zerostack/settings.json (global; on macOS ~/Library/Application Support/zerostack/settings.json; on Windows %APPDATA%\zerostack\settings.json, experimental) Trusted by default
.zerostack/settings.json (project, relative to CWD) Not trusted by default — see Trust model below
/etc/zerostack/managed-settings.json (Linux) / /Library/Application Support/zerostack/managed-settings.json (macOS) / C:\ProgramData\zerostack\managed-settings.json (Windows, experimental) — admin-controlled Always trusted; unaffected by disableAllHooks

Each file may have a top-level hooks object (keyed by event name) and a top-level disableAllHooks: true boolean. disableAllHooks (from the global or project file) or the --no-hooks CLI flag suppresses every non-managed hook; managed hooks still run regardless. A missing or invalid file is not an error — it just contributes nothing.

Compatible with Claude Code’s .claude/settings.json: zerostack does not read that file directly, but its own settings.json uses the identical hooks schema, so copying or symlinking the hooks key from .claude/settings.json into .zerostack/settings.json works as-is. Scripts themselves may still need a change: zerostack sets $ZEROSTACK_PROJECT_DIR rather than $CLAUDE_PROJECT_DIR, so a script that reads the latter must be updated to read the former.

Handler schema

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write",
        "hooks": [
          { "type": "command", "command": "./guard.sh", "timeout": 30 }
        ]
      }
    ]
  }
}
Field Type Description
type string Only "command" is supported.
command string Shell command, run via sh -c on Unix (powershell -Command on Windows, experimental). Receives the stdin envelope as JSON; $ZEROSTACK_PROJECT_DIR is set in its environment.
args array of strings When present, bypasses the shell entirely: command is executed directly as the program with args as its argv (no shell metacharacter expansion).
timeout integer (seconds) Per-hook timeout; the whole process group is killed on expiry. Default: 60.
async boolean When true, the hook runs in the background and its decision is ignored. Default: false.
if string A shell command evaluated (with the same stdin envelope) before the handler runs; the handler only runs if it exits 0. Fails closed: a broken/unparseable/timed-out condition still runs the handler, with a warning.
once boolean Runs the handler at most once per event per session; later matches are skipped.

matcher (on the handler group, not the handler) follows Claude Code semantics: omitted, "", or "*" matches every tool; a bare name or a |/,-separated list is an exact case-insensitive match after tool-name normalization (e.g. Bashbash, Globfind_files, Edit|Write matches zerostack’s write tool); anything else is treated as a regex. Invalid regexes are reported at load time.

Events

PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SessionStart, SessionEnd, SubagentStart, SubagentStop. PreCompact and Notification are not currently implemented.

Only PreToolUse is permission-blockable by default. A handler’s stdout JSON may set "permissionDecision" to "deny", "ask", "allow", or omit it (defer to the normal permission system). deny always blocks, holding even under --yolo. ask forces an interactive confirmation regardless of permission mode, and escalates to deny in non-interactive contexts (-p, --loop) where no confirmation is possible. allow suppresses the interactive prompt for that one call only — it can never override a deny from a rule, security mode, managed policy, or another hook. PreToolUse may also set "updatedInput" to rewrite the tool’s arguments before it runs, and PostToolUse may set "result" to rewrite the model-visible output.

UserPromptSubmit and SubagentStart can set "additionalContext" to prepend text to the prompt. Stop and SubagentStop can set "decision": "block" with a "reason" to force the agent (or subagent) to continue instead of finishing, using reason as the next instruction; Stop gives up after 8 consecutive blocks without progress.

Any handler can also signal via exit code instead of JSON: exit 0 means no objection, exit 2 blocks (for blockable events) with stderr as the reason, and any other exit code is a non-blocking error. Exit 2 combined with stdout JSON is a mixed-channel warning — the JSON is ignored.

Trust model

Project-level hook handlers (.zerostack/settings.json — global and managed hooks are trusted automatically) require interactive confirmation the first time they’d run, keyed by a hash of the handler’s definition (event + matcher + command/args/timeout/etc.); changing the definition changes the hash and requires re-confirmation. Confirmations persist to $XDG_DATA_HOME/zerostack/trusted-hooks.json (a user-level file, so child processes/orchestrated subagents sharing it inherit trust automatically). In headless contexts (-p, --loop) an unconfirmed project hook is skipped with a warning rather than prompting.

Global switches

Flag Effect
--no-hooks Disables all non-managed hooks for this run.
disableAllHooks: true (in global or project settings.json) Same effect, via config.
--hooks-test <tool> [--hooks-test-input <json>] Dry-runs PreToolUse for tool against the loaded/trust-filtered dispatcher and prints the merged verdict/reason/updatedInput, then exits — no session, agent, or API key required.

See COMMANDS.md for the /hooks slash command.

Mid-turn compaction

By default zerostack only compacts the conversation between turns, after a response finishes, when the accumulated session history exceeds context_window - reserve_tokens. A single long turn (many tool calls and large tool results) can still blow past the model’s real context limit before that check ever runs, because the in-flight tool traffic never enters the session’s token estimate.

mid_turn_compact_threshold opts in to a second, within-turn check. On every provider call zerostack compares the real provider-reported prompt size against context_window; when the ratio crosses the threshold it stops the run at a clean boundary, compacts, and resumes the same task on the compacted history.

compact_enabled = true            # master switch (default true)
context_window = 24576
mid_turn_compact_threshold = 0.80 # compact mid-turn at 80% real prompt pressure

OpenAI API styles and custom headers

The openai provider (and any custom provider with "provider_type": "openai") can talk to either of rig’s two OpenAI transports:

Set api_style to override the auto-detected default — for example, to force completions against a gateway, or responses against an endpoint that actually implements /responses.

Custom providers may also send arbitrary HTTP headers, which is useful for gateways behind an auth proxy such as Cloudflare Access. Header values support ${ENV_VAR} expansion, so secrets stay in the environment rather than in the config file:

{
  "custom_providers": {
    "company-gateway": {
      "provider_type": "openai",
      "base_url": "https://gateway.example.com/v1",
      "api_key_env": "GATEWAY_API_KEY",
      "headers": {
        "cf-access-client-id": "${CF_ACCESS_CLIENT_ID}",
        "cf-access-client-secret": "${CF_ACCESS_CLIENT_SECRET}"
      }
    }
  }
}

The optional timeout_secs field overrides the default HTTP timeout for the provider. TLS certificate verification can be disabled with "danger_accept_invalid_certs": true (for self-signed or internal-CA gateways) — use with care, as it makes the connection vulnerable to MITM.

Provider-specific request body parameters

headers only touches HTTP headers. Some providers also accept parameters in the JSON request body — for example OpenRouter’s plugins presets that select a routing strategy:

{
  "model": "openrouter/fusion",
  "plugins": { "preset": "general-budget" }
}

extra_body injects arbitrary JSON into the completion request body. It is shallow-merged (top-level keys win on collision) and works for every provider — OpenAI, Anthropic, Gemini, Ollama, OpenRouter, and any custom provider — not just OpenRouter. The same value is applied to the main agent and the isolated /btw agent so they behave identically.

It can be set at two levels, resolved most-specific first:

  1. Per quick_models entry — applies only when that model is active.
  2. Global top-level extra_body — applies to every model, including the base model, unless a matching quick_models entry overrides it.
# Global default — applies to the base model and any model without its own value.
model = "openrouter/fusion"
provider = "openrouter"
extra_body = { plugins = { preset = "general-budget" } }

# A quick-model entry overrides the global value for that model.
[quick_models.quality]
provider = "openrouter"
model = "openrouter/fusion"
extra_body = { plugins = { preset = "quality" } }

In YAML:

extra_body:
  plugins:
    preset: general-budget
quick_models:
  quality:
    provider: openrouter
    model: openrouter/fusion
    extra_body:
      plugins:
        preset: quality

Note that body parameters are provider-specific: a key one provider understands may be ignored or rejected by another. Unlike temperature, a global extra_body does not follow model switches, so prefer setting it per quick_models entry — bundled with the matching provider/model — when the parameter is tied to a specific provider.

Status bar

The status bar at the bottom is configurable through [statusline]: up to 3 lines, each an ordered list of segments. When [statusline] is absent, a built-in single-line layout is used.

# Line 1
[[statusline.lines]]
segments = [
  { item = "cwd", color = "blue" },
  { item = "separator", text = " " },
  { item = "git_branch", color = "magenta" },
  { item = "git_changes", color = "yellow" },
  { item = "flex_separator" },          # fills the row, pushing the rest right
  { item = "context_used", color = "green" },
  { item = "separator", text = "/" },
  { item = "context_max", color = "green" },
  { item = "separator", text = " " },
  { item = "context_percentage", color = "green" },
]

# Line 2 (optional)
[[statusline.lines]]
segments = [
  { item = "session_name" },
  { item = "separator", text = "  " },
  { item = "session_id", color = "dark_grey" },
  { item = "flex_separator" },
  { item = "tokens_input", color = "cyan" },
  { item = "separator", text = " " },
  { item = "tokens_output", color = "cyan" },
  { item = "separator", text = " " },
  { item = "cost", color = "green" },
]

# Line 3 (optional)
[[statusline.lines]]
segments = [{ item = "prompt", color = "white", bg = "#202020" }]

Each segment has:

Field Description
item The element to show (required). See the list below.
color Foreground color: a name (red, dark_cyan, light_blue, …) or #rrggbb. Optional.
bg Background color, same format. Optional.
text Literal text for the separator item. Optional (defaults to a space).
left Powerline cap glyph drawn before the item. A name (see below) or any literal string. Optional.
right Powerline cap glyph drawn after the item. Optional.
icon Glyph shown before the value. true uses the item’s built-in icon; a string sets a custom one (a named icon or a literal glyph). Optional. Needs a Nerd Font.
always Force a numeric item (tokens_input, tokens_output, cost) to show even when its value is 0 (normally hidden until non-zero). Optional.

Items with a built-in icon (used by icon = true): git_branch, git_changes, git_status, cwd, model, cost, context_used/context_max/ context_percentage, session_name/session_id, prompt, mode, loop, btw, compaction. Named custom icons for icon = "<name>": branch, folder, chip, dollar, database, hash, terminal, lock, pencil, sync. Any other value is used literally, so a raw codepoint works too.

[[statusline.lines]]
segments = [
  { item = "git_branch", color = "magenta", icon = true },
  { item = "cwd", color = "light_blue", icon = "folder" },
]

left/right caps are drawn in the segment’s bg color (falling back to its color) over the status-bar background, so they read as the segment’s edge. They render only when the item is shown, and need a Nerd Font / Powerline font. Named caps: pl_right (), pl_left (), pl_right_thin (), pl_left_thin (), pl_round_right (), pl_round_left (), pl_flame_right, pl_flame_left. Any other value is used as-is, so a raw codepoint like "" also works. Example:

[[statusline.lines]]
segments = [
  { item = "model", color = "white", bg = "#3b4252", left = "pl_round_left", right = "pl_round_right" },
]

Available items:

Item Shows
session_name The session name (hidden when empty).
session_id The first 8 characters of the session id.
cwd The working directory name (folder only).
cwd_full The full working directory path, with $HOME shortened to ~.
worktree Linked git worktree name (hidden when not in a linked worktree).
git_branch Current git branch (or short commit on detached HEAD).
git_changes Working-tree changes: +staged ~modified -deleted ?untracked (non-zero parts only; hidden when clean).
git_status Upstream sync and dirty marker: ↑ahead ↓behind *, or when clean and in sync.
model The active model id.
model_short The model id without its provider prefix (e.g. deepseek-v4-pro).
provider The active provider name.
tokens_input Total input tokens this session.
tokens_output Total output tokens this session.
context_used Current context size in tokens.
context_max The model’s context window.
context_percentage Context used as a percentage of the max.
cost Session cost (hidden at $0.0000 unless show_cost_always is set).
prompt Active prompt (prompt:<name>).
mode Security mode when not standard (mode:<name>).
loop Active loop label.
chain Chain-of-prompts label.
compaction Number of compactions (cmp:<n>).
btw /btw side-question token/cost usage.
reasoning Shows reasoning when reasoning is enabled (hidden when off).
message_count Number of messages in the session.
session_age Time since the session was created (e.g. 5m, 2h10m).
session_updated Time since the last message (same format).
clock Current local time (HH:MM).
host Machine hostname.
user Current username.
separator Literal text from text (default a space). Trimmed around hidden items.
flex_separator Expands to fill the remaining width; several split the space evenly.

The git_changes and git_status items run git status once a second (only when one of them is used). All other items are read from the session.

Status signals

Requires the status-signals feature (included in the default build). Pass --status-socket <path> to have zerostack emit start, stop, and git-conflict events over a Unix domain socket at <path>, for external status bars or tooling to watch. This is separate from the in-TUI status bar above.

Colors

The colors object accepts an optional scheme_type field and three optional string fields for background colors, each of which can be a named color or hex color (e.g. "#1e1e2e"). Named colors are case-insensitive.

scheme_type

Controls the terminal color escape sequences emitted. Two values:

Background fields

Supported named colors: reset, black, red, green, yellow, blue, magenta, cyan, white, grey, dark_grey, dark_red, dark_green, dark_yellow, dark_blue, dark_magenta, dark_cyan.

Example:

{
  "colors": {
    "scheme_type": "full",
    "chat_background": "#1e1e2e",
    "input_background": "#181825",
    "status_background": "#11111b",
    "roles": {
      "agent": "#cdd6f4",
      "error": "#f38ba8",
      "tool": "#f9e2af",
      "permission": "#cba6f7"
    }
  }
}

Permission actions are lowercase strings: allow, ask, or deny. Each tool rule can be a single action or an object mapping patterns to actions. Supported permission tool keys are bash, read, write, edit, grep, find_files, list_dir, and todo_write. MCP-backed tools are checked under mcp_tool:{server_name}:{tool_name}. Use "*" for the default action, external_directory for absolute-path rules outside the working directory, and doom_loop for repeated identical tool calls (default: ask). If bash is omitted, zerostack installs its built-in safe bash allow/deny rules.

There are two config fields for controlling permissions by pattern:

Both fields can be used together; rules from both are merged. If both define a default action ("*"), the glob default takes precedence.

As a TOML-friendly alternative to the nested permission object, you can use permission-allow, permission-ask, and permission-deny at the top level. Each is a map from tool name to a list of glob patterns. These work side by side with the permission field and are especially convenient in TOML configs:

permission-allow = { read = ["src/**", "tests/**"] }
permission-ask = { bash = ["rm **"] }
permission-deny = { write = ["/etc/**", "/usr/**"] }

In YAML:

permission-allow:
  read: ["src/**", "tests/**"]
permission-ask:
  bash: ["rm **"]
permission-deny:
  write: ["/etc/**", "/usr/**"]

A permission-regex example in YAML:

permission-regex:
  "*": ask
  read:
    "\\.md$": allow
    "\\.rs$": ask
  bash:
    "^cargo (test|check|build)$": allow
    "^rm ": deny

When compiled with MCP support, mcp_servers accepts command-based and URL-based servers. Servers can also be added per project via .zerostack/config.toml (see Project-local override above); project servers merge with — and can override — global ones by name.

{
  "mcp_servers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
      "env": {}
    },
    "remote-search": {
      "url": "https://example.com/mcp",
      "headers": {
        "authorization": "Bearer token"
      }
    }
  }
}

Timeouts, retries, and auto-reconnect

Both server kinds accept optional resilience fields:

Field Default Description
connect_timeout_secs 10 Timeout for establishing the connection and MCP handshake.
tool_timeout_secs 20 Timeout for individual tool calls (and tool listing).
connect_retries 1 How many times a failed connection attempt is retried (2 attempts total).
{
  "mcp_servers": {
    "slow-server": {
      "url": "https://example.com/mcp",
      "connect_timeout_secs": 30,
      "tool_timeout_secs": 60,
      "connect_retries": 2
    }
  }
}

All servers connect concurrently at startup, so one slow server does not delay the others. If a server’s transport drops mid-session (child process exits, HTTP session is lost), the next tool call to that server automatically reconnects once and retries the call before reporting an error.

OAuth for URL servers

URL-based servers can authenticate with OAuth 2.0 (authorization code + PKCE). Add an oauth field. Use true for defaults (dynamic client registration, no extra scopes), or an object for explicit settings:

{
  "mcp_servers": {
    "my-oauth-server": {
      "url": "https://example.com/mcp",
      "oauth": true
    },
    "scoped-server": {
      "url": "https://api.example.com/mcp",
      "oauth": {
        "scopes": ["read", "write"],
        "client_id": "pre-registered-client-id",
        "redirect_port": 8970
      }
    }
  }
}

OAuth fields (all optional):

Field Default Description
scopes none Scopes to request during authorization.
client_id dynamic registration Pre-registered client id. When omitted, the client registers on the fly.
redirect_port 8970 Loopback port for the redirect URI http://127.0.0.1:<port>/callback.

The first time you connect, run /mcp login <server> inside the TUI. zerostack prints an authorization URL and copies it to your clipboard; open it in a browser, approve access, and the redirect is caught on the loopback port. The browser wait runs in the background, so the TUI stays responsive (you can keep working or select the URL with the mouse to copy it). The token is saved to <data_dir>/mcp-oauth/<server>.json (mode 0600 on unix), and the server reconnects automatically once authorization completes. Later sessions reuse the stored refresh token and reconnect without a browser. Use /mcp logout <server> to remove a stored token. A server with OAuth enabled but no stored token fails to connect until you log in.

When mcp_servers is not explicitly set, three recommended MCP servers are available. Each can be toggled with a boolean config key (all default to the listed API key environment variable when that variable is set):

Key Default Description Env var
enable-exa-mcp true Exa web search (mcp.exa.ai) EXA_API_KEY
enable-context7-mcp false Context7 documentation lookup (mcp.context7.com) CONTEXT7_API_KEY
enable-grepapp-mcp false Grep.app semantic code search (mcp.grep.app) GREP_APP_API_KEY

Set enable-exa-mcp = false to disable the Exa default without touching mcp_servers. Set "mcp_servers": {} to disable all MCP auto-configuration.

ACP (Agent Communication Protocol) configuration

When compiled with the acp feature, zerostack can act as an ACP agent server. The following config keys are available:

Key Type Description
acp_servers object Named ACP server configurations (see below)
acp_host string TCP bind host for ACP server (default: stdio mode)
acp_port integer TCP bind port for ACP server (default: 7243)

ACP server configs (in acp_servers) support two transport types:

{
  "acp_servers": {
    "tcp-server": {
      "host": "127.0.0.1",
      "port": 7243,
      "api_key": "optional-key"
    }
  }
}

When --acp is passed without --acp-host, zerostack runs in stdio mode (the editor spawns it as a subprocess). With --acp-host, it listens on TCP.

TOML configuration

Within each search directory, zerostack picks the first existing file in this priority order: config.toml, config.yaml, config.yml, then config.json. config.json is kept for backwards compatibility — since YAML is a superset of JSON, legacy JSON configs parse transparently through the YAML reader. If none exists, a default config.toml is created automatically.

TOML is especially well suited for zerostack’s permission rules and structured settings. Hyphenated keys such as permission-regex, permission-allow, permission-ask, and permission-deny are idiomatic in TOML and avoid deeply nested tables:

permission-allow = { read = ["src/**", "tests/**"] }
permission-ask = { bash = ["rm **"] }
permission-deny = { write = ["/etc/**", "/usr/**"] }

For more complex configurations, explicit TOML tables provide clear structure:

[permission]
"*" = "ask"

[permission.bash]
"cargo test" = "allow"
"rm **" = "deny"

[permission.write]
"**/*.rs" = "allow"
"**" = "ask"

Key naming in TOML

All top-level keys use kebab-case when they contain hyphens (e.g. permission-allow, allow-all-mcp-calls). Simple keys use the same name as their YAML counterpart. Quoted keys ("*", "**") are required when the key contains special characters like * or /.

Edit System Modes

zerostack supports two edit systems, selectable via edit_system config key, --edit-system CLI flag, or /editsys slash command:

similarity (default)

The classic aider-style SEARCH/REPLACE format. The LLM copies exact text from read output into <<<<<<< SEARCH blocks and provides replacements in >>>>>>> REPLACE blocks. Falls back to whitespace normalization and fuzzy matching when the exact text doesn’t match.

edit_system = "similarity"

hashedit

Tag-based edits using CRC-32 line hashes and file-level CAS (check-and-set) tokens. The read tool annotates each line with an 8-char hex CRC-32 tag (e.g. " 10|f1e2d3c4 int count = 10;") and a file-level CRC header. The edit tool receives tagged lines from the read output and provides only the replacement text — no old-text reproduction needed.

Key advantages:

edit_system = "hashedit"

Switching between modes is immediate and does not require agent restart. The /editsys similarity and /editsys hashedit slash commands provide the same functionality at runtime.

Prompt directives

Custom prompt .md files may include a %%mode=<mode> directive on the first line to automatically switch the security mode when the prompt is activated (via /prompt <name> or as the default_prompt).

Valid modes: standard, restrictive, readonly, guarded, yolo.

Use %%mode=last_user_mode to keep (or restore) the mode the user last set explicitly via /mode or startup config — useful when a prompt wants to avoid overriding the user’s chosen mode.

The directive line is stripped from the prompt content before it reaches the agent.

Example ask.md:

%%mode=readonly

## Read-Only Mode

You are in read-only mode. Only read files and explore.

Example code.md that defers to the user’s mode:

%%mode=last_user_mode

## Coding Mode

Write well-tested code. Follow project conventions.

The mode change is applied when the prompt is activated and persists until changed again by /mode, another prompt directive, or a restart. The status bar shows | mode:<name> when the mode is not standard.

Prompt-to-model switching

The [prompt_to_model] table maps prompt names to quick-model names. When you switch to a prompt (via /prompt, .name, /review, chain transitions, or default_prompt at startup), zerostack looks up the mapping and automatically switches the active model to the corresponding quick model.

Values are quick-model names — the same names defined in [quick_models]. An empty string ("") means “no change”, so the current model stays active.

[prompt_to_model]
plan = "glm-52"
code = "deepseek-v4-pro"
review = "qwen37-plus"
brainstorm = ""

With this config:

When you run /prompt default (clearing the active prompt), the model reverts to the session’s default model (from model / provider config or --quick-model).

The model switch writes a line to chat: switched to model: glm-52 (from prompt 'plan')

Chain-of-Prompts

When enabled, after the agent finishes responding with a brainstorm, plan, or code prompt, the status bar shows Continue to <next>? [Yes/But/No]. The user’s next input is interpreted as a chain decision:

Typing anything that doesn’t match these patterns clears the chain and processes the input as a normal message.

Phases

Transition Default Description
brainstorm-to-plan true After brainstorming, prompt to move to planning
plan-to-code true After planning, prompt to start coding
code-to-review false After coding, prompt to run a review

TOML

[chain]
brainstorm-to-plan = true
plan-to-code = true
code-to-review = false

YAML

chain:
  brainstorm-to-plan: true
  plan-to-code: true
  code-to-review: false

Advisor

The advisor tool lets the agent consult a stronger reviewer model (or the user, in human-handoff mode) for strategic guidance before making important decisions. This follows the advisor strategy: a cheaper “executor” model drives the task and escalates to a more capable model only when needed.

TOML

[advisor]
enabled = true
model = "deepseek-v4-pro"
# max_uses = 3                    # max advisor calls per request (nil = unlimited)
# human_handoff = true            # struct default is true, but currently has no effect from config alone; see the note below
# advisor_kilobytes_limit = 256   # max KB of conversation context (split half head / half tail)

YAML

advisor:
  enabled: true
  model: deepseek-v4-pro
  max_uses: 3
  human_handoff: true
  advisor_kilobytes_limit: 256

CLI flags

Flag Description
--advisor Enable the advisor tool
--advisor-model <name> Advisor model name
--advisor-max-uses <n> Max advisor calls per request
--advisor-human-handoff[=<bool>] Route advisor calls to the user instead of a model. Bare flag or =true enables it; CLI default is false unless passed
--advisor-kilobytes-limit <n> Max KB of conversation context sent to advisor (default: 256)

Known quirk: the CLI flag always supplies a value (Some(false) when not passed), so resolve_advisor_human_handoff() never falls through to the config file’s human_handoff key in practice. Use --advisor-human-handoff or the /advisor handoff on runtime command to actually enable it; setting human_handoff in the config file alone currently has no effect.

Human handoff mode

When enabled, the agent’s advisor calls are redirected to the user instead of a second model. The agent pauses, shows its question, and the user types a response. This is useful for:

Runtime control

The /advisor slash command provides runtime control:

/advisor                    Show current advisor status
/advisor on|off             Enable or disable the advisor
/advisor handoff [on|off]   Toggle human handoff mode
/advisor model <name>       Change the advisor model
/advisor max-uses <n>       Set max advisor calls per request (0 = unlimited)
/advisor context-limit <n>  Set max kilobytes of conversation context

LSP

zerostack can run language servers for the files the agent edits and feed diagnostics (errors/warnings) back into edit/write tool results — the agent sees type errors immediately instead of discovering them on the next build. An lsp_diagnostics tool also lets the agent query one file or the whole project on demand.

This integration is behind the non-default lsp Cargo feature — build with --features lsp to enable it.

TOML

[lsp]
enabled = true

[lsp.servers.rust]          # override a built-in default
command = "rust-analyzer"
extensions = [".rs"]

[lsp.servers.myserver]      # fully custom server
command = "my-ls"
args = ["--stdio"]
extensions = [".my"]
# env = { RUST_LOG = "debug" }
# initialization = { ... }   # server-specific initializationOptions
# disabled = false           # true removes a same-named built-in

YAML

lsp:
  enabled: true
  servers:
    rust:
      command: rust-analyzer
      extensions: [".rs"]

Built-in server defaults (used only when the binary is on PATH): rust-analyzer, gopls, typescript-language-server, pyright-langserver, clangd, bash-language-server, lua-language-server.

Behavior notes:

rtk output filtering

zerostack can route bash commands through rtk, an external CLI proxy that filters and compresses command output before it reaches the model context (e.g. tests report failures only, git status returns a compact summary). rtk claims up to 90% reduction of bash output tokens.

This integration is behind the non-default rtk Cargo feature — build with --features rtk to enable it.

When enabled, every bash tool command is passed through rtk rewrite before execution. rtk decides which commands have a compact equivalent — unsupported commands run unchanged. Permission checks always run against the original command, so existing permission patterns keep working. The integration is fail-open: if the binary is missing, times out, or errors, the original command runs unfiltered.

TOML

[rtk]
enabled = true
# path = "rtk"    # rtk binary; default resolves `rtk` via PATH

YAML

rtk:
  enabled: true
  path: rtk

When active, a short note is appended to the system prompt telling the model that bash output is compacted and that rtk writes full raw output to a tee log on failure.

Logging

zerostack uses the tracing framework for structured logging. By default, only warnings and errors are printed to stderr at the warn level (with the rig crate silenced). Full debug and trace output is available via CLI flags.

Verbose mode (-v / --verbose)

zerostack -v

Enables full trace-level logging to a timestamped log file under ~/.local/share/zerostack/logs/ (or $ZS_DATA_DIR/logs/). The log file is named zerostack-YYYY-MM-DD_HH-MM-SS_<pid>.log. A new file is created per instance — previous runs are never overwritten.

With -v, stderr output stays at the default warn level so the TUI remains clean. The log file captures everything at trace level for all zerostack modules.

Custom log file (--log-file)

zerostack --log-file /tmp/debug.log

Writes full trace-level logs to the specified path instead of the default location. Implies -v for the file output. Can be combined with -v (no effect on the path, since --log-file takes precedence).

Custom stderr log level (--log-level)

zerostack --log-level debug

Sets the minimum level for stderr output. Accepted values: trace, debug, info, warn, error. This overrides the RUST_LOG environment variable.

Environment variable (RUST_LOG)

The standard RUST_LOG environment variable is still supported for backward compatibility:

RUST_LOG=zerostack=debug zerostack          # debug level for zerostack
RUST_LOG=debug,rig=off zerostack             # debug for everything except rig
RUST_LOG=zerostack::agent::tools=trace zerostack  # trace only tool execution

Priority (highest wins): --log-level > RUST_LOG env > default warn,rig=off.

Logged subsystems

With -v, the following subsystems produce debug/trace output: