rpi-plugin-sdk
The stable #[repr(C)] ABI contract for rpi plugins. Depend on this crate, and
nothing else, to build a Rust cdylib that extends the rpi agent with tools,
providers, slash commands, event handlers, renderers and discovered resources.
This crate has no dependency on the rpi runtime on purpose. A plugin must not link the host — the host links the plugin. Both sides share only the types defined here.
Install
[]
= "my-rpi-extension"
[]
= ["cdylib"]
[]
= "0.3"
= "1"
Minimal plugin
use ;
export_plugin!;
The macro emits the rpi_plugin_register symbol. The version lives inside the
PluginApi struct (abi_version, struct_size) rather than in the symbol name,
so the entrypoint signature does not change when the struct grows.
Safe plugin helpers
handles::ToolHandles<T> keeps tool state inside the plugin and returns opaque
numeric StepHandle tokens. get retains an Arc, lock serializes state
updates, and cancel sets a separate atomic flag without waiting for a poll.
remove cancels and retires the token; repeated removal and unknown handles are
no-ops. An in-flight callback keeps its state alive until it returns. Workers
can share the flag through insert_with_cancel.
event_handler! defines an event callback with a tag-checked EventView and
panic containment. Its accessors return owned strings, so handlers never read
the ABI union directly or retain host pointers. Event buffers are borrowed
during fan-out and must not be freed by handlers.
event_handler!
These helpers preserve the C ABI. They require an SDK release containing the helpers; packages using a local path dependency must publish that SDK before publishing dependent packages.
string_handler! adapts command, prompt and runtime-action callbacks to &str
inputs and a lifetime-bound callbacks::Output. Output::write handles null
slots and reclaims earlier writes without freeing host-owned input. The SDK's
callbacks::free_string is the producer-side ABI deallocator to register for
SDK-allocated outputs. execute_handler! adapts borrowed tool call IDs, and
take_step_result! copies and reclaims a plugin-owned result into the pointer-free
callbacks::Step enum. These are ABI boundaries: the host must still honor
buffer validity and producer allocation ownership.
The optional process feature provides process::ProcessTree for terminating
descendants and closing inherited pipes. Its Win32/libc calls and owned handles
stay in the SDK; packages can enable forbid(unsafe_code) while using the
platform-independent interface.
Registered TUI text components
Use RuntimeActionId::UiComponent (22). Each plugin owns its own component IDs.
Register once, then send content updates without repeating the layout:
Registration is idempotent and preserves existing content, visibility and layout.
update accepts text and optional title; layout fields are rejected. configure
changes layout while preserving text and visibility. Hidden components retain state.
Updating an unregistered ID returns an error. Plugin unload removes its components.
Use RuntimeActionId::SetStatus with key and value for legacy footer statuses;
the old panel payload has been removed.
Slots: footer.after, sidebar.left, sidebar.right, overlay.
Components sort by order (default 0), then plugin identity and ID.
Layout fields: anchor, offsetX, offsetY, width, maxHeight,
minScreenWidth, border. Anchors use the nine conventional positions from
top-left to bottom-right, with left-center and right-center.
Width is 4–240 columns, maxHeight is 1–80 rows. Content is limited to 64 lines,
2048 UTF-8 bytes per line and 256 bytes per title. Controls are stripped by the
renderer and Unicode display widths are respected. Each plugin may register 32
components. Invalid changes preserve the previous state.
Footer rows render below usage in both terminal modes. Sidebars reserve space in
fullscreen mode; native scrollback shows their content above the editor. Overlays
use the visible transcript viewport. Passive components do not change input focus.
Headless hosts store components without rendering them. Older hosts reject action 22;
plugins should handle that error. This API supports text components; interactive
questions continue to use UiDialog.
Why a hand-written C ABI
Both sides are Rust, but they are compiled separately and may use different
compiler versions and different versions of this crate. That rules out passing
any Rust type that is not #[repr(C)] or that has a Drop impl. The design
rules the SDK enforces:
- Every crossing type is
#[repr(C)], and enums in unions carry an explicit#[repr(u32)]so the discriminant width is pinned. - No
Droptype crosses. NoVec,String,serde_json::Value,Optionof those, orResult. Owned strings cross asStbString(pointer + length) together with an explicitfree_stringfunction the producer exports, so each allocation is freed exactly once, by the side that received it. - Structured data crosses as JSON in a
StbString.serde_jsonmust be configured consistently on host and plugin (preserve_order+arbitrary_precision), or integers beyondu64/i64lose precision and object key order can change. - Unions hold
Copypayloads only, so a variant read isunsafeand the caller discriminates on the tag. - Unwinding never crosses the boundary. Every call is
extern "C", and both sides contain panics withcatch_unwind; a panic is converted into a status code rather than aborting the process.
These are not stylistic preferences — violating any of them is undefined behaviour at the boundary.
Compatibility
The host loader only resolves rpi_plugin_register, exported with
export_plugin!. The host passes a PluginApi containing registration slots,
runtime_action and declare (priority and platforms).
The SDK checks abi_version against RPI_PLUGIN_ABI_VERSION_UNIFIED and
requires struct_size to cover the PluginApi expected by the plugin before
calling its registration body. A mismatch returns a nonzero status and the
host reports a load failure. Extensions using retired entrypoints must be
updated to export_plugin! and rebuilt with the current SDK.
Versioning
A plugin depends on rpi-plugin-sdk only, so its version is decoupled from the
rest of the family. ABI compatibility is checked through abi_version and
struct_size; each optional capability must also be checked before use.
Rebuild extensions when the SDK contract they require changes.
Examples and docs
examples/plugin-stub— a complete plugin registering anechotool with the full four-function lifecycle (execute→poll→cancel→destroy), an event handler, and a resource-discovery handler.- Extension authoring guide — templates, safety rules, testing and release checklist.
- Host-side loader:
rpi-extensions. - Ready-to-install extensions:
pi-rust/rpi-package.
License
Exclusive stdio servers
During registration, declare {"ownsStdio":true} requests exclusive ownership
of the process's stdin/stdout. Each plugin has its own runtime context; two
owners cause a startup error, including when their file stems are identical.
Failed registration releases its claim. The host leaves stdio untouched and
runs the normal session lifecycle without rendering a TUI.
Use RuntimeActionId::StdioLifecycle (21) with {"op":"status"} to check
support and with {"op":"finish","exitCode":0} to signal EOF or server
completion. Only the owner can finish, and early completion is retained. The
host dispatches SessionShutdown and returns the code. Stop and join plugin
workers in that handler; do not terminate the process from a plugin.
The optional stdio feature provides stdio::StdinLines::poll_line. It polls
pipe/file input without a background stdin task that can block shutdown.
Wait between InputLine::Pending results using the server's own cancellation
mechanism. The caller must exclusively own stdin. Windows console input is
unsupported by this pipe/file server helper.
Dialog purpose
UiDialog (17) keeps its open/poll/cancel protocol. A presentation kind such
as selector does not imply permission. Omitted ui.purpose means question;
purpose: "permission" requires a nonempty toolCallId, a flat single-choice
confirm/selector and explicit decision metadata on every option:
Decisions are allow_once, allow_always, reject_once, or reject_always.
Titles are nonempty, unique and have no surrounding whitespace. Permission
questions do not accept nested questions, multiple answers or freeform input.
dialogs::validate and dialogs::permission_options share these checks between
hosts and adapters. Answers retain the selected original title in values.
Transport adapters that cannot show ordinary questions cancel them explicitly;
they must not reinterpret them as permission choices.