Sidecar Spec¶
steerable-sidecar is a portable Python executable that exposes the
runtime over JSON-RPC 2.0 framed on stdin/stdout. UI shells (Electron,
Tauri, native, …) spawn the sidecar as a subprocess, send method calls
on stdin, receive responses + notifications on stdout, and observe
log + ready markers on stderr.
Why JSON-RPC over stdio?¶
- No port allocation — works inside sandboxed app containers.
- No TLS dance — every byte stays inside the parent process.
- Native to subprocess supervision —
child.kill()is your DR plan.
Boot sequence¶
sequenceDiagram
autonumber
participant P as parent
participant S as sidecar<br/>(python -m steerable_sidecar)
P->>S: spawn(child, ['-m','steerable_sidecar'], stdio=pipe)
Note over S: bootstrap …
S-->>P: stderr: __SIDECAR_READY__:{"status":"ok",<br/>"version":"0.6.4","protocolVersion":"0.1.0", …}
S-->>P: stdout (no id):<br/>{"jsonrpc":"2.0","method":"lifecycle.ready","params":{…}}
Note over P: Now safe to send JSON-RPC frames.
The parent must wait for the __SIDECAR_READY__: marker on stderr
before sending its first frame. The sidecar also emits a
lifecycle.ready JSON-RPC notification on stdout immediately after —
parents that use a frame-based reader (rather than peeking stderr) can
key off that instead. Either way, your reader must distinguish
responses (carry an id) from notifications (no id).
Frame format¶
One JSON object per line, UTF-8, terminated by \n. No length-prefix.
Request¶
Successful response¶
{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"0.6.4","protocolVersion":"0.1.0","uptimeMs":1234,"pid":42,"pythonVersion":"3.12.6","platform":"darwin-arm64","loadedProviders":[],"loadedTools":0,"activeTraces":0,"checks":{}}}
Error response¶
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found","data":{"method":"foo"}}}
Notification (sidecar → parent, no id)¶
Method catalog (v0.6.x)¶
| Method | Direction | Result |
|---|---|---|
system.ping |
request | SidecarHealth |
system.shutdown |
request | null (graceful drain, then exit) |
system.shutdown_now |
request | null (immediate exit) |
agent.session.create |
request | AgentSession |
agent.session.resume |
request | AgentSession |
agent.session.list |
request | AgentSession[] |
agent.session.fork |
request | BranchPoint (fork a record, no turn run) |
agent.session.branches |
request | {lineage, children} (branch-family view) |
agent.session.tree |
request | {recordId, tree, nodeCount, truncated} (full branch family from the root) |
agent.chat.stream |
request | {"streamId": "s_…"} |
agent.chat.cancel |
request | null (cooperative cancel) |
agent.chat.steer |
request | {"accepted": bool} (mid-turn steer) |
agent.chat.compact |
request | {"ok": bool} (manual compact at next pre_step; CC /compact parity) |
agent.chat.fork |
request | fork the running turn's record |
tool.list |
request | ToolDescriptor[] |
tool.invoke |
request | ToolResult |
workspace.apply_edits |
request | {content, diff, applied, matches} (pure edit algorithm; host owns file I/O) |
skills.list |
request | {skills} (parse + select SKILL.md from host roots) |
trace.fetch |
request | {"trace": HarnessTrace, "spans": TraceSpan[], "events": TraceEvent[]} |
trace.export |
request | {status, traceId, privacyMode} (OTLP/HTTP push) |
config.get |
request | Record<string, unknown> |
config.set |
request | null |
models.list |
request | {models, catalogStatus, fetchedAt?, current?, error?} (live GET /models discovery against the configured gateway) |
agent.session.messages |
request | projected transcript of a history record |
catalog.describe |
request | {providers} (bundled serving-provider catalog: default URLs, wire kinds, chat-capable model ids) |
presets.describe |
request | {presets} (per-vendor sampling-optima table for host settings UIs) |
presets.resolve |
request | {preset} the auto-matched preset for a (baseUrl, model) pair, or null |
compat.describe |
request | {flags} (provider compat-flag wire vocabulary) |
sandbox.describe |
request | per-exec sandbox enforcement the host can reach, probed with the turn's network / allowedHosts |
harness.describe |
request | harness vocabulary + the active default selection |
plugin.list |
request | {plugins} (registered plugins with their tools and state) |
plugin.enable |
request | {plugin} |
plugin.disable |
request | {plugin} |
plugin.reload |
request | {plugin} (re-executes the module in place, swapping its tool registrations without a restart) |
config.get with {"merged": true} previews the layered user config — default → ~/.steerable/config.json → selected profile → STEERABLE_* env → per-request RPC override → managed file — reporting each key's resolved value and the layer it came from (the --dump-config counterpart). A malformed user file fails loud. The defaults dict doubles as the schema: a value whose type doesn't match the declared default fails the load naming the key, the layer, and the expected type (env strings coerce). The user file may carry named profiles blocks, selected by STEERABLE_PROFILE; an unknown profile name fails loud listing the available ones. STEERABLE_MANAGED_CONFIG_PATH points at an enterprise-managed file applied after every other layer, so its pins (e.g. a restrictive sandbox posture) cannot be loosened from below — CC managed-settings parity.
Notifications emitted by the sidecar:
| Notification | When | Params |
|---|---|---|
lifecycle.ready |
After boot, before accepting requests | {version, protocolVersion, pid, listenInfo} |
lifecycle.shutdown |
Just before the process exits | {reason} ("normal" \| "eof") |
stream.chunk |
LLM token / tool-call / usage during a stream | {streamId, delta?, toolCall?, usage?, finishReason?, rawChunk?} |
stream.done |
Stream terminated cleanly | {streamId, ok, cancelled?} |
stream.error |
Stream failed (provider error, etc.) | {streamId, kind, message} |
agent.child |
Child-agent lifecycle (spawned/completed/failed/cancelled) — from the orchestration pool and from delegate_subagent delegations |
{streamId, kind, childId, depth?, status?, profile?} |
agent.chat.stream payload¶
{
"jsonrpc":"2.0", "id":7, "method":"agent.chat.stream",
"params": {
"provider":"openai_compat",
"model":"gpt-4o-mini",
"baseUrl":"https://api.openai.com/v1",
"apiKey":"sk-…",
"temperature":0.7,
"messages":[{"role":"user","content":"Say hi"}],
"tools":[{"type":"function","function":{"name":"echo","parameters":{}}}]
}
}
The sidecar replies with {"streamId":"s_42"} immediately, then pushes
stream.chunk notifications until stream.done.
CoreLoop streams accept contentMode: "all" | "final" (default "all").
"all" streams assistant text from every model request. "final" buffers
display text until each request's outcome is known, discards tool-round
narration and rejected retry drafts, and emits only the terminal tool-free
response. Tool progress notifications, the durable record, and the trace are
unchanged. "final" requires useCoreLoop: true.
streamRawChunks: true (CoreLoop-only, default off) forwards every raw
provider chunk the loop's on_stream_chunk hook observes — before UI-tag
stripping and surrogate splitting turn it into display text — as
stream.chunk notifications carrying rawChunk:
{contentDelta?, reasoningDelta?, toolCallDelta?: {id, name, arguments}, finishReason?},
unset fields omitted. This is the input for hosts running incremental
renderers (e.g. a streaming UI-tag parser); the digested delta /
reasoningDelta fields on the same channel stay post-stripping display
text. Emission is fire-and-forget: chunk order is preserved, ordering
against the digested notifications is not. Note the OpenAI-compat provider
buffers tool-call argument fragments into one complete ToolCall, so
toolCallDelta arrives whole — only content/reasoning are incremental.
resume: true (CoreLoop-only) continues the durable record's interrupted
turn instead of opening a new one: the record's projected transcript —
dangling tool_calls closed with a synthetic "interrupted" tool message —
becomes the loop seed verbatim, so the host neither re-sends the last user
message nor injects a synthetic continuation prompt. messages must be
empty and recordId (or chatId) must name a non-empty record; both
violations fail with invalid_params. A host systemPrompt on the same
request replaces the record's leading system message, so a refreshed
prompt takes effect on the resumed turn.
CoreLoop tunables accepted in params (all optional): maxRounds,
maxToolErrors, budgetTokens, softTimeoutMs, toolTimeoutMs.
toolTimeoutMs is the per-tool-execution backstop: a tool that produces
no result within the budget is cancelled and returns a failed
ToolResult (error: "tool_timeout") instead of hanging the turn — the
consecutive-error breaker treats it like any other tool failure. It
applies to every executor, in-process or remote (reverse channel, future
MCP). Default 300000 (5 min); the default is a hung-tool backstop, not a
budget — set a tighter value for fast tools.
budgetTokens is the run's cumulative token cap; exceeding it terminates
the turn budget_exhausted. Unset, it defaults to
max_rounds × window × cached_token_weight, floored at 2 × window.
The scaling matters because the cap is cumulative while a context window
is a per-request size: an agentic turn re-sends its prompt every round, so
even a fully cache-hit window bills cached_token_weight × window per
round, and a flat multiple of the window silently caps the round count
well below maxRounds. Scaling with the round budget keeps maxRounds
the primary runaway guard and leaves the token axis a backstop for rounds
that cost more than a cached full window; the floor keeps short-round
configs (subagent profiles) at the production-calibrated 2 × window.
OpenAI-compatible vendor divergences are data, not provider branches
(steerable_agent_runtime.llm.compat). An optional compat object in
params overrides request/response handling for the OpenAI-compatible
path; keys are camelCase (supportsUsageInStreaming, maxTokensField,
supportsReasoningEffort, supportsTemperature, reasoningDeltaFields,
reasoningEchoField, supportsForcedToolChoice,
echoEmptyReasoningForToolCalls, cachedTokensFields) and unknown keys
are rejected; compat.describe serves the live vocabulary to host
settings UIs. Without compat,
the sidecar auto-detects known vendors from the baseUrl host
(PROVIDER_COMPAT_HOSTS); anything unmatched runs on reference OpenAI
behavior.
Optimal generation parameters are data too
(steerable_agent_runtime.llm.presets): a (host, modelPrefix) table of
vendor-documented sampling optima for the open-weight families (DeepSeek,
Qwen3, GLM, Llama, gpt-oss, MiniMax) fills any request field the caller
left unset — explicit per-request fields, host extra kwargs, and
default_temperature always win, and compat flags still gate what may be
sent at all (Moonshot's fixed-temperature models carry no entry for that
reason). Entries key on the model leaf when the family travels across
gateways and on the base-URL host when one vendor serves model classes
with divergent optima (DeepSeek chat vs. reasoner).
STEERABLE_PROVIDER_PRESETS=0 disables the layer.
Hosts steer the layer per stream with the presets chat param: omitted or
{"enabled": true} auto-matches the registry, {"enabled": false} turns
it off, and {"override": {...}} pins an explicit preset (camelCase keys
parsed fail-loud by ProviderPreset.from_dict). Two RPCs serve host
settings UIs, symmetric with compat.describe: presets.describe returns
the registry table for a preset picker, and presets.resolve answers the
preset a given (baseUrl, model) pair would auto-match (null when none)
so the UI can preview what applies without reimplementing the matching
rules. OpenAICompatProvider(preset=...) accepts the same three states
("auto" / "off" / a ProviderPreset) for in-process embedders.
sandbox.describe answers what per-exec enforcement this host can reach,
so a host chooses an execSandbox posture it can satisfy instead of
guessing from its own platform check. Params are the egress half of
execSandbox (network, allowedHosts, shell); the reply is the
{backend, enforcement} pair a turn sent with those arguments would carry
on every data["_sandbox"] marker. writableRoots is not consulted —
every backend derives enforcement from egress alone, so the answer is
exact and probing cannot fail on a root that does not exist yet. The
motivating case is requireFull: only Seatbelt reaches full with egress
open (it pins per host), so a host that sets requireFull from a platform
guess has every shell call refused on Linux and Windows.
models.list serves the gateway's live model catalog to host model
pickers. The sidecar fetches GET {baseUrl}/models (params or
STEERABLE_BASE_URL/STEERABLE_API_KEY env) and tolerates both listing
shapes in the wild (OpenAI-style data array, models.dev-style models
map) and their field variants for window / max-output / pricing. Each id
is joined with the bundled models.dev catalog by exact cross-provider
final-segment match (endpoint modifiers like OpenRouter's :free /
:batch suffixes are stripped first — they change pricing, never
capabilities), and capability fields come from resolve_model_info
itself — the same resolution the request path's
clamp_reasoning_effort uses, hand-owned legacy union included — so the
picker and the wire never disagree
about a model's reasoning knob. A successful fetch is installed into
the runtime's resolution path, so window budgeting sees gateway-advertised
windows. The catalog is discovery, not a routing whitelist: a host may
send an unlisted id, and a listed id with no catalog match is returned
with capabilities: "unknown" rather than dropped. catalogStatus is
live (just fetched), stale (refresh failed, previous listing served
from the 60s TTL cache), or offline (no listing at all; a 200 with an
error string, not an RPC error — an unreachable gateway must not break
the settings screen).
Reasoning effort is strict on the request path. agent.chat.stream
accepts reasoningEffort (per-request, wins over
STEERABLE_REASONING_EFFORT and the preset default); any explicit request
is validated against the resolved catalog entry at provider construction,
and an unsupportable level — unknown model, no reasoning knob, unsupported
or misspelled level — fails as an invalid_params RPC error instead of
being silently dropped from the wire body (EVALS 2.5.22). A vendor whose
compat flags reject the field outright (Moonshot thinking) still drops it
silently — the gate runs before validation.
agent.chat.cancel on a CoreLoop stream is cooperative: the loop winds
down at the next safe point (round boundary, stream chunk, or tool-call
slot), records the partial turn so the chat can continue, and the
terminal stream.done carries status: "cancelled" with
cancelled: true. A 5s watchdog hard-cancels the task only if the
wind-down wedges.
Sub-agent delegation is ON BY DEFAULT: the sidecar advertises
delegate_subagent and answers it with a bounded child CoreLoop running
on the agent pool — the model's single multi-agent surface. Pass
subagent: false to turn it off, or a dict to configure it:
{toolFilter?: string[], maxParallel?: int, profiles?: {name:
{toolFilter?, model?, maxRounds?, concurrent?, description?,
systemPrompt?}}}.
toolFilter narrows every child's tool domain (filtered-out calls fail
closed with tool_not_delegated); profiles adds named profiles the
schema advertises as a subagent_type enum — with each profile's
description listed in the tool description so the model picks a
profile by what it is for (CC Agent-tool parity), and a profile's
systemPrompt seeded as the child loop's first message (CC
.claude/agents body parity). Unknown names fail closed
listing the registered ones; a profile's model resolves through the
host's provider factory or fails closed. A concurrent: true profile
lets same-round delegations execute in parallel under the pool's
maxParallel budget — the overflow delegation fails closed with
orchestration_budget_exceeded. Children advertise the host tool surface
minus the delegation tool itself (depth-1 by construction), narrowed per
profile. Delegate children emit agent.child lifecycle notifications
like orchestration children; the child_spawned payload adds profile
(the resolved profile name, general-purpose when untyped).
Multi-agent orchestration (the explicit six-tool family) is opt-in via
orchestration: {enabled: true, maxDepth?, maxParallel?, childMaxRounds?} in params: the parent model drives
parallel child CoreLoops through six tools — agent_spawn (returns a
lineage id like 0.2, optional toolFilter narrows the child's tool
domain), agent_send (steers a running child; resumes a finished or
interrupted one as a follow-up turn seeded from its preserved record),
agent_wait (timeoutMs; a live child at timeout returns status:
"running"), agent_close (terminal: cooperative cancel with a
hard-cancel backstop, rejects further sends), agent_list (pool
snapshot with per-child status), and agent_interrupt (cooperative
cancel that keeps the child addressable for a later agent_send). Budgets
fail closed: spawning at the parallel cap returns
orchestration_budget_exceeded, and depth is structural — a child only
has orchestration tools when maxDepth allows its own pool. Child
lifecycle lands as agent.child notifications; every spawn/wait result
carries the child id as structured JSON, so the delegation is
reconstructable from the session record alone. Children still running
when the parent ends are wound down cooperatively. When both surfaces
are on, delegation and the six-tool family share ONE pool — one
maxParallel budget and one lineage space, and delegate children appear
in agent_list.
Per-turn MCP servers mount via mcp: [{name?, command, args?, env?}] in
params (CoreLoop, sidecar-local path only). Each entry spawns one stdio
MCP server subprocess; its tools are registered on the turn's router under
the mcp__<name>__<tool> prefix and are callable by the model like any
local tool. Every entry needs a non-empty command — a malformed entry
fails the request with invalid_params before any provider call. Clients
are closed when the stream ends (completion, error, or cancel), so no
server subprocess outlives its turn. The param is ignored under
toolsViaHost (the host owns tool execution there) and when an embedder
replaces the harness via a hooks factory. This mirrors the ACP adapter's
mcpServers wiring; HTTP/SSE MCP transports remain an honest gap.
agent.session.tree payload¶
Returns the full branch family containing recordId in one call — the
view a host needs to render a full-tree branch switcher and to validate
activating ANY family member (cousins included), where
agent.session.branches only sees the lineage plus direct children:
{
"recordId": "chat_1:r2",
"nodeCount": 4,
"truncated": false,
"tree": {
"recordId": "chat_1", "sourceRecordId": null, "sourceUntilSeq": null,
"label": "root", "depth": 0,
"children": [
{
"recordId": "chat_1:r2", "sourceRecordId": "chat_1", "sourceUntilSeq": 5,
"label": "question 1", "depth": 1, "children": []
}
]
}
}
tree is the family root (found by walking seed provenance up from
recordId); each node carries its fork provenance, the derived label,
and a depth that matches lineage numbering. Expansion is bounded —
depth ≤ 32 (the lineage corruption bound) and ≤ 500 nodes; a family cut
by either bound returns truncated: true with the partial tree. An
unknown record fails with invalid_request, as does provenance
corruption (a lineage cycle).
Health snapshot¶
{
"status": "ok",
"version": "0.6.4",
"protocolVersion": "0.1.0",
"uptimeMs": 12345,
"pid": 42,
"pythonVersion": "3.12.6",
"platform": "darwin-arm64",
"loadedProviders": [],
"loadedTools": 0,
"activeTraces": 0,
"checks": {}
}
Error codes¶
The sidecar reuses standard JSON-RPC error codes (-32700 parse error,
-32600 invalid request, -32601 method not found, -32602 invalid
params, -32603 internal error) plus framework-specific:
| Code | Meaning |
|---|---|
-32001 |
BudgetExhaustedError |
-32002 |
PolicyDeniedError |
-32003 |
ToolDispatchError |
-32004 |
StorageError |
-32005 |
TransportError |
CLI flags¶
$ python -m steerable_sidecar --help
usage: steerable-sidecar [-h] [--log-level {DEBUG,INFO,WARNING,ERROR}] [--quiet-ready]
options:
-h, --help Show help and exit.
--log-level {DEBUG,INFO,WARNING,ERROR}
Sidecar log level (always logged on stderr).
--quiet-ready Skip the __SIDECAR_READY__ stderr marker.
(Useful for embedded supervisors that prefer to
key off the `lifecycle.ready` stdout notification.)
Implementation notes¶
- The sidecar is single-loop async; concurrent requests interleave on
the event loop but are ordered by their JSON-RPC
id. agent.chat.streamreturns immediately and continues to push notifications even if the parent processes them slowly. There's no back-pressure on the wire — assume your parent can drain stdout.system.shutdowntriggers a graceful drain (in-flight streams cancel, pending tool dispatches abort) before returningnulland exiting.system.shutdown_nowskips the drain.- Parent processes should also send
SIGTERMas a backstop in casesystem.shutdownhangs; the sidecar installs aSIGTERMhandler that forces an immediate exit. web_fetch(always) andweb_search(when a search backend is configured) are registered on the RPC router at boot (steerable_sidecar/web_tools.py; bounds and SSRF policy intools.md"Web tools"). Hosts learn availability fromtool.listrather than assuming it, and delegate execution withtool.invoke— the nested call (the sidecar's CoreLoop asking the host over reversetool.invoke, and the host forwarding back over forwardtool.invoke) is safe because requests interleave on the loop. A malformedSTEERABLE_WEB_*bound logs an error and leaves the web pair unregistered instead of failing boot.