Snodo Runbook — Install & Operate¶
Install¶
Requires Python 3.12 or later.
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¶
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...
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)
The full configuration file¶
Configuration lives in ~/.snodo/config.yml ($SNODO_HOME overrides the
location). Manage it with snodo config rather than editing by hand; the file
below is the whole surface, with the defaults and ranges shown.
model: deepseek/deepseek-v4 # default for all roles
llm:
num_retries: 3 # 0-10, litellm retry count
coder:
model: null # null = the default model
max_tokens: 16000
max_tool_turns: 6 # 1-200
timeout_seconds: 1800
concurrency: 1 # coders this operator can carry
validator:
model: null # role-specific override
max_tokens: 1500
max_tool_turns: 6 # 1-200
classifier:
model: null
max_tokens: 500
temperature: 0.0 # 0.0-2.0
recon:
num_agents: 1
models: [] # ordered failover list
wave:
max_age_days: 14
max_idle_days: 5
engine:
max_subtask_depth: 3 # 1-10
max_session_age_days: 30 # 1-365
token_ttl_seconds: 600 # 60-86400
providers:
anthropic:
api_key: sk-ant-...
api_key_env: ANTHROPIC_API_KEY # injected at runtime when a matching model runs
ollama:
base_url: https://ollama.com/v1
api_key_env: OLLAMA_API_KEY
litellm_provider: openai # route ollama/<model> through the OpenAI protocol
cloud:
api_url: https://api.snodo.dev
sync_enabled: false
liveness_interval_seconds: 60 # at most one liveness push per
# interval, and at least one while
# a session is running; lower it to
# hear from a quiet run more often
Every key is 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
Anthropic, OpenAI, Google, OpenRouter, DeepSeek and Cloudflare Workers AI have
built-in provider configuration. 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 without an LLM, whether every artefact the protocol
demands is committed — decision records, a resolvable test command, coder
configs, paths cited in criteria — and scores what is missing by how cheap it
is to fix. 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 (missing binaries, plaintext keys) 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¶
# 1. Initialize a project from a template
snodo init --template team
# 2. Run a task through the protocol
snodo run "implement a user registration endpoint" --mock
# 3. Run with a real LLM (requires configured API key)
snodo config add anthropic sk-ant-...
snodo run "add password reset flow"
The --mock flag uses a deterministic stub coder — no API call is made and no
key is spent. It still returns artifacts, so the protocol's gates, the worktree
isolation and the merge path are exercised end to end; useful for testing
protocol configuration and validator behaviour without a provider.
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 |
decide, scaffold, build | Phased build of a new project with per-phase exit gates |
Run snodo init --template <name> to pick one directly, or snodo init to choose from the menu.
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 |
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 agy --model agy/gemini-2.5-pro.- In-place coders own their commit.
opencode,opencode-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 (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¶
snodo <command> --help is authoritative; the table below is the map.
init |
Scaffold .snodo/ from a template |
run |
Execute a task, a plan (--plan), or a single wave (--wave). --background, --resume, --retry, --from-pr, --interactive, --no-isolation |
ready |
Score method-scaffolding readiness against the protocol |
intake |
Propose validator criteria from decision records; accept or reject each |
plan |
list, status, create, validate, add-wave, add-task, run, delete |
status / mode |
Active session and mode; mode change to switch |
session |
list, show, new, switch, delete, prune |
authorize |
Adjudicate escalated disagreements and set_model proposals |
validate |
Run a phase's validators without a coder and return the structured result |
audit verify |
Verify the hash chain |
job / logs / meta |
Background jobs: list, status, logs, wait, cancel; log streaming; usage |
task / worktree |
Task branches and the git worktrees used for isolation |
recon |
Fan out read-only agents to answer a question; llm.recon.models is an ordered failover list |
models / config |
Model discovery; keys and settings |
serve |
Run the protocol as an MCP server (stdio or SSE) |
cloud |
connect, disconnect, status for audit sync |
dashboard |
TUI (snop) |
agent / sandbox / install / uninstall |
Agent memory; Docker sandbox; Claude Desktop MCP entries |
Models¶
snodo models lists the configured providers, and snodo models --provider=<name>
lists that provider's models with context window and price. --stats reports
what your own jobs actually spent and how they ran, aggregated from project
records.
snodo models --benchmark answers a different question: how fast is a model on
one fixed task? It sends one prompt, the same prompt every run, read from
snodo/cli/commands/model_benchmark_prompt.txt in the repository, and reports
the output tokens per second, the time to first token and the total wall time.
Because the prompt never varies, two providers produce two numbers worth
comparing — unlike --stats, whose rate moves with whatever prompts happened to
run. The prompt's identity (opening line, length and content hash) is printed
with the result, so you can tell whether two runs used the same prompt.
Changing the prompt file makes past numbers incomparable; the file says so.
Narrow the run to a single model with the same flags that filter a listing:
snodo models --benchmark --provider=deepseek --id-contains=chat
--benchmark makes one real, billed API call. It is never reachable from
any other command and runs only when you pass the flag; the command prints the
model, the prompt identity and the fact that it will spend before it does. The
output names the token-count basis as well: throughput is computed from the
provider's reported usage when it reports any, and from a local tokenizer when
it does not — a comparison between a provider-reported count and a locally
estimated one is flagged as such rather than silently averaged.
Plan¶
| Command | Description |
|---|---|
snodo plan create <intent> |
Create an empty plan to author into (never generates waves) |
snodo plan list |
List all plans |
snodo plan status <name> |
Show plan progress |
snodo plan validate <name> |
Verify plan structure and task spec files (--json) |
snodo run --plan <name> |
Execute a plan by name (--wave N, --interactive) |
Plans are authored, not generated: plan create scaffolds one empty wave, and
you add waves and tasks (ids are <wave>.<seq>_<name>, e.g. 1.1_models) or
edit plan.yml directly. A plan is re-verified on every load. See
runbooks/hand-authored-plan.md.
Session¶
| Command | Description |
|---|---|
snodo session list |
List sessions (filterable by --mode, --project) |
snodo session show <id> |
Show session details |
snodo session delete <id> |
Delete a session |
snodo session prune |
Remove stale sessions (>30 days by default) |
Mode¶
| Command | Description |
|---|---|
snodo mode show |
Show active mode |
snodo mode change <mode_id> |
Switch active mode |
Config¶
| Command | Description |
|---|---|
snodo config show |
Show configured keys (masked) |
snodo config add <provider> <key> |
Store an API key |
snodo config remove <provider> |
Remove an API key |
snodo config test |
Validate all configured keys |
snodo config set <section> <key> <value> |
Set a config value |
snodo config get <section> <key> |
Get a config value |
Agent memory¶
| Command | Description |
|---|---|
snodo agent list |
List all agents |
snodo agent memory <name>:<mode> |
Show agent memory summary |
snodo agent reset <name>:<mode> |
Clear memory, assign new thread |
snodo agent rotate <name>:<mode> |
Rotate thread ID (keeps checkpoints) |
Authorization¶
| Command | Description | Key flags |
|---|---|---|
snodo authorize [TASK_ID] |
Review and sign pending decisions | --yes, --reject-all |
Jobs¶
| Command | Description |
|---|---|
snodo job list |
List background jobs |
snodo job status <id> |
Show job status |
snodo job logs <id> |
Show job logs |
snodo job wait <id> |
Wait for completion |
snodo job cancel <id> |
Cancel a running job |
Docker sandbox¶
| Command | Description |
|---|---|
snodo sandbox build |
Build the worker image |
snodo sandbox status |
Check Docker availability |
Run with snodo run ... --sandbox docker to execute inside a container.
Install / Uninstall (Claude Desktop)¶
| Command | Description |
|---|---|
snodo install |
Install MCP servers into Claude Desktop config |
snodo uninstall |
Remove MCP servers from Claude Desktop config |
Dashboard¶
snop
Or snodo dashboard — launches the Textual TUI for live session monitoring.
MCP Serving¶
snodo serve starts one or two FastMCP servers based on the protocol:
# All modes — one server exposing all tools
snodo serve
# Single mode — only that mode's tool set
snodo serve --mode producer
snodo serve --mode reviewer
Server naming: snodo-{protocol_id} (all modes) or snodo-{protocol_id}-{mode_id} (single mode).
Connecting an orchestrator (Claude Desktop, custom agent):
{
"mcpServers": {
"snodo-producer": {
"command": "snodo",
"args": ["serve", "--mode", "producer"]
}
}
}
Or use snodo install / snodo uninstall to manage the Claude Desktop config automatically.
How modes become servers¶
Each protocol mode declares a set of logical tools (e.g., edit, approve, pr). snodo serve maps those to concrete MCP operations and serves exactly the mode's grant: tools the active mode(s) do not grant are not exposed at all (write_file and delete_file belong to no shipped grant), and 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 found issues. Check the structured halt payload for per-validator justifications:
--- STRUCTURED HALT PAYLOAD ---
{
"halt_type": "escalated",
"validator_results": [...],
"hint": "Address the blocking concerns and re-run..."
}
Two refusal modes appear in the payload:
halt_type: escalated— no single validator blocked, but the policy threshold wasn't met (e.g., unanimous needs all to pass, but some emitted warn). Usesnodo authorize <task_id>to review and sign.halt_type: blocked— at least one validator emitted blocker (INV3). Address the blocking concern and re-run; blocking concerns cannot be voted down.
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 auto-detects the test command from common marker files (package.json, pyproject.toml, Cargo.toml, go.mod, Makefile). 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.