Extending Snodo¶
Five extension points. Each maps to an interface or registry in the codebase. You implement the interface, register your implementation, and reference it from protocol.yml.
Try it in a minute¶
From the Snodo repository root, check which Python the snodo command uses:
sed -n '1p' "$(command -v snodo)"
For Snodo installed with uv tool install, install the runnable snodo-hello-validator example into Snodo's tool environment:
uv pip install --python ~/.local/share/uv/tools/snodo/bin/python -e ./examples/snodo-hello-validator
For Snodo installed in an activated virtualenv or with pip:
python -m pip install -e ./examples/snodo-hello-validator
Confirm discovery:
snodo ready --json
Confirm extensions → snodo.validators lists hello_acceptance as installed. Add this validator to .snodo/protocol.yml:
validators:
- validator_id: hello_acceptance_check
validator_type: hello_acceptance
evaluation_phase: pre_execute
criteria:
- Task specification includes an ACCEPTANCE section
Then run snodo validate <task-id>. Uninstall from the same environment where Snodo is installed.
For a uv-tool installation:
uv pip uninstall --python ~/.local/share/uv/tools/snodo/bin/python snodo-hello-validator
For an activated virtualenv or pip installation:
python -m pip uninstall snodo-hello-validator
See the example README for details.
1. Custom validators¶
Interface¶
Subclass ValidatorBase (snodo.validators.context) and implement the required methods:
from snodo.validators.context import ValidatorBase, ValidatorContext
from snodo.core.interfaces import ValidatorResult
class MyValidator(ValidatorBase):
@classmethod
def registered_type(cls) -> str:
return "my_type"
def evaluate(self, context: ValidatorContext) -> ValidatorResult:
...
registered_type() returns the string you'll use in protocol.yml as the validator_type. evaluate(context) receives a ValidatorContext with the task, current mode, protocol, artifacts, working directory, and an optional LLM completion function — read what you need.
Registration¶
Register with the default registry:
from snodo.validators.registry import _default_registry
_default_registry.register("my_type", MyValidator)
For a validator that handles multiple types, use register_compound:
_default_registry.register_compound({"my_type", "my_alias"}, MyValidator)
Register an installable validator package with the snodo.validators entry-point group. The entry-point name becomes the protocol's validator_type; its value must load a ValidatorBase subclass:
# pyproject.toml
[project.entry-points."snodo.validators"]
my_type = "my_package.validator:MyValidator"
After publishing and installing the package in Snodo's environment, Snodo discovers it automatically. snodo ready lists loaded and failed plugins, including load errors. Validators may still use the default registry's register/register_compound APIs for in-process registrations.
Wiring into the protocol¶
validators:
- validator_id: "my_check"
validator_type: "my_type"
evaluation_phase: "pre_execute"
criteria:
- "Custom check description"
severity_cap: "blocker" # optional — cap at "warn" for experimental validators
The engine dispatches to your validator through the validator registry. The evaluation_phase controls when it runs: pre_execute before code generation, post_execute after, mode_transition on mode change.
Worked example¶
The test suite includes a complete custom-validator proof (tests/validators/test_custom_validator.py):
class CustomValidator(ValidatorBase):
def __init__(self, validator_spec: Validator):
self.validator_spec = validator_spec
self.validator_id = validator_spec.validator_id
@classmethod
def registered_type(cls) -> str:
return "custom_type"
def evaluate(self, context: ValidatorContext) -> ValidatorResult:
if "safe" in context.task.spec.lower():
return ValidatorResult(
validator_id=self.validator_id,
severity="pass",
justification="Task spec mentions 'safe'.",
)
return ValidatorResult(
validator_id=self.validator_id,
severity="warn",
justification="Task spec does not mention 'safe'.",
)
from snodo.validators.registry import _default_registry
_default_registry.register("custom_type", CustomValidator)
ADR 005 for the design rationale. See docs/coders.md for coder-backend implementation details.
2. Custom predicates¶
Interface¶
Subclass Predicate (snodo.predicates.base) and implement one method:
from snodo.predicates.base import Predicate, PredicateContext, PredicateResult
class MyPredicate(Predicate):
def evaluate(self, context: PredicateContext, **params) -> PredicateResult:
# context.artifacts — list of file paths produced so far
# context.mode — current mode ID
# context.workspace_mcp — WorkspaceMCP (or None)
# **params — constraint-specific params from the YAML
...
return PredicateResult(
passed=True,
justification="All files in scope",
evidence={"matched": [...]},
)
Predicates are deterministic — no LLM calls, no write side effects. They must handle both "governance" and "post_validate" phases (context.phase), passing trivially when context is insufficient.
Registration¶
from snodo.predicates.registry import _default_registry
_default_registry.register("my_predicate", MyPredicate())
Note the difference from validators: predicates register instances, not classes. For an installable package, register the instance or subclass in the snodo.predicates entry-point group; the entry-point name is the predicate name. The entry point may resolve to an instance or a no-argument Predicate subclass:
[project.entry-points."snodo.predicates"]
my_predicate = "my_package.predicates:MyPredicate"
Snodo discovers installed predicate plugins automatically, and snodo ready reports loaded plugins and failures. WF5 verifies referenced predicate names at protocol load time.
Wiring into the protocol¶
global_constraints:
- constraint_id: "my_check"
description: "All artifacts must pass my check"
predicate: "my_predicate"
params:
my_param: "value"
severity: "blocker"
Constraints can be placed at three levels: global_constraints (every task), mode.constraints (per-mode), or validator.constraints (per-validator).
Shipped predicates¶
Three predicates ship for reference (snodo.predicates):
files_in_scope— verifies all modified files match configured scope paths; a task scoped to a module (ADR 041) is instead bounded by that module's declared pathstests_exist_for_modified— requires test files for each modified implementation fileno_secrets_in_diff— scans git diff for credential patterns
ADR 004 for the design rationale.
3. Coder adapters¶
Interface¶
Implement CoderAdapter (snodo.coders.base), the adapter-facing alias for the core Coder interface (snodo.core.interfaces). Most adapters subclass CoderAdapter; adapters that execute a host CLI should normally build on SubprocessCoderAdapter, and adapters writing directly into the working tree use InPlaceCoderAdapter. See the coder adapter contract and docs/coders.md for shared obligations and a host-CLI example.
from snodo.coders.base import CoderAdapter
from snodo.core.interfaces import TaskSpec, CodeArtifact
class MyCoder(CoderAdapter):
def implement(self, spec: TaskSpec) -> CodeArtifact:
# spec.description — the task description
# spec.constraints — declared constraints
# spec.memory_summary — agent memory context
# spec.project_context — project-level metadata
...
return CodeArtifact(files=[...])
implement() is the only abstract method, but it is not the whole contract. Coder also declares several optional capabilities as class attributes with defaults, and the engine sets these on every adapter instance unconditionally — never behind a hasattr guard — so that an adapter's non-support of a capability is a visible default rather than a silently skipped line:
| Attribute | What the engine gives you | Default if you don't override it |
|---|---|---|
workspace_mcp |
The workspace the task runs under, when the task runs under one | None — no workspace access |
progress_callback |
A sink to emit per-turn progress to | None — the adapter reports no progress |
_job_id, _task_id |
Correlation ids for adapter-side logging/telemetry | "" — unset |
skip_workspace_write |
Set True if your adapter writes the working tree itself; the executor then does not replay the returned artifacts through WorkspaceMCP |
False — the executor writes the returned artifacts |
skip_engine_commit |
Set True if your adapter (or its base class) owns the commit; the executor then does not stage or commit |
False — the executor commits |
A coder that overrides none of these is a complete, valid adapter: it gets no workspace, reports no progress, and has the executor write and commit its CodeArtifact on its behalf. That's a legitimate choice, but make it deliberately — the way to find out otherwise is a task that reports no progress or ignores a workspace it needed.
Opting into skip_workspace_write or skip_engine_commit does not waive the underlying obligation: an adapter that owns its own commit still has to leave the workspace in a state where what changed is observable and attributable. "The coder produced nothing" is a fault regardless of who commits.
A CodeArtifact is a list of FileArtifact objects (path, content, action="write"|"delete"). LiteLLMAdapter (routes to 100+ LLM backends via litellm) and MockAdapter (deterministic stub for testing) are two of the adapters that ship with snodo.
Wiring¶
Coder names resolve through CODER_REGISTRY in snodo.coders. An installable adapter package registers its class in the snodo.coders entry-point group; the entry-point name is the coder name used by the protocol:
[project.entry-points."snodo.coders"]
my_coder = "my_package.coder:MyCoder"
The entry point must load a CoderAdapter subclass. Snodo discovers installed adapters automatically, and snodo ready reports loaded plugins and failures. Built-in coder names are reserved.
modes:
- mode_id: "producer"
coder: "litellm"
coder_config:
model: "claude-sonnet-4-20250514"
temperature: 0.7
The engine resolves coder to an adapter class at graph build time. The --mock CLI flag overrides to MockAdapter. For the in-repository adapter registration pattern and conformance expectations, see docs/coders.md.
ADR 007 for the design rationale.
4. Validator tools¶
Interface¶
Validator tools let an external decision model answer yes/no questions over a large input while the LLM keeps writing the verdict.
Expose a ValidatorTool from snodo.validators.tool_registry. Its definition is the model-facing function definition, and its handler receives parsed arguments and a ValidatorToolContext, then returns text for the model:
from snodo.validators.tool_registry import ValidatorTool, ValidatorToolContext
tool = ValidatorTool(
definition={
"type": "function",
"function": {
"name": "count_diff_lines",
"description": "Count diff lines in a path glob.",
"parameters": {
"type": "object",
"properties": {"path_glob": {"type": "string"}},
"required": ["path_glob"],
},
},
},
handler=handle_count,
)
def handle_count(arguments: dict, context: ValidatorToolContext) -> str:
# Read the diff and context.workspace_root as needed; return text.
...
ValidatorToolContext provides workspace_root, spec_text, validator_id, configured tool_options, and optional diff_refs and diff_accessor. Treat workspace inputs as read-only; handlers must not modify the workspace or .snodo/. Built-in validator tool names are reserved. See the diff-line counter example for a complete local implementation.
Registration¶
Register a ValidatorTool instance or a zero-argument factory returning one in the snodo.validator_tools entry-point group:
[project.entry-points."snodo.validator_tools"]
count_diff_lines = "snodo_diff_line_tool:tool"
Snodo discovers installed tools through entry points and indexes them by the function name in definition. The callable handler returns text that is supplied back to the model. Tool handlers should be read-only and complete within the validator's bounded execution time.
Wiring into the protocol¶
For an installable decision-model tool, see TypeSafe Jev decide
for installation, API key setup, per-validator configuration, and an
acceptance-check example.
Select the registered tool for an individual LLM validator with tools; per-tool options are passed through as context.tool_options:
validators:
- validator_id: "diff_review"
validator_type: "quality"
validator_tools:
- name: "count_diff_lines"
options:
max_lines: 2000
The LLM validator offers configured, discovered tools to the model. The model can call the handler and use its returned text while preparing its usual verdict. Install the example package in Snodo's Python environment and see its README for the registry test and a sample protocol entry.
5. Code-host providers¶
Interface¶
Implement the v1 CodeHostProvider contract (snodo/providers/base.py). Identifiers are opaque strings so they can represent pull requests, merge requests, or another host's change request. The discussion methods exchange JSON containing title, comments, and reviews; each entry has an author string and body string, and reviews may also have a state string.
Change requests opened by Snodo are labelled snodo when the code host permits it.
from snodo.providers.base import CodeHostProvider
class GitLabProvider(CodeHostProvider):
def __init__(self, project_root: str = "", metadata: dict | None = None):
...
def create_change_request(
self, branch: str, title: str, body: str,
target_branch: str | None = None,
) -> str: ...
def read_change_request_diff(self, change_request_id: str) -> str: ...
def post_change_request_comment(self, change_request_id: str, comment: str) -> str: ...
def approve_change_request(self, change_request_id: str) -> str: ...
def request_change_request_changes(self, change_request_id: str, reason: str) -> str: ...
def merge_change_request(self, change_request_id: str) -> str: ...
def read_change_request_discussion(self, change_request_id: str) -> str: ...
All seven methods must be implemented. Return strings (URLs, confirmations, or JSON payloads) and raise ProviderError for failures. Provider constructors may accept project_root and metadata keyword arguments; the registry also supports no-argument constructors. For remote auto-detection, implement claims_remote(cls, url): it receives the complete git origin remote URL as a string (SSH, HTTPS, or scp-style form), and should return whether this provider recognizes that URL. remote_hosts is supported as a compatibility fallback for older plugins when the hook does not claim the URL.
The old create_pr, read_pr_diff, post_review_comment, approve_pr, reject_pr, merge_pr, and read_pr_comments names are deprecated compatibility wrappers. New providers should implement only the neutral method names.
Registration¶
Register an installable provider with the setuptools entry-point group:
Setuptools entry point (recommended for installable plugins; register the provider class in the snodo.providers group):
# pyproject.toml
[project.entry-points."snodo.providers"]
gitlab = "my_package.gitlab:GitLabProvider"
The entry-point name is the provider name referenced by metadata.provider. The registry discovers installed plugins from the snodo.providers entry-point group; for remote matching, it calls each plugin's claims_remote(url) hook. Plugins that do not yet implement that hook may declare remote_hosts as a compatibility fallback. Entry points are not independently selected when no remote match exists. If multiple plugins claim the same URL, the registry selects the first matching entry point returned by Python's entry-point discovery; do not rely on an ordering among competing claims. If an explicitly named provider is unavailable, the error suggests installing snodo-provider-<name> with uv add or pip install.
Verify your plugin¶
After packaging and installing the distribution in the same environment as Snodo, run snodo ready in a project that uses it. The readiness report lists installed code-host plugins (and plugins that failed to load), then reports which provider the project resolves to and why. For remote auto-detection, use a project whose origin URL your claims_remote(url) recognizes; the report should say it was detected from that remote host. For explicit selection, set metadata.provider in the protocol and readiness reports that it was selected by metadata.provider. A plugin listed as installed confirms entry-point loading, while a failed status includes the load error; provider construction failures are also reported. If multiple plugins claim the remote, readiness reports the selected one. For the runnable validator check, follow Try it in a minute: snodo ready --json should list hello_acceptance under extensions → snodo.validators as installed.
Two working code-host plugins are available as examples to copy. Both implement this neutral contract and register in the snodo.providers entry-point group; plugins should ship their own top-level import package rather than adding modules beneath Snodo's snodo.providers package:
- GitHub:
snodo-provider-github(import packagesnodo_provider_github) is bundled with the Snodo distribution. It claims remotes ongithub.com. It readsGITHUB_TOKEN,metadata.github_token, or the configured GitHub key (snodo config set github <token>); repository identity can come frommetadata.github_repoor the remote. - GitLab:
snodo-provider-gitlab(import packagesnodo_provider_gitlab) is installed separately, for example withpip install snodo-provider-gitlab, or used as a workspace package during development. It claimsgitlab.comremotes and self-hosted GitLab remotes whenGITLAB_HOSTorGITLAB_URLis set. It readsGITLAB_TOKENormetadata.gitlab_token;metadata.gitlab_repoandmetadata.gitlab_urlcan configure the project and host.
Explicit provider selection is configured in protocol metadata (also useful for in-project providers):
# protocol.yml
metadata:
provider: "gitlab"
gitlab_repo: "my-org/my-project"
gitlab_token: "${GITLAB_TOKEN}"
Resolution order¶
metadata.providerif set (provider resolved by name, including installed entry points)- Auto-detect from git remote URL: installed entry points in the
snodo.providersgroup are checked usingclaims_remote(url);remote_hostsis used only when the hook does not claim the URL - Fallback to
LocalProviderwhen no provider matches (localis the only built-in)
With execution.delivery: change_request, Snodo pushes the task branch and then uses this same provider resolution to open a change request targeting the project's active branch. If no installed plugin claims the remote (and metadata.provider is not set), resolution falls back to local; change-request delivery then fails with a no-provider message. If the selected plugin cannot obtain its token or otherwise fails to construct or open the request, delivery reports the failure and leaves the pushed branch and task unmerged for manual resolution. See the execution.delivery reference for configuring delivery modes and their requirements.
Shipped providers¶
The local provider is built in (no remote; change-request operations raise ProviderError). GitHub (snodo-provider-github, module snodo_provider_github, backed by PyGithub) and GitLab (snodo-provider-gitlab, module snodo_provider_gitlab, backed by python-gitlab) are discovered as entry-point plugins; GitHub is bundled with Snodo while GitLab is installed separately.
ADR 007 for the design rationale.
Where extensions run in the loop¶
Every extension plugs into the orchestration graph at a specific point:
Governance → Validate → Execute → Post-validate → Move-next → Complete
│ │ │ │
│ validators coder predicates
│ (pre_execute) adapter validators
│ (post_execute)
predicates
(governance)
providers
(via PrMCP
during execute)
- Validators run in the
validatenode (pre_executephase) or thepost_validatenode (post_executephase). The engine builds a singleValidatorContextper pass and dispatches each validator spec through the registry. - Predicates run in the
governancenode (pre-execute constraints) orpost_validatenode (post-execute constraints). The engine builds aPredicateContextfromLoopStateand callsevaluate(context, **params). - Coders run in the
executenode. The engine passes aTaskSpecand receives aCodeArtifact— file operations are then applied via WorkspaceMCP and committed via GitMCP. - Providers are used by
PrMCPduring theexecutenode when change-request operations are requested, and bysnodo run --from-prto prepend discussion and diff context. The provider is resolved viadetect_provider().
All extensions are referenced from protocol.yml — no code changes needed in the engine.