Sidecar plugins (JSON-RPC over stdio)
Bowire's first-party protocol plugins are .NET assemblies implementing
IBowireProtocol.
That works fine when the best client for a protocol ships as a NuGet
package — gRPC, REST, GraphQL, NATS.Net, DotPulsar all qualify. It
falls apart when the canonical library lives in another ecosystem:
Zenoh (Rust), paho-mqtt + the SciPy/ML stack (Python), the Temporal
SDK (Go), socket.io-client (Node.js). Porting each library to .NET
isn't viable.
Sidecar plugins solve this. A sidecar is an executable in any
language that implements the
IBowireProtocol
contract by speaking JSON-RPC 2.0 over its own stdin/stdout. The
Bowire host spawns the process, talks to it via JSON-RPC, and exposes
the result in the workbench like any other protocol plugin.
The transport choice (JSON-RPC over stdio) matches what the Language
Server Protocol, Debug Adapter Protocol, and Model Context Protocol
all settled on independently. The framing is NDJSON: each JSON-RPC
envelope is a single UTF-8 line terminated by \n (same as MCP's
stdio transport). No Content-Length header, no chunking.
Layout on disk
Sidecars install into the same per-user plugin tree the .NET plugins use:
~/.bowire/plugins/<package-id>/
sidecar.json # manifest (this file is the marker)
bin/ # vendor's choice — could be anything:
my-sidecar # a native binary
my-sidecar.exe # …or a Windows binary
my-sidecar.py # …or a Python script
package.json # …or a Node.js entry point
The sidecar.json file at the directory root is what marks the
directory as a sidecar plugin. (It's deliberately not named
plugin.json — that filename is taken by the NuGet-install metadata
file the .NET plugin path writes, and the two plugin kinds share the
same ~/.bowire/plugins/ tree.) The Bowire host scans every
sidecar.json in the plugin tree at startup, registers one
SidecarBowireProtocol
per manifest, and proxies every call through to the process.
Installing
Ship the sidecar as a .zip containing sidecar.json at its root
plus the executable and any runtime files. Install it with the same
command the .NET plugins use — a .zip extension (or an oci://
reference) routes to the sidecar path:
bowire plugin install --file ./my-sidecar.zip # local
bowire plugin install --file https://example.com/x.zip # http(s) URL
bowire plugin install --file oci://ghcr.io/acme/zenoh-sidecar:1.0.0 # OCI registry
The archive unpacks into ~/.bowire/plugins/<packageId>/ (packageId
read from the manifest). On Unix the executable bit is restored after
extraction. bowire plugin list tags the result [sidecar: <id>] to
distinguish it from [nuget: N files] .NET plugins.
Pulling from an OCI registry
An oci://registry/repo:tag (or @sha256:<digest>) reference pulls the
sidecar zip straight from any OCI Distribution v2 registry — GHCR,
Docker Hub, Harbor, a local localhost:5000 registry, and so on. The
tool resolves the manifest, follows a multi-arch index to its first
entry if needed, picks the zip layer, and downloads the blob to a temp
file that flows through the same extract path as a .zip. Anonymous
pulls and the standard bearer-token dance (a 401 with a
WWW-Authenticate: Bearer realm=…,service=… challenge → token endpoint
→ retry) are handled automatically; pushing artifacts, credential
helpers, and signature verification are out of scope.
Publish a sidecar zip as a single-layer OCI artifact with
oras:
oras push ghcr.io/acme/zenoh-sidecar:1.0.0 \
./zenoh-sidecar.zip:application/zip
Manifest schema
{
"$schema": "https://bowire.io/schemas/sidecar.schema.json",
"packageId": "Acme.Bowire.Protocol.Zenoh",
"protocol": {
"id": "zenoh",
"name": "Zenoh",
"iconSvg": "<svg viewBox=\"0 0 24 24\">...</svg>"
},
"executable": "bin/bowire-zenoh-sidecar",
"args": ["--quiet"],
"envPrefix": "BOWIRE_ZENOH_",
"shutdownTimeoutMs": 3000
}
| Field | Required | Notes |
|---|---|---|
packageId |
yes | Reverse-DNS package id, surfaced in bowire plugin list. |
protocol.id |
yes | The id the workbench tabs against (e.g. zenoh); must match what initialize returns. |
protocol.name |
yes | Display name (e.g. Zenoh). |
protocol.iconSvg |
no | Inline SVG. Initial-handshake initialize response can override. |
transport |
no | "stdio" (default) or "http". Picks the wire — see below. |
executable |
stdio | A path relative to the plugin directory, or a bare command name looked up on PATH — see Resolving executable. Required for stdio. |
args |
no | Args appended to the executable command line (stdio). |
envPrefix |
no | Env-var prefix forwarded to the subprocess (stdio). Default: BOWIRE_. |
shutdownTimeoutMs |
no | Grace period after shutdown before SIGKILL (stdio). Default: 3000. |
url |
http | The JSON-RPC endpoint (e.g. http://localhost:7000/rpc). Required for http. |
version |
no | Version string for bowire plugin list (sidecars have no NuGet version). |
The manifest has a published JSON Schema at
https://bowire.io/schemas/sidecar.schema.json
(source: site/schemas/sidecar.schema.json). Point your editor at it via the
$schema key for completion + validation as you author sidecar.json. The
schema enforces the transport rule — executable is required for stdio,
url for http.
Resolving executable
A sidecar does not have to be a compiled binary the plugin ships. An interpreted one names its interpreter, and the arguments name the script:
{ "executable": "python3", "args": ["echo_plugin.py"] }
The host resolves executable in two steps, in this order:
- The plugin directory. An absolute path is taken as given. A relative path is resolved against the plugin directory, and wins whenever the file is there.
PATH. Only for a bare name — one containing neither/nor\. The name is handed to the OS unchanged, which searchesPATHand, on Windows, appliesPATHEXT(sopython3findspython3.exe).
The order is deliberate, and it is the security-relevant part: the plugin
directory is the trusted location, so a binary the plugin ships is never
shadowed by a program of the same name that happens to be on PATH. The
fallback is reached only when the plugin ships no such file.
Both separators count on both platforms — a manifest is written once and
installed everywhere, so bin\sidecar names a path on Linux too rather
than a command to look up. A path that resolves to nothing therefore
fails without consulting PATH, and the error says so.
The practical consequence: a sidecar written in Python, Node or Ruby
needs neither a shebang plus an executable bit (Unix only) nor a
run.sh wrapper (Unix only). It names its interpreter and runs
everywhere.
Transports
The same contract rides over two wires (like MCP's stdio + streamable-HTTP):
stdio(default) — Bowire spawnsexecutableas a local child process and speaks NDJSON JSON-RPC over its stdin/stdout. Zero-config, host-owned lifecycle, no network surface. Best for a plugin shipped as a zip and run on the same machine as the workbench.http— the sidecar is a (possibly remote) HTTP service aturl. Requests are JSON-RPC envelopes POSTed to the endpoint (the HTTP response body carries the JSON-RPC reply); server-initiated notifications ($/stream/data,$/channel/data, …) stream back over one long-lived SSEGETon the same endpoint. Bowire does not own the process — disposing just closes the SSE stream and sends a best-effortshutdownthe service may ignore. Suits hosted / multi-tenant deployments where one sidecar serves many hosts.
{ "packageId": "Acme.Bowire.Protocol.Remote",
"protocol": { "id": "remote", "name": "Remote" },
"transport": "http",
"url": "http://localhost:7000/rpc" }
The method surface below is identical on both transports.
JSON-RPC method surface
Every method on IBowireProtocol maps 1:1 onto a JSON-RPC method. The
mapping is intentionally mechanical so per-language SDKs can be thin.
initialize (host → sidecar, request)
Sent immediately after the process starts. Carries the host's version,
the sidecar wire-contract version the host speaks (protocolVersion),
and the declared protocol id (so the sidecar can verify). The sidecar
replies with its own protocolVersion, a capabilities object, and
name / id / iconSvg overrides.
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"hostVersion":"2.2.1",
"protocolVersion":1,
"expectedProtocolId":"zenoh"
}}
Reply:
{"jsonrpc":"2.0","id":1,"result":{
"name":"Zenoh",
"id":"zenoh",
"iconSvg":"<svg...>",
"protocolVersion":1,
"capabilities":{"discover":true,"invoke":true,"invokeStream":true,"channels":true},
"settings":[
{"key":"probeWindow","label":"Probe window","type":"number","defaultValue":5},
{"key":"verbose","label":"Verbose","description":"Log every frame","type":"bool","defaultValue":false}
]
}}
Protocol versioning (#416). The current sidecar contract is version
1. The host accepts a sidecar whose protocolVersion falls within the
host's supported range and rejects one outside it at the handshake
(with an actionable error) rather than failing at the first call. A legacy
sidecar that omits protocolVersion is tolerated as contract v1, with a
warning — update your SDK to advertise protocolVersion + capabilities.
Capabilities. Optional; every flag defaults to true when the object
is omitted, so a legacy sidecar behaves unchanged. Setting a flag to
false lets the host skip a call the sidecar doesn't implement — currently
channels:false makes openChannel return null without a round-trip.
Settings (#693). Optional. Each entry becomes a control in the
plugin's section of the Settings dialog, the same way a .NET plugin's
IBowireProtocol.Settings does. Fields:
| Field | Required | Notes |
|---|---|---|
key |
yes | Unique within the plugin. An entry without one is dropped — it has nowhere to be stored. |
label |
no | What the operator reads. Defaults to key. |
description |
no | Shown below the label. |
type |
no | "bool" (default), "string", "number", "select". |
defaultValue |
no | Any JSON value. |
options |
select | [{value, label}] — a select without them has nothing to pick from. |
labelKey, descriptionKey |
no | Catalogue keys (#691). The workbench prefers the key when its catalogue has an entry, and falls back to the text. A third-party plugin has no entry in Bowire's catalogue and sends neither. |
Fields the host does not know are ignored rather than rejected, so an SDK
can add one before a host reads it. That is how required behaves today:
the Node and Go SDKs send it, the Python SDK does not, and the host has no
such concept.
This is the only place settings are declared, and that is deliberate. The manifest could carry them too — it is read without starting anything — but then a plugin author would maintain the same list in two files and learn which one wins the first time they disagreed. One source means the host has to start the sidecar before it can answer the question.
Since the spawn is lazy, the answer is not available until something has
called the plugin. The host closes that gap through
IBowireDeferredSettings: a surface that is about to read the settings
(the Settings dialog's GET /api/protocols) asks every plugin to prepare
itself first. A .NET plugin does not implement that interface and is
untouched; a sidecar that will not start reports no settings rather than
failing the list, and the failure is logged.
How a value reaches the sidecar. A .NET plugin reads its own values
from IBowirePluginSettings, pulling when it needs one so that a change
arrives without a restart. A sidecar cannot reach that store, so the host
reads on its behalf and puts the result in a settings object on the
params of discover, invoke, invokeStream and openChannel:
{"jsonrpc":"2.0","id":7,"method":"invoke","params":{
"serverUrl":"zenoh://demo", "service":"Pub", "method":"Pub/put",
"jsonMessages":["{}"], "showInternalServices":false,
"settings":{"probeWindow":"7"}
}}
Read fresh per call rather than captured at spawn — that is what gives a
sidecar the same property the .NET seam has, a changed setting taking
effect without restarting the process. Values are strings, whatever the
declared type; the sidecar parses what it declared. Only keys that are
actually set travel: the sidecar declared the defaults and still knows
them, so an absent key means "use yours", and a sidecar whose settings
nobody touched sees no settings field at all.
Env vars are not the channel for this. envPrefix forwards the host's
environment to the subprocess, which is a different mechanism, and a value
placed in the environment is fixed for the life of the process — exactly
the restart-to-change behaviour the per-call form avoids.
A failed initialize (id mismatch, incompatible protocolVersion, missing
dependency, etc.) returns a JSON-RPC error; the host marks the plugin as
unhealthy and surfaces the error in bowire plugin list --verbose.
shutdown (host → sidecar, request)
Asks the sidecar to clean up + exit. The host waits up to
shutdownTimeoutMs for the process to exit on its own, then kills it.
ping (host → sidecar, request)
Liveness probe. Reply: {"result":"pong"}. Hosts may use this between
calls to detect a hung process before the next invocation timeouts
out.
discover (host → sidecar, request)
Maps to IBowireProtocol.DiscoverAsync. Params: {serverUrl, showInternalServices, settings}. Reply: an array of BowireServiceInfo shapes
serialized as the same JSON the .NET plugins emit.
invoke (host → sidecar, request)
Maps to IBowireProtocol.InvokeAsync. Params: {serverUrl, service, method, jsonMessages, showInternalServices, metadata, settings}. Reply: the
InvokeResult shape (response, durationMs, status, metadata).
invokeStream (host → sidecar, request + notifications)
Maps to IBowireProtocol.InvokeStreamAsync. The host generates the
streamId and passes it in the request params, then subscribes
before the request goes out — so a sidecar that starts pushing data
immediately can't beat the host to the subscription. (Earlier drafts
had the sidecar mint the id and return it in the ack; host-generated
ids removed the subscribe-after-ack race.) The sidecar acks the
request, then emits zero or more notifications:
{"jsonrpc":"2.0","method":"$/stream/data",
"params":{"streamId":"...","message":"..."}}
…until it sends a terminator notification:
{"jsonrpc":"2.0","method":"$/stream/end",
"params":{"streamId":"...","error":null}}
error is null on clean completion or a JSON-RPC error object on
failure.
openChannel / channel.send / channel.close / $/channel/data notifications
Map to IBowireProtocol.OpenChannelAsync + the IBowireChannel
surface — full duplex, both sides sending over a long-lived pipe.
Same host-generated-id convention as invokeStream: the host mints
the channelId, subscribes, then sends openChannel with it in the
params.
// host → sidecar: open
{"jsonrpc":"2.0","id":7,"method":"openChannel",
"params":{"channelId":"ab12","serverUrl":"...","service":"...","method":"...",
"showInternalServices":false,"metadata":{}}}
// host → sidecar: send a frame (repeatable)
{"jsonrpc":"2.0","id":8,"method":"channel.send",
"params":{"channelId":"ab12","message":"..."}}
// sidecar → host: inbound frame (zero or more, any time)
{"jsonrpc":"2.0","method":"$/channel/data",
"params":{"channelId":"ab12","message":"..."}}
// host → sidecar: close the send side
{"jsonrpc":"2.0","id":9,"method":"channel.close",
"params":{"channelId":"ab12"}}
// sidecar → host: channel fully closed (ends the read stream)
{"jsonrpc":"2.0","method":"$/channel/closed",
"params":{"channelId":"ab12"}}
channel.send returns a JSON-RPC result (any value) on accept or an
error object when the channel is gone — the host maps that onto
IBowireChannel.SendAsync returning true / false. The host's
SidecarChannel treats every sidecar channel as full duplex
(IsClientStreaming = IsServerStreaming = true), since the
protocols that need a channel at all — WebSocket, chat-style pub/sub —
are duplex by nature.
Lifecycle
- Spawn — Bowire resolves
executable(plugin directory first, thenPATHfor a bare name — see Resolvingexecutable) and launches it with stdin / stdout piped, stderr inherited (so sidecar logs land in the host's console). The env is the host's env filtered byenvPrefix. - Initialize — host sends
initialize; sidecar replies with metadata. The plugin shows up in the workbench only after this completes. - Steady state — host issues
discover/invoke/invokeStream/openChannelas the workbench drives them. - Shutdown — host sends
shutdownon graceful host exit; gives the sidecarshutdownTimeoutMsto terminate; SIGKILLs (orProcess.Kill()on Windows) on timeout. - Crash recovery — if the sidecar exits unexpectedly, the host
treats the next
invokeas a transport error (Status= the exit reason), and the next call respawns the process. No automatic exponential-backoff loop in Phase 1 — keeping the recovery model simple to debug.
Why JSON-RPC and not gRPC?
- Stdio works on every OS without sockets or port-allocation pain.
- Hand-rolling a JSON-RPC client takes ~50 LOC in any language.
- gRPC needs protoc, codegen, a runtime — not realistic for plugin authors who want to write a 200-line Python script.
- The LSP / DAP / MCP precedent shows JSON-RPC scales to thousands of messages per second in steady-state.
What sidecar plugins are NOT
- A way to extend the host. Sidecars implement protocols, not authentication providers, not UI extensions, not mock emitters. Those still ship as .NET assemblies (and likely always will — the surfaces are too .NET-shaped).
- A sandbox. The sidecar runs with the host's privileges. Treat installing a third-party sidecar with the same scrutiny as installing a third-party NuGet plugin.
- A streaming wire under the hood. JSON-RPC envelopes have non-zero serialization cost; if you need >10k msgs/sec sustained throughput, use a native .NET plugin.
Per-language SDKs
Hand-rolling the JSON-RPC loop is ~50 LOC, but an SDK turns a plugin into a five-method subclass. Available today:
- Python —
Kuestenlogik/Bowire.Sdk.Python(pip install bowire-plugin): subclassBowirePlugin, implementdiscover/invoke/invoke_stream, callrun(). Ships a runnableexamples/echoplugin +sidecar.json.
Node.js / Go / Rust SDKs mirror the same surface — see the ROADMAP.
See also
docs/protocols/custom.md— writing a custom .NET plugin (when you can stay in-ecosystem)- The Phase 2+ roadmap entry in
ROADMAP.md: remaining per-language SDKs + templates repo.