Declarative provider bindings (post-v1 design)¶
Design doc, not landed. This describes the direction tracked in v1 PRD § 4.5 task 4 — a config-only way to add a new CLI provider binding without writing a Go file. v1 ships with three hand-written bindings (
claude,codex,gemini). Declarative bindings are planned for a later release.
Why declarative bindings¶
The current shape requires internal/provider/<name>.go + a factory
entry for every new provider. That’s fine while the list is three
and growing slowly, but it pushes every CLI-compatibility effort
through a code review cycle. Operators who want to try a new CLI —
Claude Desktop’s claude mcp sessions, a local llama-cli, a
corporate-internal chat tool — can’t do it without opening a PR here.
A declarative binding says: if your CLI speaks one of a fixed set of
I/O framings and takes its inputs through a parameterizable argv
template, you can register it in config.toml and be done.
Proposed config shape¶
[providers.my-cli]
# One of: stream-json | plain-stdout | last-message-file
type = "stream-json"
# Binary lookup: absolute path, or bare name resolved via $PATH.
bin = "my-cli"
# Argv template. Tokens in {braces} are substituted at runtime.
# Available tokens:
# {model} resolved Model string (or "" if none)
# {effort} resolved Effort string (or "")
# {prompt_file} path to a tempfile holding the combined prompt
# {schema_file} path to OutputSchema tempfile (or "" if none)
# {working_dir} Request.WorkingDir
# {allowed_tools} comma-joined list of AllowedTools
args = [
"chat",
"--stream",
"--model={model}",
"--system-from-file={prompt_file}",
"--cwd={working_dir}",
]
# For types that produce output on a file (last-message-file), where
# to read the final assistant message from.
output_file = "{working_dir}/.my-cli/last.txt"
# Timeouts and retries at the declarative layer. Omit to inherit
# from the global defaults in internal/provider.
turn_timeout = "10m"
max_retries = 2
# Session-resume: "stateful" means the runner threads SessionID
# through; "stateless" means every turn is a fresh call.
state_model = "stateless"
# Optional: a session-id extractor. For stateful bindings the runner
# needs to know how to pull the session id out of the output. A
# regex against the final frame / output is enough for most CLIs.
session_id_regex = "session=([a-z0-9-]+)"
The three supported framings¶
stream-json¶
The CLI writes newline-delimited JSON frames on stdout, each with a
type field (user, assistant, result, etc.). The runner parses
frames as they arrive and returns the last assistant.text.
This is the claude CLI framing and is the richest option — it lets the runner surface partial progress, tool-use intents, etc., to the supervisor event log.
plain-stdout¶
The CLI prints the assistant’s response on stdout, nothing else. If
there’s a prelude (warnings, progress), the runner extracts the
JSON block via {...} matching when OutputSchema is set, or
returns stdout verbatim.
This is the gemini shape.
last-message-file¶
The CLI writes its final response to a file. The runner reads that file after the process exits.
This is the codex shape with --output-last-message.
Validation at load time¶
On service start, the runtime expands every declarative binding
against a synthetic Request and validates:
binresolves on$PATH(fail loud if missing)argstemplate references only known tokens (no typos like{modl})typeis one of the three supported framingsoutput_filetemplate, if present, is syntactically validsession_id_regex, if present, compiles
Any failure here is a hard error with a pointer to the misbehaving
[providers.<name>] block. Validation runs before any worker spawn.
Migration path¶
v1 keeps the three hand-written bindings in internal/provider/.
When declarative bindings land:
Add a new
declarative.gorunner that implementsRunnerand reads its config fromBinding.Config.NewRunnerreturns the declarative runner whenBinding.Config.Typeis not one of the built-in type names.The three built-ins keep their hand-written runners (they handle edge cases — session resume for claude, schema-file for codex — that we don’t want to relitigate).
Docs shift
docs/design/provider-contract.md§ “Extension model” to point at declarative as the default path, with “contribute a Go binding” as the fallback for CLIs whose framing doesn’t match the three supported shapes.
Non-goals¶
Arbitrary Go callbacks — the declarative binding is config- driven; it does not evaluate user-supplied Go code or Lua/JS. Callers who need custom post-processing write a Go binding.
Multi-turn within one
Runner.Runcall — the runtime already handles multi-turn via the claim loop + session resume. Declarative bindings don’t try to batch multiple turns into one CLI invocation.Streaming back to the operator — the supervisor’s event log shows claim/start/finish, not assistant-token-level streaming. Streaming stays inside the runner.
Open questions¶
Do we want a
providers_pathconfig option pointing at~/.config/radioactive-ralph/providers.d/*.tomlso declarative bindings live outside the repo? Probably yes for user-scope defaults; deferred to the implementation PR.How do declarative bindings handle
AllowedTools? Most CLIs either always grant all tools or have a bespoke per-provider flag. First pass: ignoreAllowedToolsfor declarative bindings; revisit once a concrete third-party CLI needs it.Session-id extraction via regex is crude. A structured
session_id_json_path = "$.session.id"option would be more robust for stream-json framings. Consider for the first implementation pass.
Reference¶
Current contract:
provider-contract.mdGo interface:
internal/provider/provider.go::RunnerBuilt-in bindings:
internal/provider/{claude,codex,gemini}.gov1 PRD tracking:
docs/plans/2026-04-16-v1-remaining-work.prd.md§ 4.5 task 4