CONFIG

Every YAML knob, its default, what it controls.

09 / 16·reference·v0.9.26

alpi's settings live in ~/.alpi/config.yaml (or ~/.alpi/profiles/<name>/config.yaml for non-default profiles). This page lists every knob, its default, and what it controls.

What ships in the YAML

On first install alpi only writes the sections you're likely to tweak — and where defaults are platform-dependent enough to deserve visibility:

model: ""                          # empty on a fresh scaffold; pick via `alpi setup → Model` (see docs/MODELS.md)
providers:
  ollama: []
mcp:
  servers: {}
gateway:
  telegram: {}
  matrix: {}
  imap:
    poll_interval: 60
    mark_as_read: true

Everything else (tool limits, TUI flags, fallback models, workspace) falls back to the defaults below at load time. Add a key to the YAML only when you want to override it.

How to change settings

Three options:

Reference

Core

KeyDefaultTypeTakes effect
model"" (empty; pick via alpi setup → Model — see docs/MODELS.md)stringnext session
workspace"" (cwd at launch)stringnext session
fallback_models[]list of stringsnext turn
providers.ollama[]list of {name, url} — one per Ollama servernext session
providers.openrouter.models[]list of OpenRouter model ids the user has pickednext session
public_bio""string — one-line public tag-line broadcast to every workgroup this profile joins (source of truth for Member.bio on the hub). Empty = don't publish; peers see name only. AGENT.md stays private.next workgroup.join
pausedfalsebool — profile-level pause flag. Surfaced in the desktop / mobile profile summary so paired apps can show + respect the state; the daemon itself does not gate turns on this flag. Persisted only when true.next host-plane read

Tools

KeyDefaultTypeTakes effect
tools.max_steps_per_turn40intnext turn
tools.deny[]list of tool namesnext turn
tools.web_extract.model"" (use main)stringnext turn
tools.read_image.model"" (use main)stringnext turn
tools.terminal.sandboxfalseboolnext turn
tools.terminal.allow_networkfalseboolnext turn
tools.terminal.approval.allowlist[]list of pattern descriptions and/or command globs (see below)next turn
tools.browser.visionfalseboolnext turn
tools.browser.allow_localfalsebool — let the browser tool navigate loopback only (127.0.0.1, ::1, and hostnames that resolve to loopback such as localhost). RFC1918 / CGNAT / Tailscale stay blocked even when this is on; the exemption is loopback-only, matching _guards._is_loopback. Off blocks every local target; on is for hitting a local dev server you trust.next turn
tools.budget.per_result_chars100_000int (-1 = unlimited)next turn
tools.tts.voice"en-US-AriaNeural"Edge TTS voice idnext turn
tools.tts.rate""string ("+10%", "-20%") — speednext turn
tools.tts.pitch""string ("+5Hz", "-10Hz") — pitchnext turn
tools.tts.auto_readfalsebool — apps auto-play each agent reply aloudnext turn
tools.stt.model"base"tiny \base \small \medium \large-v3next turn
tools.stt.language"" (auto)ISO code (en, es, ...)next turn
tools.<name>.max_result_chars (unset)int (-1 = unlimited)next turn

max_steps_per_turn is a cost guard. When left at the default, a free model (zero per-token pricing) or a local/ollama one raises the effective ceiling to 1000 (no runaway cost to bound). An explicit value you set is always respected — it also bounds loops, refusals, and the TODO guard, not just cost.

tools.budget.per_result_chars caps the size of any tool output the LLM sees in-context, with a … [N chars elided by tool budget] suffix when hit. Prevents a single read_file on a 5 MB log from blowing up a turn. Per-tool overrides via tools.<name>.max_result_chars — set -1 on read_file if you want the LLM to get the whole source deliberately, or lower a chatty tool's cap.

Precedence: tools.<name>.max_result_chars (if set) → tools.budget.per_result_chars → hardcoded 100_000.

Not implemented (tracked, not planned): per-turn aggregate cap and inline preview. Comparable agents carry both, but alpi only ships them if real turns start burning through several large tool results.

tools.deny is a per-profile denylist of tool names. Denied tools are absent from the schema the LLM sees (it can't reach for what it doesn't know about) AND refused by the executor as defence in depth — if a stale context or a peer's link.ask names a denied tool, the call returns tool denied for this profile: <name> instead of running. Unknown names are no-ops, so typos are harmless. Denying alpi_knowledge also drops the self-knowledge rule from the system prompt, so the model is never told to call a tool it cannot reach.

