Plugin SDK

bmux Plugin SDK

Everything you need to write a bmux plugin.

Quick Start

A bmux plugin is a Rust crate with three files: a manifest, a library, and a Cargo.toml.

1. plugin.toml

id = "example.hello" name = "Hello Plugin" version = "0.1.0" [[commands]] name = "hello" summary = "Print a greeting" expose_in_cli = true

2. src/lib.rs

use bmux_plugin_sdk::prelude::*; #[derive(Default)] pub struct HelloPlugin; impl RustPlugin for HelloPlugin { fn run_command(&mut self, ctx: NativeCommandContext) -> Result<i32, PluginCommandError> { bmux_plugin_sdk::route_command!(ctx, { "hello" => { let name = ctx.arguments.first().map_or("world", String::as_str); println!("Hello, {name}!"); Ok(EXIT_OK) }, }) } } bmux_plugin_sdk::export_plugin!(HelloPlugin, include_str!("../plugin.toml"));

3. Cargo.toml

[package] name = "my_plugin" edition = "2024" version = "0.1.0" [lib] crate-type = ["cdylib", "rlib"] [dependencies] bmux_plugin_sdk = { version = "..." } [features] static-bundled = []
crate-type must include cdylib (for dynamic loading) and rlib (for static bundling into the host binary).

The RustPlugin Trait

Every plugin implements RustPlugin. All five methods have default implementations – override only what your plugin needs:
MethodReturn typeWhen to override
run_commandResult<i32, PluginCommandError>Plugin provides CLI commands
invoke_serviceServiceResponsePlugin provides services to other plugins
activateResult<i32, PluginCommandError>Plugin needs setup on activation
deactivateResult<i32, PluginCommandError>Plugin needs cleanup on deactivation
handle_eventResult<i32, PluginCommandError>Plugin subscribes to system events
The trait requires Default + Send + 'static. Use #[derive(Default)] on your struct.

Retained attach presentations

Attach presentations use the domain-neutral retained layout and surface APIs in bmux_plugin; the host does not call a plugin while painting a frame.
use bmux_plugin::layout::{ LayoutEdge, LayoutExtent, PluginLayoutId, PluginLayoutRequest, PluginLayoutSnapshot, global_plugin_layout_registry, }; use bmux_plugin::surface::{ PluginSurface, PluginSurfaceId, PluginSurfaceRegion, PluginSurfaceSnapshot, global_plugin_surface_registry, }; use bmux_plugin::{AttachInputEndpoint, ExtensionRect, RenderOp, RenderStyle}; use uuid::Uuid; let owner = "example.presentation"; let allocation = PluginLayoutId::new(owner, "leading"); let layout = global_plugin_layout_registry().publisher(owner); let surfaces = global_plugin_surface_registry().publisher(owner); layout.publish(PluginLayoutSnapshot { revision: 1, requests: vec![PluginLayoutRequest::split( allocation.clone(), 0, LayoutEdge::Left, LayoutExtent::Cells(20), )], })?; let endpoint = AttachInputEndpoint { capability: "example.presentation.input".into(), interface_id: "presentation-input".into(), operation: "handle".into(), }; let surface = PluginSurface::layout( PluginSurfaceId::new(owner, "main", Uuid::from_u128(1)), 1, allocation, vec![RenderOp::text_run(0, 0, "Item", RenderStyle::new())], ) .interactive_region( PluginSurfaceRegion::new("item-0", ExtensionRect::new(0, 0, 20, 1)) .endpoint(endpoint), ); surfaces.publish(PluginSurfaceSnapshot { revision: 1, surfaces: vec![surface], })?; # Ok::<(), Box<dyn std::error::Error>>(())
Keep both publishers alive while the presentation is active. Their Drop implementations remove all retained state for the owner, preventing stale layout, pixels, and input regions after unload. For updates, reuse the same layout, surface, retained, and region IDs; publish a complete snapshot with a strictly higher revision. Changing one retained operation lets the compositor damage only that operation’s bounds.
Input regions carry typed generic endpoints. The attach host supplies semantic pointer phases or focused key events with surface-local coordinates. The plugin interprets those events and invokes its domain’s generated BPDL client; do not put domain commands or handwritten transport envelopes into the generic presentation API.
Publications are validated and bounded. Handle publication errors rather than retrying in the frame loop. There is no synchronous render callback for these surfaces. The current global registries are in-process attach-host capability; out-of-process companions require a retained, revisioned publication transport before they can use this path.

Writing Command Plugins

Dispatching commands

Use route_command! to match on the command name and auto-generate the unknown-command fallback:
fn run_command(&mut self, ctx: NativeCommandContext) -> Result<i32, PluginCommandError> { bmux_plugin_sdk::route_command!(ctx, { "list" => handle_list(&ctx), "create" => handle_create(&ctx), }) }
Each arm must evaluate to Result<i32, PluginCommandError>. Unrecognised commands automatically return Err(PluginCommandError::unknown_command(...)).

Error handling

