Snodo Runbook — Install & Operate¶
Install¶
Requires Python 3.12 or later.
Install or update Snodo in one step:
curl -fsSL https://snodo.dev/install | bash
Run the same command again to update. Alternatives:
Try Snodo without installing it:
uvx snodo --version
For regular use, install the command with uv:
uv tool install snodo
If you prefer pip, install Snodo in your environment:
pip install snodo
Verify:
snodo --version
From source (a uv workspace):
git clone https://github.com/snodo-dev/snodo.git
cd snodo
uv sync --all-extras
Configure¶
Task blocked¶
When a task blocks, inspect its preserved changes and grouped validator verdicts
with snodo task show <task_id> --diff. Use the recommended next action printed
with the halt and in task/job status: review and authorize an escalate, repair
the cause of an operational failure before retrying, or fix a blocker forward.
For a blocked plan, keep the work in that plan, replace the task spec only when
the spec is wrong, and rerun the affected wave until all waves are delivered.
API keys¶
Store keys in ~/.snodo/config.yml (permissions 0600, file created on first snodo config add):
snodo config add openai sk-...
snodo config add anthropic sk-ant-...
snodo config add google AIza...
To keep config-based keys without leaving them in plaintext, run
snodo config --encrypt-provider-keys. It backs up config.yml as
config.yml.bak (or a numbered backup if one exists), creates a dedicated
provider-encryption RSA keypair in ~/.ssh/NO-AGENT/ on first use, and moves
each plaintext provider key to ~/.ssh/NO-AGENT/keys/<provider>.key (directory
0700, files 0600). The config then contains a quoted reference such as
api_key: "@keys/openai.key". Existing references are skipped, so rerunning
the command is safe. Snodo decrypts a key only when that provider is used.
Keep the dedicated provider-keys.pem private key to read these files; it is
separate from the audit-signing snodo.pem and survives snodo init --force-keygen.
This is obfuscation, not a vault: anyone with the private key and encrypted
files can decrypt the provider credentials. The config backup still contains
the original plaintext keys; remove it securely once you no longer need it.
Or set environment variables — the engine auto-detects based on the model prefix:
| Model prefix | Environment variable |
|---|---|
claude-* |
ANTHROPIC_API_KEY |
gpt-*, o1-*, o3-* |
OPENAI_API_KEY |
gemini-*, gemini/* |
GEMINI_API_KEY |
GitHub token for --from-pr:
export GITHUB_TOKEN=ghp_...
Engine settings¶
snodo config set engine.max_subtask_depth 5 # default: 3, range 1-10
snodo config set engine.max_session_age_days 60 # default: 30, range 1-365
snodo config set engine.token_ttl_seconds 1200 # default: 600, range 60-86400
Snodo home directory¶
Default: ~/.snodo/. Override with SNODO_HOME:
export SNODO_HOME=/custom/path
All data — config, sessions, agent memory — lives under this directory.
Token secret¶
JWT validation tokens are HS256-signed. The signing secret is randomly generated per process. To persist across restarts:
export SNODO_TOKEN_SECRET=$(openssl rand -hex 32)
User configuration reference¶
The complete schema, defaults, environment references, cloud settings, and all
five notification target formats live in the canonical
user configuration reference. Notification targets belong
in ~/.snodo/config.yml (or $SNODO_HOME/config.yml), not in a project
protocol. Test them with snodo config --notify-test.
Example ~/.snodo/config.yml with common role settings and one webhook:
model: claude-sonnet-4-20250514
llm:
num_retries: 3
coder:
model: null
max_tokens: 16000
max_tool_turns: 6
timeout_seconds: 1800
silence_timeout_seconds: 600
concurrency: 1
validator:
model: null
max_tokens: 1500
max_tool_turns: 6
classifier:
model: null
max_tokens: 500
temperature: 0.0
recon:
num_agents: 1
models: []
wave:
max_age_days: 14
max_idle_days: 5
engine:
max_subtask_depth: 3
max_session_age_days: 30
token_ttl_seconds: 600
cloud:
api_key: "" # add a literal Snodo Cloud key to enable sync
api_url: https://api.snodo.dev
tunnel_api_url: https://app.snodo.dev
sync_enabled: false
liveness_interval_seconds: 60
notifications:
targets:
- type: webhook
name: team-updates
url: env:SNODO_WEBHOOK_URL
events:
- job_finished
- task_halted
- authorization_needed
- job_silent
silence_threshold_seconds: 900
The bare key model and settings under engine., llm., providers.,
cloud., and notifications. are settable without hand-editing:
snodo config set model deepseek/deepseek-v4
snodo config set llm.coder.max_tokens 32000
snodo config set llm.validator.model openai/@cf/google/gemma-4
snodo config set llm.recon.num_agents 2
snodo config get llm.coder.max_tool_turns
snodo config set providers.ollama.base_url http://localhost:11434/v1
snodo config set providers.ollama.litellm_provider openai
snodo config get providers.ollama.base_url
snodo config set cloud.sync_enabled true
snodo config set notifications.silence_threshold_seconds 1200
Unknown keys report the rejected key. Secret fields are redacted by config
get; provider credentials can also be managed with snodo config add and
snodo config remove.
The built-in providers—Anthropic,
OpenAI, Google,
OpenRouter, DeepSeek, and
Cloudflare Workers AI—have built-in provider
configuration. For Ollama and OpenCode Go,
see their setup guides. litellm_provider: openai is what makes an
arbitrary compatible endpoint work: snodo rewrites ollama/<model> to
openai/<model> and sends it to base_url. Omit api_key_env for a local
server that needs no key. This covers Ollama Cloud, a local Ollama or
llama.cpp server, vLLM, LM Studio, and self-hosted gateways.
Read from the environment, never stored in the config file: SNODO_HOME,
SNODO_TOKEN_SECRET, GITHUB_TOKEN (for --from-pr), and any
<PROVIDER>_API_KEY.
Check readiness before you spend¶
snodo ready is worth running first on an existing repository. It checks,
deterministically and offline, protocol well-formedness, committed decision
records, a resolvable test command, coder configs, cited paths, git worktree
viability (including an initial commit), and required delivery remotes. It also
reports model/provider and credential configuration and workstation binaries.
Repository findings are scored; workstation findings are unscored. It makes no
model calls, contacts no remotes, and does not execute command credential
references. Task worktrees only see HEAD, so "present on disk" is not enough.
$ snodo ready
Method Scaffolding Readiness: 80% (4/5 checks satisfied)
Repository Readiness (Scored — travels with git repository):
❌ [warn] Path 'authentication/authorization' cited in criteria of validator
'security' does not exist in repository.
Fix: Create and commit 'authentication/authorization'
Repository findings are scored and travel with the repository; workstation
findings (binaries and credential availability) are reported but unscored. --mode
filters the displayed findings to one mode; --json emits the machine-readable
form.
Propose criteria from your own decisions¶
A governed repository's records often state, in prose, the very rules the
protocol encodes by hand — that an id is derived server-side, that every query
carries a tenant filter. snodo intake reads those records and offers each
rule as a validator criterion, one at a time, each naming the record it came
from. The operator accepts or rejects each, and the protocol is written only
after an acceptance:
$ snodo intake
Intake: decision records under /path/to/repo
1 criterion proposal(s), each citing the record it came from.
• The org_id is derived server-side from the API key lookup, never accepted
from the request.
from: docs/decisions/001-derive-org-id-server-side.md
[1/1] The org_id is derived server-side from the API key lookup, never
accepted from the request.
from: docs/decisions/001-derive-org-id-server-side.md — Derive org_id server-side
Accept? [y/N]
Only a record's Decision section is proposable. Context is background,
Consequences are effects a reader can observe rather than rules to violate,
Alternatives considered names the road not taken, and Status is metadata —
none of them is offered. A record with no Decision section proposes nothing.
Use --validator <id> to choose the target validator (the default is the
protocol's architecture validator, then its first), --reject-all to see the
proposals and write nothing, or --json to have a machine read them without a
write. With no flag and no terminal, intake refuses rather than guessing.
Quickstart¶
# In a new, empty project directory; an existing repository with a commit can
# skip the Git initialization and initial commit steps.
printf '# My project\n' > README.md
git init -b main
git add README.md
# Git must have user.name and user.email configured for this commit.
git commit -m "Initial project commit"
# uvx runs the published package in a temporary environment. For the pip
# fallback instead, install Snodo and the selected stack's test runner first.
# pip install snodo
# pip install pytest
uvx --with pytest snodo init --template team --test-command "PYTHONPATH=. pytest" --yes
uvx --with pytest snodo run "add a hello() function that returns the string 'world', with a test" --mock
The examples below use the same sequence for each stack: add that stack's root
manifest, initialize Snodo, run the deterministic mock task, then read the
result printed by the run (including its outcome and task branch). snodo init
detects the test command from these root files. The Node example declares a
test script and uses npm unless a pnpm, Yarn, or Bun lockfile selects that
package manager. Install Snodo with uv tool install snodo for regular use (or
pip install snodo); uvx runs the published package in a temporary environment.
Python¶
# Add pytest as the project's declared test dependency before committing.
printf '[project]\nname = "my-project"\nversion = "0.1.0"\n\n[tool.pytest.ini_options]\npythonpath = ["."]\n' > pyproject.toml
printf '# My project\n' > README.md
git init -b main
git add README.md pyproject.toml
git commit -m "Initial project commit"
# Pip alternative: pip install snodo
# Then install the test runner: pip install pytest
uvx --with pytest snodo init --template team --test-command "PYTHONPATH=. pytest" --yes
uvx --with pytest snodo run "add a hello() function that returns the string 'world', with a test" --mock
For auto-detection rather than an explicit override, use uvx --with pytest
snodo init --template team --yes; pyproject.toml selects pytest. The
explicit --test-command form above also works and is useful when the detected
command needs project-specific options.
Node / TypeScript¶
printf '{"name":"my-project","version":"1.0.0","scripts":{"test":"node --test"}}\n' > package.json
printf '# My project\n' > README.md
git init -b main
git add README.md package.json
git commit -m "Initial project commit"
uvx snodo init --template team --yes
uvx snodo run "add a hello() function that returns the string 'world', with a test" --mock
The mock fixture uses Node's built-in test runner, so Node.js must be available.
For TypeScript projects, keep a real test script in package.json; init uses
that script, while the mock fixture remains JavaScript.
Go¶
printf 'module example.com/my-project\n\ngo 1.22\n' > go.mod
printf '# My project\n' > README.md
git init -b main
git add README.md go.mod
git commit -m "Initial project commit"
uvx snodo init --template team --yes
uvx snodo run "add a hello() function that returns the string 'world', with a test" --mock
The Go toolchain must be installed; init detects go.mod and configures
go test ./....
Rust¶
printf '[package]\nname = "my-project"\nversion = "0.1.0"\nedition = "2021"\n' > Cargo.toml
printf '# My project\n' > README.md
git init -b main
git add README.md Cargo.toml
git commit -m "Initial project commit"
uvx snodo init --template team --yes
uvx snodo run "add a hello() function that returns the string 'world', with a test" --mock
The Rust toolchain must be installed; init detects Cargo.toml and configures
cargo test.
This is the canonical first-run command sequence; the README and
docs home link here. Git needs a committer identity before the
commit: configure it with git config --global user.name "Your Name" and
git config --global user.email "[email protected]", or set repository-local
values with git config user.name "Your Name" and
git config user.email "[email protected]".
uvx runs Snodo and pytest from a temporary environment, without installing
either into the system Python. The starter README ensures the initial
commit has a file, and Snodo's isolated task worktrees start from that committed
HEAD. Starting on main is required for the default isolated-worktree flow.
In an existing repository, skip git init; if it has no commits, commit at
least one project file first. --yes acknowledges Snodo's trusted-repository
model so initialization works without an interactive prompt. snodo init adds
Git ignore rules for Snodo's runtime state while allowing the authored
.snodo/protocol.yml to be added and committed.
For Python, the mock coder always writes the same src/hello.py and
tests/test_hello.py hello-world fixture, whatever the task text says; it does
not implement the requested task. PYTHONPATH=. in the test command makes the
fixture's from src.hello import hello import resolve from the project root.
With the fixture present, that command lets pytest import and run its test; a
passing fixture test does not establish that a real coder can implement your
requirements.
On a successful mock run, the quality validator reports that the fixture test
passed and Snodo resolves the task. The team template deliberately leaves
producer work unmerged, so the run prints Task resolved but its work was NOT
merged to the base branch. The output names the task branch holding the work
and gives the exact command to merge it manually (git merge <branch>); the
base branch has not moved. This is the expected result, not a failed run, for
all four stacks.
Choose delivery during interactive snodo init, or pass --delivery to select
it without a prompt. The choices are local_merge (merge locally after
verification), push_branch (push the verified branch), change_request (push
and open a change request), and leave_unmerged (leave the work for a human to
merge). Omitting the choice keeps the template default. In particular, the
team template defaults to leaving producer work unmerged to preserve its
separate producer/reviewer roles. --yes, --no-input, and non-interactive
init never prompt for delivery. Automated delivery still requires a resolved
task and passing verification for its branch commit.
For provider-backed coding, configure a provider (for example,
snodo config add anthropic <key> or ANTHROPIC_API_KEY) and run the same task
description without --mock; the selected provider needs a credential in Snodo
config or its environment variable. A configured local endpoint that declares
no api_key_env needs no credential. --test-command declares the test runner;
it does not create tests. The configured command must exist and pass for the
quality validator to report a passing test outcome. Task isolation is required
by default; --no-isolation is an explicit fallback that runs in the current
working tree.
For an MCP run, the orchestrator validates and dispatches a task (or builds a
plan/queue when the work needs that structure). Work starters return a job ID;
use the configured cloud live view for task status/progress when a task_ref is
available, and snodo logs <job_id> --watch for live output. watch_job's MCP Apps panel is an
optional host feature; clients without Apps support receive a text snapshot.
Templates¶
The template list is derived from snodo/protocols/templates/ — drop in a YAML file and it becomes selectable. Shipped templates:
| Template | Modes | When to use |
|---|---|---|
solo |
producer | Single developer, no review handoff |
team |
producer, reviewer, planner | Two-stage with separate approval authority |
2+n |
producer, reviewer | Paper reference config with scope/test/secrets predicates |
intent |
producer | Intent-driven, warn-only spec validators |
bugfix-surgeon |
producer | Bug-fix flow with a post-execute review gate |
feature-warden |
producer | Feature flow with a scope guard |
greenfield |
plan, decide, scaffold, build | Phased build of a new project with per-phase exit gates |
product |
producer | Product documents in Markdown with role-specific validation and change-request delivery |
design |
producer | Design briefs, UX specifications, UI copy, and component specifications in Markdown |
docs |
producer | Technical documentation with accuracy, example, and link checks |
research |
producer | Evidence-led Markdown reports and decision memos |
Run snodo init --template <name> to pick one directly, or snodo init to choose from the menu.
See templates for product, design, docs, and research for deliverables, checks, and human review.
Coders¶
The coder writes; snodo governs, gates and records. --coder selects one:
--coder |
Mechanism | Needs | Auth |
|---|---|---|---|
litellm (default) |
In-process completions via LiteLLM | built-in | provider API keys |
opencode-cli |
Host opencode run |
opencode on PATH |
opencode auth login |
agy |
Antigravity CLI (agy -p) |
agy on PATH |
agy login |
codex-cli |
OpenAI Codex CLI (codex exec) |
codex on PATH |
codex login |
opencode (experimental) |
OpenCode server in Docker over HTTP | Docker, opencode:latest |
container env |
mock |
Deterministic stub | nothing | none |
Three things are worth knowing up front:
-msets the judging model, not the coder's. Validators and the classifier run on it. Host CLIs keep their own model catalogs; to pin a coder's model, namespace it —--coder codex-cli --model codex-cli/gpt-5-codex.- In-place coders own their commit.
opencode,opencode-cli,codex-cliandagyedit the worktree directly and commit, so post-execute validators judge the exact change. Any attempt to touch.snodo/halts as a blocker (ADR 027). - Selection order:
--mock, then--coder, then a mode'scoder:field, then a model prefix (codex-cli/,agy/,opencode-cli/,claude,gpt…), thenlitellm.
To add one, see the coder adapter contract.
Retrying a failed task¶
Retrying a failed task keeps its specification. snodo run --retry <task_id>
re-runs the task against the spec on record — that bare form is what the CLI
prints after a failure, so pasting it is safe. --append-spec "…" adds guidance
on top of that spec (a positional description does the same); --replace-spec
"…" replaces it, which is the only retry that discards anything, and the
discarded spec stays readable with snodo task show <task_id>.
Completing a task outside the loop (by hand)¶
When an operator completes a task by hand outside the loop (for instance, inspecting a preserved worktree, merging the changes, and verifying the work manually), record the completion with:
snodo task complete <task_id>
This records the provenance in the audit log (who, when, unjudged by the engine) and updates the plan's status.json so the plan advances from that record rather than manual file edits. Supported options include --plan, --who, --notes, and --json.
An orchestrator connected over MCP records the same thing with the
record_task_status tool (plan_name, task_id, status, who, optional
notes). It is the machine-side snodo task complete: the same status
vocabulary, the same provenance and the same audit event, written through the
same implementation, so the plan state the two leave cannot diverge. A recorded
status is an operator's account — the audit entry is marked unjudged and
outside_loop, and it is never a validator verdict or a substitute for one.
CLI reference¶
The maintained, complete command and flag index is the command reference. Model discovery and selection guidance is in Choosing models; plan authoring is in Authoring a plan.
MCP Serving¶
snodo serve starts one FastMCP server for the resolved protocol mode. Without
--mode, it resolves the current mode; it does not combine grants from all
modes. Pin a mode explicitly when you want that mode's tool set:
# Current mode
snodo serve
# Single mode — only that mode's tool set
snodo serve --mode producer
snodo serve --mode reviewer
Server naming: snodo-{protocol_id} for the current project mode, or
snodo-{protocol_id}-{mode_id} for a server pinned with --mode.
Connecting an orchestrator (Claude Desktop, custom agent):
{
"mcpServers": {
"snodo-producer": {
"command": "snodo",
"args": ["serve", "--mode", "producer"]
}
}
}
Use snodo serve --mcp-install to write MCP server entries to detected Claude
Desktop JSON configuration and the shared Codex / ChatGPT desktop TOML
configuration (~/.codex/config.toml, or $CODEX_HOME/config.toml). Codex
entries include startup and tool timeouts, and the launcher points to the Snodo
installation environment that ran the installer. The installer skips clients
whose config directory is absent. Restart updated clients after installation
for the new configuration to take effect. --mcp-uninstall,
--mcp-uninstall-all, --mcp-list, and orphan cleanup cover those clients too.
How modes become servers¶
Each protocol mode declares logical tools; snodo serve maps each grant to
concrete MCP operations. A tool a mode does not grant is not exposed. Current
templates grant the write capability where their workflow needs plan/config
authoring; it exposes write_file only, and delete_file is not exposed.
Unknown capability grants are warned about when an MCP server is created and
reported by snodo ready, with the mode, unknown grant, and known capability
names; they do not expose tools.
| Protocol mode tool | MCP tool(s) | Purpose |
|---|---|---|
edit |
read_file, list_files, model/recon tools |
Read and understand project files |
write |
write_file |
Write files under the protocol's allowed prefixes |
approve / commit |
stage_files, commit |
Stage and commit changes |
pr |
create_change_request, read_change_request_diff, post_change_request_comment, approve_change_request, request_change_request_changes, merge_change_request, read_change_request_discussion |
Create and manage vendor-neutral change requests; legacy *_pr tools remain available as deprecated aliases |
write_file writes directly; it does not stage or commit. Its default path
allowance is .snodo/. A protocol can replace that default with a top-level
prefix list, for example:
write_allowed_prefixes:
- .snodo/
- docs/specs/
Paths are resolved against the project root (including .. and symlinks),
must remain inside an allowed prefix, and cannot name a directory. Refusals
name the allowed prefixes. Successful writes are recorded in the audit log
with their path, byte count, SHA-256 content hash, mode, and session. Grant
write only to modes that need this capability. No exposed call demands a
validation token from the caller (ADR 047); the validator quorum is enforced
inside the engine loop, per task.
Validation flow (engine loop + INV3)¶
- An orchestrator calls
validate_task→ the server runs the real pre-execute validators (the same quorum the loop runs) - If the quorum passes (policy threshold met, no blockers), a single-use JWT is recorded; the next
dispatch_taskconsumes it as the audit link between the pre-check and the work - The orchestrator dispatches (
dispatch_task,run_plan); the run validates the task again inside the engine loop before the coder executes - If any validator emits blocker, the task halts (INV3) — no token is issued and the loop does not proceed
- If the task escalates (threshold not met, no blockers), use
snodo authorizeto review and sign
Troubleshooting¶
"Protocol file not found"¶
Run snodo init first, or specify the protocol path with --protocol <path>.
Task blocked with "BLOCKED: ..."¶
The validators or runtime returned an outcome. Check the structured halt
payload for canonical halt_type, raw cause, and per-validator justifications:
--- STRUCTURED HALT PAYLOAD ---
{
"halt_type": "escalate",
"validator_results": [...],
"hint": "Address the blocking concerns and re-run..."
}
For example:
halt_type: escalate— the policy threshold was not met and there was no blocker. A human may review and sign withsnodo authorize <task_id>.halt_type: blocker— at least one validator returned blocker (INV3). Address the blocking concern; it cannot be authorized away.validator_error,internal_error, orenvironment_error— an operational failure, not a verdict about the task. Diagnose the error and do not feed it back as task-spec critique.
Token expired or invalid¶
Tokens expire at the configured TTL (default 10 minutes) and are single-use. At the tool surface this is never a refusal: dispatch is not gated on a caller-held token (ADR 047). Inside the engine loop an expired token does stop the run — the session checkpoint preserves state, and the engine re-issues a new token on resume after re-validating.
Quality validator runs subprocess tests¶
The quality validator type executes the repo's test suite via subprocess. It
uses a declared tooling.test_command or detects one from project marker files.
Shipped templates provide a no-op fallback if no test command is configured or
detected; the audit outcome is no_tests, not a claim that tests passed.
Override in the protocol:
validators:
- validator_id: "quality"
validator_type: "quality"
evaluation_phase: "post_execute"
tooling:
test_command: "pytest"
timeout: 120
Session disappeared or cannot resume¶
Sessions are scoped to (mode, project). Use snodo session list --mode <m> to filter. Auto-resume finds the matching active session; if none exists, a new session is created.