Write a Bowire protocol plugin in Python.
Bowire's first-party plugins are .NET assemblies, but any executable can be a plugin by speaking JSON-RPC 2.0 over its stdin/stdout — a sidecar. This SDK hides the wire: subclass BowirePlugin, implement the methods you need, call run(). Perfect when the best client for your protocol lives in the Python ecosystem (paho-mqtt, the SciPy/ML stack, vendor SDKs, …).
See the full wire spec: docs/architecture/sidecar-plugins.md.
pip install bowire-pluginfrom bowire_plugin import BowirePlugin, ServiceInfo, MethodInfo, InvokeResult, run
class MyPlugin(BowirePlugin):
id = "myproto" # must match sidecar.json's protocol.id
name = "MyProtocol"
icon_svg = "<svg/>" # optional
def discover(self, server_url, show_internal, settings):
return [ServiceInfo("Things", methods=[
MethodInfo("get", full_name="Things/get", method_type="Unary"),
])]
def invoke(self, server_url, service, method, json_messages, show_internal, metadata,
settings):
payload = json_messages[0] if json_messages else ""
return InvokeResult(response=f"got {payload}", status="OK")
def invoke_stream(self, server_url, service, method, json_messages, show_internal, metadata,
settings):
for i in range(5):
yield f'{{"tick":{i}}}' # each yield → a $/stream/data frame
if __name__ == "__main__":
run(MyPlugin())run() blocks, handling the contract (initialize / discover / invoke / invokeStream / ping / shutdown) until the host disconnects.
A plugin declares settings once; Bowire renders them in its Settings dialog,
under the plugin's own section (Bowire 2.8 or later). The values come back on
every call as the settings argument.
Declare them by overriding settings():
from bowire_plugin import PluginSetting, SettingOption
class MyPlugin(BowirePlugin):
def settings(self):
return [
PluginSetting("probeWindow", "Probe window", type="number", default_value=5,
description="Seconds to listen before answering discovery."),
PluginSetting("verbose", "Verbose", type="bool", default_value=False),
PluginSetting("mode", "Mode", type="select", default_value="fast", options=[
SettingOption("fast", "Fast"),
SettingOption("thorough", "Thorough"),
]),
]type is "bool" (the default), "string", "number" or "select". A
select needs options; declaring one without them raises ValueError.
Read them from the settings argument every call receives:
def discover(self, server_url, show_internal, settings):
window = settings.get_float("probeWindow") # 5.0 until somebody sets it
if settings.get_bool("verbose"):
...
thorough = settings.get_str("mode") == "thorough"What the accessors guarantee:
- Fresh per call. The host sends the current values with each request, so a changed setting applies from the next call — no restart. Read the argument; don't copy it into the plugin at startup.
- Typed, with the declared default.
get_str/get_bool/get_int/get_floatparse the value the host sent (always a string on the wire) and return thedefault_valueyou declared when the key is absent — the host only sends keys somebody actually set. Passdefault=to override it for one read. - A value that does not parse falls back to the default, as the host's own
.NET accessors do:
get_boolacceptstrue/falsein any case,get_intASCII digits with an optional sign,get_floata finite decimal with an optional exponent. A key with neither a usable value nor a default raisesKeyError. settingsis also a read-only mapping of exactly what the host sent:dict(settings),"verbose" in settings,settings.get("mode").
A user can call your plugin without discovery, from the workbench's freeform
form or Compose, and only your plugin knows what such a call needs. Declare it
in manual_entry(). The SDK sends it in the initialize reply, and the
workbench renders a Compose form from it and offers exactly these method types
in the freeform form (Bowire 2.11 or later).
from bowire_plugin import ManualCallKind, ManualEntryForm, ManualField
def manual_entry(self) -> ManualEntryForm:
return ManualEntryForm(
url_placeholder="mybroker://localhost:9000",
call_kinds=[
ManualCallKind("produce", "Unary", label="Produce", service="{topic}", method="produce"),
ManualCallKind("consume", "ServerStreaming", label="Consume", service="{topic}", method="consume"),
],
fields=[
ManualField("topic", label="Topic", placement="template", required=True),
ManualField("key", label="Key", call_kinds=["produce"]),
],
)- A call kind names how it runs:
Unaryisinvoke,ServerStreamingisinvoke_stream, andClientStreamingorDuplexopens a channel.serviceandmethodare templates, in which{key}is the value of the field with that key. - A field with
placement="template"only feeds the templates. The default,"metadata", goes into the call's metadata under itskey, which is where yourinvokereads it. - A form the host would refuse raises
ValueErrorwhere it is built, naming the problem. Examples are an unknown method type, a placeholder no field declares, and aselectwithout options.manual_entry_problems(form)runs the same check.
Put the script next to a sidecar.json manifest:
{
"packageId": "Acme.Bowire.Sidecar.MyProto",
"protocol": { "id": "myproto", "name": "MyProtocol" },
"executable": "python",
"args": ["my_plugin.py"],
"version": "1.0.0"
}The manifest names the interpreter and passes the script as its argument, so
the script needs no shebang and no executable bit. The host first looks for
executable in the plugin directory, then — for a bare name — on PATH
(Bowire 2.8 or later). The SDK has to be installed into the interpreter that
name resolves to.
Why python and not python3: no single name exists on every machine, and
python is the one that does wherever this SDK is installed in practice.
| Where the plugin runs | python |
python3 |
|---|---|---|
Windows, python.org installer or actions/setup-python |
yes | no — resolves to the Microsoft Store alias, which exits without running anything |
Any virtual environment (venv on PATH), Linux, macOS or Windows |
yes | Linux / macOS only — a Windows venv has no python3.exe |
| Linux / macOS system interpreter, no venv | only where the distribution ships it (python-is-python3) |
yes |
The last row is also the one where pip install bowire-plugin is refused by
default on current distributions (PEP 668), so a plugin there already needs a
venv. If you ship a plugin only for Linux or macOS system interpreters, name
python3 instead.
Zip the folder and install into Bowire:
bowire plugin install --file my-proto-sidecar.zip
bowire --url myproto://your-serverbowire plugin list tags it [sidecar: myproto].
The same BowirePlugin runs over either wire — like MCP's stdio +
streamable-HTTP. Default is stdio (the examples above). For a
long-running / remote service, serve it over HTTP instead:
from bowire_plugin import run_http
run_http(MyPlugin(), host="127.0.0.1", port=8770)run_http exposes one path: POST for JSON-RPC requests (the response
body is the reply), GET as the SSE stream that carries server
notifications ($/stream/data, …). Point the manifest at it with
transport: "http":
{
"packageId": "Acme.Bowire.Sidecar.MyProto",
"protocol": { "id": "myproto", "name": "MyProtocol" },
"transport": "http",
"url": "http://127.0.0.1:8770/"
}| stdio | http | |
|---|---|---|
| Lifecycle | Bowire spawns/kills the process | you run the service; Bowire is just a client |
| Best for | local plugin shipped as a zip | hosted / remote / shared by many hosts |
| Deps | none | none (stdlib http.server) |
An http sidecar outlives the hosts that use it, and serves several at once.
What keeps them apart is the session
(spec):
a host sends an id of its own as the Bowire-Session header on every POST
and on the SSE GET. The runtime handles all of it; a plugin sees no
session.
- A stream belongs to the session of the request that started it, and its notifications go to the SSE streams of that session only. One host never reads another host's frames.
- A session ends when its host sends
shutdown, or when its last SSE stream closes (a host that crashed or lost the network). The session's streams are stopped; every other session is served on. Ending a session twice is harmless. - A stopped stream ends where it next yields: the value is dropped and the
generator is closed, so cleanup in a
finallyor awitharound theyieldruns. A generator that is blocked waiting for its source is stopped once the source next produces something. - Requests without the header form one unnamed session, shared by every
host that sends none. Their notifications go to the SSE streams opened
without the header. A
shutdownwithout the header is answered and ends nothing. - The plugin's
shutdown()hook is not a session event. It runs once, when the server stops.
run_http stops on SIGTERM (what a container manager sends) or Ctrl-C, ends
the SSE streams hosts hold open, stops every stream, runs the plugin's
shutdown() hook, and returns 0. Pass port=0 and on_listening= to let
the OS pick a free port and learn which one it picked:
run_http(MyPlugin(), port=0, on_listening=lambda port: print(f"listening on {port}"))The echo example runs this way with python echo_plugin.py --http 127.0.0.1:0. scripts/e2e.py --transport http drives it through a real
host, and CI runs both transports.
| Override | When it's called | Return |
|---|---|---|
discover(server_url, show_internal, settings) |
discovery | list[ServiceInfo] (empty = decline the URL) |
invoke(server_url, service, method, json_messages, show_internal, metadata, settings) |
unary call | InvokeResult |
invoke_stream(...) |
server-streaming call | iterator of JSON strings (each → one frame) |
settings() |
once, for the initialize reply |
list[PluginSetting] |
manual_entry() |
for the initialize reply |
ManualEntryForm, or None for no form |
shutdown() |
once, when the sidecar stops: the host's shutdown or a closed stdin over stdio, the server stopping over http |
None (cleanup hook) |
Class attributes: id (required, matches the manifest), name, icon_svg.
Models — ServiceInfo, MethodInfo, MessageInfo, FieldInfo, InvokeResult, PluginSetting, SettingOption, ManualEntryForm, ManualCallKind, ManualField — are dataclasses that serialize to the camelCase shapes the host expects. Settings is the per-call argument described under Settings.
examples/echo is a runnable plugin (unary echo + a ticker stream) that declares and reads three settings: a reply prefix (string), a letterCase (select) and the ticker length ticks (number). Smoke-test it as a plain process:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python examples/echo/echo_plugin.pyOr install it into a real Bowire host, discover through it, and round-trip its settings through the workbench — what CI runs on Linux and Windows:
python scripts/e2e.py --bowire "$(command -v bowire)" --work "$(mktemp -d)"Pushing a tag vX.Y.Z that matches version in pyproject.toml publishes
bowire-plugin to PyPI (.github/workflows/release.yml). The workflow tests
that commit, builds the sdist and the wheel, and uploads them through PyPI
Trusted Publishing from the pypi environment, so no API token is stored. A
version already on PyPI is skipped. To publish an existing tag again, run the
workflow by hand with that tag.
Sidecars implement protocols, not host extensions (auth providers, UI widgets, mock emitters stay .NET). Full duplex channels (open_channel) are on the roadmap; today expose long-lived streams via invoke_stream.
Apache-2.0.