Declarative provider bindings
The shipped providers (claude, codex, opencode) use hand-written Go
runners for their edge cases, but a CLI that fits one of a fixed set of
I/O framings can be registered without a new
internal/provider/<name>.go file (internal/provider/declarative.go).
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's framing and the richest option.
plain-stdout
The CLI prints the assistant's response on stdout, nothing else. The runner extracts a JSON block via brace-matching when an output schema is set, or returns stdout verbatim.
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.
Binding shape
type BindingConfig struct {
Type string // stream-json | plain-stdout | last-message-file
Binary string // absolute path, or bare name resolved via $PATH
Args []string // argv template
OutputFile string // for last-message-file: where to read the result
SessionIDRegex string // optional session-id extractor for stateful CLIs
TurnTimeout string
StallTimeout string
MaxRetries int
}Argv template tokens available at dispatch time: {model}, {effort},
{prompt_file}, {schema_file}, {working_dir}, {allowed_tools}.
Validation
Before a declarative binding can be dispatched to, the runtime validates:
- the binary resolves on
$PATH(or the configured absolute path) argsreference only known tokensTypeis one of the three supported framingsSessionIDRegex, if present, compilesturn_timeoutandstall_timeout, when set, are positive duration strings within the runtime safety maxima
Any failure is a hard error naming the misbehaving binding, not a silent fallback.
Independent runtime bounds
All three framings use the same two-clock model as built-in providers.
turn_timeout bounds the complete turn and all retries together.
stall_timeout is renewed by every stdout or stderr read, including bytes in
an incomplete stream-json record. A process that remains silent fails with
the typed stall_timeout category; a productive process may continue past
that interval but still stops at the absolute turn deadline.
Cancellation signals the provider process group and waits boundedly for pipe drain. Partial output from a canceled, stalled, oversized, or failed attempt is never accepted as assistant evidence.
Non-goals
- Arbitrary Go callbacks. The declarative binding is config-driven; it does not evaluate user-supplied Go, Lua, or JS. Callers who need custom post-processing write a Go binding.
- Multi-turn batching within one
Runner.Runcall. Multi-turn already happens via the claim loop plus session resume. - Token-level streaming to the operator. The event log shows claim/start/finish, not assistant-token-level streaming.
Reference
- Current contract:
provider-contract.md - Go interface:
internal/provider/provider.go::Runner - Built-in bindings:
internal/provider/{claude,codex,opencode}.go - Implementation:
internal/provider/declarative.go
