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:

  1. 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.
  2. PATH. Only for a bare name — one containing neither / nor \. The name is handed to the OS unchanged, which searches PATH and, on Windows, applies PATHEXT (so python3 finds python3.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 spawns executable as 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 at url. 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 SSE GET on the same endpoint. Bowire does not own the process — disposing just closes the SSE stream and sends a best-effort shutdown the 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

  1. Spawn — Bowire resolves executable (plugin directory first, then PATH for a bare name — see Resolving executable) 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 by envPrefix.
  2. Initialize — host sends initialize; sidecar replies with metadata. The plugin shows up in the workbench only after this completes.
  3. Steady state — host issues discover / invoke / invokeStream / openChannel as the workbench drives them.
  4. Shutdown — host sends shutdown on graceful host exit; gives the sidecar shutdownTimeoutMs to terminate; SIGKILLs (or Process.Kill() on Windows) on timeout.
  5. Crash recovery — if the sidecar exits unexpectedly, the host treats the next invoke as 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): subclass BowirePlugin, implement discover / invoke / invoke_stream, call run(). Ships a runnable examples/echo plugin + 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.