Canonical names are the strings used at registration time — write_file, edit_file, terminal, email, send_message, schedule, delegate, peer, index_workspace, search_workspace, alpi_knowledge, research, browser, workgroup, etc. See alpi/tools/__init__.py for the full registry. Note: the alpi-docs tool registers as alpi_knowledge, not knowledge — writing knowledge in deny is a no-op.

Useful for tightening a profile that is exposed to less-trusted input — e.g. a "librarian" profile that other peers reach via link.ask and that has no business writing files, running shell, or sending mail:

# ~/.alpi/profiles/archi/config.yaml
tools:
  deny:
    - write_file
    - edit_file
    - terminal
    - email
    - send_message
    - schedule
    - delegate

Today this is YAML-only. There is no alpi setup wizard for deny and no surface for it in the desktop/mobile apps — the surface is power-user enough that raw names beat any UI we'd build right now.

tools.terminal.sandbox enables OS-level isolation on shell commands (macOS sandbox-exec, Linux bubblewrap). Toggle via alpi setup → Sandbox, or directly in YAML. The TUI top bar shows the current state (sandbox on / off). Most useful on profiles that run unattended (gateway, schedule, sub-agents) — see SECURITY.md for the recommended pattern + platform requirements.

allow_network has no effect unless sandbox is on. When sandbox is on and allow_network=false, the flag blocks ALL agent-initiated network:

The TUI top bar shows offline instead of sandbox when network is locked, so unattended profiles can be audited at a glance.

tools.terminal.approval controls the command approval system — a layer on top of the sandbox that gives the user a chance to approve borderline destructive commands instead of blocking them outright. Each terminal call is classified by a small pattern list into three severities:

Allowlist entries come in two shapes, sharing the same list:

Pattern descriptions — the human label attached to one of the built-in caution regexes (recursive rm, sudo, git force-push, git hard reset, chmod 777 / a+w, sql drop / truncate, process kill -9). A pattern-desc entry allows every command of that severity-category. This is what the Always button writes.

Command globs — any other string is treated as an fnmatch pattern matched against the literal command (whitespace-trimmed). Use this for per-command exceptions when the category-level bypass is too broad. Globs only override caution classification; dangerous commands stay blocked. Globs also do not apply to compound commands (containing &&, ||, ;, |, newline, backticks, or $(…)) — otherwise "sudo apt *" would also approve sudo apt update && rm -rf build. Compound commands fall back to the prompt unless a category-desc bypass covers them.

tools:
  terminal:
    approval:
      allowlist:
        - recursive rm                       # category: every rm -rf passes
        - sudo apt *                         # glob: any sudo apt subcommand
        - git reset --hard origin/main       # glob: this exact command only
        - git push --force origin my-branch  # glob: only this branch's force-push

Session approvals live in memory (a module-level set) and die with the TUI process. Permanent approvals persist to config.yaml via the Always button (which writes a pattern-desc) or by hand-editing the list with globs. Dangerous commands never get an allowlist entry.

This layer composes with the sandbox (tools.terminal.sandbox): the sandbox is an OS-level boundary (network, filesystem writes outside workspace) that catches what the approval layer misses; approval is user-in-loop for the subset of commands that are legitimately destructive inside the allowed scope. Both can be on at once; the approval check runs first so the user sees the prompt before the sandbox has a chance to refuse.

tools.browser.vision lets the browser(screenshot, question=…) action auto-chain the screenshot into the vision model (tools.read_image.model or the active main model) and return the answer instead of the file path. When false (default), screenshot always returns the path and a hint pointing at read_image so the LLM can decide whether to pay for vision per call. Useful to turn on in an exploratory profile; keep off in watchdog/gateway profiles so the agent doesn't burn vision tokens silently.

