Lesson 4.1: Author a .NET protocol plugin
Difficulty: Intermediate | Duration: 15 min | Prerequisites: Unit 1 (CLI or Embedded track), .NET 10 SDK
Overview
Build your own protocol plugin from scratch, install it into Bowire, and watch a fresh workbench discover it on startup. By the end you'll have a (silly) Pirate Speak protocol in the sidebar that turns plain English into pirate-speak when invoked.
The protocol is deliberately self-contained — no external wire, no broker, no schema-discovery step. The point is the plugin contract: implement IBowireProtocol, ship the NuGet, get your code into Bowire's plugin pipeline. Replace the substitution-table body with HttpClient.SendAsync / MQTTnet.PublishAsync / GrpcChannel.ForAddress and you have a real protocol plugin.
Path-split is narrow. Authoring (scaffold, code, pack) is identical on both paths — the same NuGet works in both. Only the install + run steps differ: CLI uses
bowire plugin install, embedded uses aPackageReferencein your host project. Path-B insets show the diff inline at Steps 5 and 6.
Steps
1. Install the plugin scaffold template
dotnet new install Kuestenlogik.Bowire.Templates
You should see bowire-plugin listed under the installed templates.
2. Scaffold a fresh plugin
dotnet new bowire-plugin \
-n Bowire.Plugin.Pirate \
--ProtocolId pirate \
--DisplayName "Pirate Speak" \
--PluginClassName PirateProtocol \
--Author "Bowire Bootcamp"
cd Bowire.Plugin.Pirate
The template emits a self-contained .slnx with:
src/Bowire.Plugin.Pirate/Bowire.Plugin.Pirate.csproj— the plugin assembly.src/Bowire.Plugin.Pirate/PirateProtocol.cs— theIBowireProtocolstub returning aDemoService.Echomethod that parrots the request.tests/Bowire.Plugin.Pirate.Tests/— an xUnit test project asserting the discovered shape.Directory.Packages.props— pinned versions forKuestenlogik.Bowireand friends..github/workflows/ci.yml— GitHub Actions that builds + tests on every push.
Build it once to confirm the scaffold restores cleanly:
dotnet build
3. Make it do something pirate-ish
The scaffolded PirateProtocol.cs advertises a DemoService.Echo that echoes the request payload. Replace its contents with sample/PirateProtocol.cs from this lesson:
cp ../sample/PirateProtocol.cs src/Bowire.Plugin.Pirate/PirateProtocol.cs
The replacement:
- Renames the discovered service to BuccaneerService with one Translate method (
text → translated). - Replaces the parrot
InvokeAsyncbody with a tiny English-to-Pirate substitution table (the → th',you → ye, &c, plus a🏴☠️suffix). - Makes
InvokeStreamAsyncan empty stream (Pirate Speak is unary-only) andOpenChannelAsyncreturnnull(no duplex).
Rebuild — should be clean:
dotnet build
The scaffold's tests assert on
DemoService.Echoso they'll fail after this edit. Either delete the failing assertions or update them to check forBuccaneerService.Translate. Out of scope for this lesson.
4. Pack the plugin as a NuGet
dotnet pack -c Release -o nupkgs
nupkgs/Bowire.Plugin.Pirate.1.0.0.nupkg lands in the output directory.
5. Install into Bowire
Path A — CLI install
bowire plugin install Bowire.Plugin.Pirate --source ./nupkgs
Confirm it landed:
bowire plugin list --verbose
You'll see Bowire.Plugin.Pirate@1.0.0 [nuget: 1 files] next to the bundled plugins (REST, gRPC, &c).
Path B — PackageReference from the embedded host
In your embedded host project (HelloApi from Lesson 1.1 Path B, or any other ASP.NET host with AddBowire() + MapBowire()), reference the nupkg directly:
cd path/to/your/embedded-host
dotnet add package Bowire.Plugin.Pirate --source <abs-path>/Bowire.Plugin.Pirate/nupkgs
For production publish from your own internal NuGet feed (Azure DevOps Artifacts, GitHub Packages, MyGet, &c) — the --source flag accepts URLs. There's no bowire plugin install step in embedded mode — the package is just a normal transitive dependency of your host; the plugin host's AssemblyLoadContext picks it up at startup the same way as in CLI mode.
6. Run Bowire and invoke your protocol
Pirate Speak has no wire — the protocol implements its translation inside the plugin. But Bowire's discovery loop still needs a "URL" to associate the protocol with, so feed it any placeholder. Use http://pirate.local so it's obvious in the sidebar.
Path A — CLI
bowire --url http://pirate.local
The browser opens at http://localhost:5050/bowire and the sidebar now contains a Pirate Speak entry alongside REST / gRPC / &c:
🏴☠️ BuccaneerService (Pirate Speak)
└─ Translate (unary)
Click Translate. The form shows a free-form input pane (no field constraints because the scaffold passes Fields: []). Send a request body like:
{ "text": "the gold is yours, you scurvy dog" }
Click Invoke. The response pane shows your code's output:
{
"translated": "th' gold be yers, ye scurvy dog 🏴☠️"
}
That JSON came from PirateProtocol.InvokeAsync. Your plugin is live inside Bowire.
Path B — Embedded
Add the placeholder URL to the embedded host's config so the workbench discovers against it on startup. Two options:
# appsettings.json
"Bowire": { "ServerUrls": [ "http://pirate.local" ] }
# Or as a CLI flag when launching the host
dotnet run -- --Bowire:ServerUrls:0=http://pirate.local
Then open http://localhost:5001/bowire (or whichever port your host runs on). Same Pirate Speak sidebar entry, same form, same translated response — but everything lives in the same process as your other routes. No second bowire CLI needed.
In production: your real protocol plugin probably does have a wire — you'll pass the real URL (
mqtt://broker:1883,https://api.example.com, &c.) through the same config. The placeholder shape here only exists because Pirate Speak is a toy with no wire.
7. Uninstall when you're done
Path A — CLI
bowire plugin uninstall Bowire.Plugin.Pirate
bowire plugin list # Pirate Speak is gone
The bundled plugins are untouched — uninstall only removes the directory under ~/.bowire/plugins/.
Path B — Embedded
dotnet remove package Bowire.Plugin.Pirate from your host project, rebuild, and the next startup won't load the plugin. No bowire plugin uninstall equivalent — embedded plugins are just regular project dependencies, managed the same way as any other.
Key Takeaways
- One interface, four methods.
IBowireProtocol.{DiscoverAsync, InvokeAsync, InvokeStreamAsync, OpenChannelAsync}is the entire contract. Streaming and channel methods can returnyield break/nullif your protocol is unary-only — Bowire respects that and grays out the corresponding UI affordances. - NuGet is the install format on both paths.
dotnet packproduces one nupkg; CLI installs viabowire plugin install, embedded references it as aPackageReferencefrom the host project. Same scaffold, same authoring, two install mechanics. - Auto-discovery is "the assembly is loadable". CLI: drop the DLL into
~/.bowire/plugins/. Embedded: it's just a transitive dependency of your host. Either way Bowire scansIBowireProtocolimplementations at startup; no registration code, no extra manifest beyond the NuGet metadata. dotnet new bowire-pluginsaves the scaffolding cost. The template emits a buildable solution; you only fill in the protocol-specific body ofDiscoverAsync/InvokeAsync.
What's Next
You're ready to author the same protocol in Python — same contract, JSON-RPC over stdio, no .NET on the plugin side.
Continue: → Lesson 4.2: Python sidecar plugin
Reference
dotnet new bowire-plugintemplate repo — the scaffold's source, all parameters and presets.- Plugin system docs — install / list / update / uninstall semantics, the workbench's plugin-management panel.
- Plugin architecture — how Bowire's plugin AssemblyLoadContext isolation works, why your plugin can ship its own dependency versions without colliding with the host.