PluginCommandError implements From for common error types, so the ? operator works naturally:
fn handle_list(ctx: &NativeCommandContext) -> Result<i32, PluginCommandError> { let data = std::fs::read_to_string("config.toml")?; // io::Error -> PluginCommandError let parsed: Config = toml::from_str(&data)?; // toml::de::Error -> PluginCommandError println!("{parsed:?}"); Ok(EXIT_OK) }
Supported conversions: String, &str, std::io::Error, serde_json::Error, toml::de::Error, Box<dyn Error>, Box<dyn Error + Send + Sync>.
For custom error codes, construct directly:
Err(PluginCommandError::new(EXIT_USAGE, "missing required argument")) Err(PluginCommandError::unavailable("feature not supported on this platform"))

Exit codes

ConstantValueMeaning
EXIT_OK0Success
EXIT_ERROR1Generic failure
EXIT_USAGE64Bad arguments or unknown command
EXIT_UNAVAILABLE70Plugin unavailable

Arguments

Commands receive arguments as ctx.arguments: Vec<String>. The host CLI layer handles parsing and validation based on the argument declarations in plugin.toml – the plugin receives the parsed values as strings.

Writing Service Plugins

Service plugins handle inbound requests from other plugins or the host runtime.

Dispatching services

Use route_service! to match on (interface_id, operation) pairs. Each handler receives a typed request and returns a typed response:
fn invoke_service(&mut self, context: NativeServiceContext) -> ServiceResponse { bmux_plugin_sdk::route_service!(context, { "my-service/v1", "do_thing" => |req: DoThingRequest, _ctx| { let result = do_the_thing(&req.input)?; Ok(DoThingResponse { output: result }) }, }) }
The macro wraps each handler in handle_service(), which handles request deserialization, response serialization, and error conversion. Unrecognised operations return a standard “unsupported” error.
Request and response types must implement serde::Deserialize and serde::Serialize respectively.

Returning errors from service handlers

Service handler closures return Result<Resp, ServiceResponse>. Use map_err to convert domain errors:
"my-service/v1", "create" => |req: CreateRequest, _ctx| { create_thing(&req.name) .map_err(|e| ServiceResponse::error("create_failed", e.to_string())) },

The handle_service function

If you need more control than route_service! provides, use handle_service directly:
handle_service(&context, |req: MyRequest, ctx| { // Full access to ctx for host API calls, self for plugin state Ok(MyResponse { ... }) })

The Prelude

use bmux_plugin_sdk::prelude::* imports the ~16 items most plugins need:
  • Trait: RustPlugin
  • Context types: NativeCommandContext, NativeLifecycleContext, NativeServiceContext
  • Error type: PluginCommandError
  • Exit codes: EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_UNAVAILABLE
  • Service types: ServiceKind, ServiceResponse
  • Events: PluginEvent
  • Helpers: handle_service, decode_service_message, encode_service_message
Types not in the prelude (import individually when needed):
  • Host service DTOs: SessionSelector, StorageGetRequest, ContextCreateRequest, etc.
  • Manifest types: PluginCommand, PluginService, PluginEventSubscription
  • Capability types: HostScope, PluginFeature

When to Also Depend on bmux_plugin

The bmux_plugin crate provides two traits that live outside the SDK:
  • ServiceCaller – the low-level trait for dispatching cross-plugin service calls
  • HostRuntimeApi – ergonomic methods like ctx.session_list(), ctx.storage_get(...), ctx.context_create(...)
If your plugin calls host services (session management, storage, pane operations, etc.), add bmux_plugin as a dependency and import these traits:
use bmux_plugin::{HostRuntimeApi, ServiceCaller};
Simple plugins that only handle commands or receive service calls do not need bmux_plugin.

Manifest Reference (plugin.toml)

Top-Level Fields

FieldTypeRequiredDefaultDescription
idstringYesUnique plugin identifier (e.g. "bmux.clipboard")
namestringYesHuman-readable display name
versionstringYesPlugin version (semver)
descriptionstringNoOptional description
homepagestringNoOptional URL
runtimestringNo"native"Runtime type (only "native" supported)
entrypathNoPath to .dylib/.so (only for external plugins)
entry_symbolstringNo"bmux_plugin_entry_v1"FFI entry symbol
provider_priorityintegerNo0Ordering when multiple plugins provide the same capability
required_capabilitieslistNo[]Host capabilities this plugin needs
provided_capabilitieslistNo[]Capabilities this plugin provides
provided_featureslistNo[]Feature flags this plugin provides

Compatibility (optional)

[plugin_api] minimum = "1.0" # default; omit entire section if 1.0 is fine maximum = "2.0" # optional upper bound [native_abi] minimum = "1.0" # default; omit entire section if 1.0 is fine

Commands

[[commands]] name = "hello" # required -- dispatch name (matches ctx.command) summary = "Print a greeting" # required -- short description for help text expose_in_cli = true # default: false -- set true to show as CLI subcommand path = ["greet"] # optional -- CLI subcommand path aliases = [["say", "hi"]] # optional -- alternative CLI paths execution = "provider_exec" # default: "provider_exec" description = "Longer help" # optional
execution = "provider_exec" runs the command in the plugin provider process. Use execution = "caller_process" for commands that need caller-local runtime facilities, such as an attach client’s prompt/modal host.

Command Arguments

[[commands.arguments]] name = "target" # required kind = "string" # required: string | integer | boolean | path | choice required = true # default: false position = 0 # optional: positional index (omit for flags/options) long = "target" # optional: --target short = "t" # optional: -t multiple = true # default: false value_name = "TARGET" # optional: displayed in help choice_values = ["a", "b"] # for kind = "choice"

Services

[[services]] capability = "bmux.clipboard.write" # required -- host capability scope interface_id = "clipboard-write/v1" # required -- service interface identifier kind = "command" # required -- "command" or "query"

Event Subscriptions

[[event_subscriptions]] kinds = ["system", "window"] names = ["server_started", "window_created"]

Dependencies

[[dependencies]] plugin_id = "bmux.permissions" version_req = "=0.0.1-alpha.0" required = true # default: true

Keybindings

[keybindings.runtime] c = "plugin:bmux.windows:new-window" "alt+w" = "plugin:bmux.windows:switch-window" [keybindings.scroll] y = "copy_scrollback" [keybindings.global] # global keybindings (active outside runtime mode)