Image resizing is automatic: any image whose longer edge exceeds 1568 px (Anthropic's recommended bound) is downscaled before base64-encoding to the model. Vision-model cost scales with resolution — a 4K screenshot costs ~9× more tokens than its 1568-px version for the same content. Aspect ratio is preserved, PNG-with-alpha stays PNG, everything else rounds-trips through JPEG q=85. SVG (vector) is skipped. Not a knob — it is a fixed constant (alpi.tools.read_image.MAX_EDGE).

The research sub-agent's depth tiers (quick = 8 steps, normal = 15, deep = 30) are product definition, not user config. The agent picks the depth name from intent (quick = single-answer lookups, normal = comparative research, deep = exhaustive surveys); the step ceilings live in alpi.tools.research.DEPTH_STEPS_DEFAULTS.

tools.tts.voice selects the Edge TTS voice used by the tts tool. Any Microsoft Neural voice id is valid (es-ES-AlvaroNeural, en-US-AriaNeural, fr-FR-DeniseNeural, ...). Output is an MP3 cached under ~/.alpi/cache/tts/<hash>.mp3 — same text + voice reuses the cached file. Edge TTS runs against a free Microsoft endpoint (no API key), so there's no per-call cost. To use a different voice per call the agent can pass voice=... directly without touching config. alpi setup → Voice gives you a curated shortlist (10 common-language voices) plus a "custom" entry to type any voice id.

The daemon never plays audio itself — the tts tool returns the cached file path and stops. The alpi mobile / desktop apps stream playback on demand from a per-message button, and — when tools.tts.auto_read is on — auto-play each agent reply aloud as it arrives (your own messages are never read); they synthesize through the same Edge TTS path via host.voice.preview. For an external chat (e.g. Telegram) the agent chains send_message(attachment=<path>) to deliver the MP3 as an audio attachment. Workgroups carry an analogous hub-local auto_read flag in the workgroup meta (set from the desktop/mobile workgroup settings) that auto-reads agents' messages — never your directives; it is not replicated to members.

rate and pitch are config-only (not per-call args) — persistent prosody defaults. Leave empty for neutral. Text is capped at 1000 chars (~1 minute); longer input is rejected. Output is always MP3.

tools.stt.{model,language} control the stt tool backed by faster-whisper running on CPU. First call downloads the model weights (~40 MB for tiny, ~150 MB for base, ~500 MB for small, ~1.5 GB for medium, ~3 GB for large-v3) into ~/.cache/huggingface/ and keeps them forever. Pick the smallest model that meets your accuracy bar — base is the sweet spot for spoken messages/voice notes; small or above for podcasts/meetings. language defaults to "" (auto-detect); set to an ISO code (en, es, fr, ...) only when auto-detect fails on short clips.

The Telegram gateway auto-transcribes inbound voice notes and audio files through the same stt pipeline: when a user sends a voice message, the gateway downloads it via getFile, caches under ~/.alpi/cache/inbound/, runs stt, and feeds the transcript to the agent as text ([voice note] <transcription>). The agent sees a normal text turn — nothing surface-specific to handle.

Runtime

Provider stale-call hardening for LLM streaming turns: watchdogs that fail a slow/stuck provider instead of hanging the turn, plus jittered retries before any output reaches the consumer. A timeout of 0 disables that watchdog.

KeyDefaultTypeTakes effect
runtime.first_byte_timeout_s300seconds (0 = off)next turn
runtime.stream_idle_timeout_s120seconds (0 = off)next turn
runtime.max_retries2intnext turn
runtime.retry_backoff_s1.5seconds (base; exponential + jitter)next turn

first_byte_timeout_s is generous so slow reasoning models aren't killed before their first token; bump it for very slow local Ollama or long-thinking models. Retries fire only for transient failures (timeouts, connection drops, 429/5xx) and only before any token has streamed — a partially-streamed turn is surfaced, not silently replayed.

Model reasoning

Optional reasoning-effort hint passed alongside cfg.model to providers that support it (Anthropic extended thinking, OpenAI o-series, DeepSeek R1, etc.). Applied only to the profile's default model — mid-chat model overrides and tool sub-models (research, delegate, web_extract, read_image) ignore it. Models that don't recognise the hint are unaffected.

KeyDefaultTypeTakes effect
model_reasoning.effort"" (no reasoning param sent)`"" \"low" \"medium" \"high""off" written to disk normalises to ""` on loadnext turn
model_reasoning:
  effort: medium

Memory

FieldDefaultNotes
memory.review_interval0 (off)Post-turn reviewer cadence. N > 0 fires a daemon-thread reviewer every N user turns that snapshots the conversation and writes durable facts via memory(action="add"). Append-only — the reviewer cannot replace/remove. Opt-in by design.

Internal-only constants (in alpi/memory.py and alpi/compaction.py, not user knobs):

Calibration stays evidence-gated: these constants should not become user knobs unless real logs/compaction.jsonl / memory-review traces show repeated failures that a fixed default cannot solve.

TUI

alpi's TUI is built on Textual — a full widget-based framework with streaming, focus management, scroll anchoring, and responsive layout. It's the primary surface (not a fallback); gateway and schedule processes inherit the same engine behind the scenes but render through their own channel (chat message, log file).

Design choices worth knowing before tweaking config:

What the top bar shows (left to right):

alpi <version>  │  profile <name> <size>  │  [sandbox|offline]  │  workspace <path>

Config knobs (tui.*):

KeyDefaultTypeTakes effect
tui.show_costtrueboolnext session
tui.show_tokenstrueboolnext session
tui.show_reasoningtrueboolnext session
tui.accent#c8a24eCSS color (hex / named / rgb)next session
tui.themedarkdark \lightnext session
tui.auto_resumefalseboolnext launch

tui.auto_resume makes bare alpi behave as if -c / --continue was passed — the last session is loaded automatically. Use /new inside the TUI to start a fresh thread without changing the config. The flag does not affect alpi chat --once (scripts and the gateway always start clean) or explicit -c usage (still an override).

tui.show_reasoning controls two channels of model-thinking output:

  1. Inter-tool prose — the dim » … line that appears above a tool card with whatever text the model emitted between tool calls.
  2. Streamed chain-of-thought — for reasoning models (DeepSeek-R1, OpenAI o-series, Claude extended thinking), the tail of reasoning_content scrolls live inside the thinking… indicator.

When false, both are hidden from the screen. The reasoning is still persisted to the session file (sessions/*.json) so that re-enabling the flag later brings it back on replay, and so that debug inspection (cat sessions/<id>.json) always has the full context. Gateway surfaces (Telegram, Email) never rendered reasoning, so this flag has no effect there.

Gateway — Telegram / Matrix

KeyDefaultNotes
gateway.telegram{}Placeholder section; no per-platform knobs today. Telegram credentials live in ~/.alpi/.env (TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_SENDERS).
gateway.matrix{}Placeholder section; same shape as Telegram. Matrix credentials in .env.

Both keep typing indicators hardcoded on and send only the final agent reply. Use TUI, desktop, or mobile when you want live tool progress.

Gateway — IMAP

KeyDefaultWhy
gateway.imap.poll_interval60 (seconds)IMAP polling cadence. Keeps CPU/network quiet for personal use.
gateway.imap.mark_as_readtrueProcessed messages marked \Seen so your mail client treats them as read.

Gateway — Gmail

Same knobs as IMAP, different backend. Polling uses Gmail's users.history.list with the last-seen historyId so we only fetch deltas (cheaper than rescanning INBOX). Credentials live in ~/.alpi/<profile>/gmail_token.json after the one-off OAuth consent via alpi setup → Gateways → Gmail.

KeyDefaultWhy
gateway.gmail.poll_interval60 (seconds)Same rationale as IMAP.
gateway.gmail.mark_as_readtrueRemoves the UNREAD label on processed messages.

Configure both if you want: imap polls your primary mailbox via password, gmail polls another account via OAuth, each with its own allowlist (IMAP_ALLOWED_SENDERS vs GMAIL_ALLOWED_SENDERS).

Budget

One daily spending ceiling per profile, in dollars — or unlimited.

A token-denominated cap is intentionally not offered: tokens are not a meaningful business constraint (their cost varies by model), so the only caps that mean anything are dollars or nothing.

The cap covers every turn this profile runs: interactive TUI replies, gateway responses (Telegram / IMAP / Gmail / Matrix), scheduled jobs, sub-agent spawns (research, delegate, read_image), and inbound ALP calls from pinned peers. It is re-checked before each step within a turn, so a long multi-step turn aborts as soon as it crosses the ceiling rather than running to completion. Counters reset at UTC midnight; no carry-over. The ledger lives at ~/.alpi/<profile>/logs/ledger.json and also records a per-peer breakdown for the /cost panel, though only the profile total gates new turns.

KeyDefaultNotes
budget.daily_usdunsetHard daily USD cap. Exceeding it surfaces budget-exceeded (interactive) or JSON-RPC -32005 budget-exceeded (ALP). Unset = unlimited.
budget:
  daily_usd: 5.00

Edit interactively via alpi setup → Budget or the desktop app's profile detail.

Network (shared accessible address)

One address, shared by every network listener this profile runs — the device-pairing host plane and the ALP peer listener both bind/advertise on it, each on its own port. Configure it once.

KeyDefaultEffect
network.host""The address other machines and your devices reach this profile at. Empty = auto-detect (Tailscale first, then a private LAN address). Any reachable host works: a Tailscale / WireGuard / VPN address, a private hostname or MagicDNS name, a LAN IP, 0.0.0.0, or a public IP. A public IP additionally needs host.allow_public_bind: true; that gate applies to the shared bind used by both planes (host control plane + ALP listener), so without it neither binds TCP.
network:
  host: ""        # empty = auto-detect Tailscale → LAN

Set it via alpi setup → Network → Accessible address or the desktop app (Devices → pairing). Ports stay per-plane (host.tcp_port, alp.tcp_port). A public IP is config.yaml-only: the desktop rejects it, and it also requires host.allow_public_bind: true — set both keys by hand.

ALP

ALP always serves the per-profile Unix socket for same-machine peers. The Noise_XK TCP listener is auto-exposed only for the default profile, whenever the machine has a reachable address — bound to the shared network.host (above), or an auto-detected overlay/LAN address, or 0.0.0.0 in Docker. Named profiles stay Unix-only unless they set their own explicit, unique alp.tcp_port (otherwise every profile would fight over the shared port). With no reachable address (no network.host, no Tailscale/LAN, not Docker) even default stays Unix-only. Turn ALP off entirely with service.alp: false.

KeyDefaultNotes
alp.tcp_port7423 (default profile only)The ALP peer TCP port. Auto-exposed for the default profile; a named profile binds TCP only if it sets its own unique port here. The address is network.host.
alp:
  tcp_port: 7423

Ollama

Ollama is a first-class provider. One entry per server — local, remote, different ports — each with its own user-chosen name that becomes the model prefix (home/gemma4:e4b, gpu-box/qwen3:14b). On every request against an Ollama server, num_ctx is auto-resolved from /api/show and injected so the model sees the full prompt instead of being truncated to Ollama's 2K default.

providers:
  ollama:
    - name: home
      url: http://localhost:11434
    - name: gpu-box
      url: http://192.168.1.50:11434

Add via alpi setup → Model → Add Ollama. Remove via alpi setup → Model → Remove keys.

MCP

KeyDefaultNotes
mcp.servers{}Map of <name> → {command, args, env}. Secrets in env use the env:VAR_NAME reference. Add via alpi setup → MCPs — hand-editing is supported but the wizard is easier.

Services (per profile)

Which services the alpi daemon spawns for THIS profile. The daemon itself is one-per-machine (see OPERATIONS.md → Daemon); these flags only decide what it activates here.

service:
  gateway: true     # Telegram / IMAP / Gmail / Matrix / webhook listeners
  schedule: true    # cron tick loop
  alp: true         # peer-to-peer ALP listener
  workgroups: true  # ALP.3 outbound poller for joined workgroups
  host: true        # control-plane socket for desktop / mobile clients (default profile only)

The block name remains service: for back-compat; conceptually each entry is a service the daemon runs for this profile. Missing keys default to True. Toggling takes effect at the next alpi daemon restart.

host is meaningful only on the default profile; on any other profile the toggle is honoured but the runner refuses to bind a socket (the desktop / mobile client always targets default's socket and reaches sibling profiles via the profile parameter on each verb).

Host (control plane)

The host plane serves host.* verbs over a Unix socket (always) and a WebSocket on the shared network.host (see Network above); mobile / remote desktop use this path.

KeyDefaultEffect
host.tcp_port49200WebSocket port for device pairing (the host plane's own port).
host.device_name""Optional pairing name shown in Devices. Empty = auto, otherwise embedded in the pairing QR and device list.
host.allow_public_bindfalseOpt-in to let the shared network bind use a public IP. Affects both the host control plane and the ALP listener — both derive their bind from network.host, so without it neither binds TCP on a public address. A Tailscale / private-LAN / hostname address needs no opt-in; only a public IP does.
host:
  tcp_port: 49200
  device_name: ""

The address itself lives in network.host (shared with the ALP listener), not here — this section is just the host plane's port, pairing name, and the public-bind opt-in. host.device_name controls the visible pairing label for new devices. It is optional; when empty, alpi falls back to the platform hostname.

On regular macOS/Linux installs, leaving network.host empty keeps auto mode: Tailscale first, then LAN, used as both the advertised address and the bind. Setting it advertises that address to clients/peers; the bind is derived separately — a private/Tailscale IP binds itself, a hostname or an opted-in public IP binds 0.0.0.0, and a public IP without host.allow_public_bind refuses to bind at all. In Docker the daemon binds 0.0.0.0 inside the container while clients dial the address you advertise via ALPI_NETWORK_HOST — a LAN IP, a 100.x Tailscale IP, or a MagicDNS hostname (see docker/README.md).

Pairing tokens for the WS transport live at ~/.alpi/host/devices.yaml (mode 0600). Manage them through alpi setup → Devices — generate, label, revoke. The full token appears once inside the pairing QR; the listing redacts to a token_id (last 8 chars).

Takes-effect cheat sheet

theme