hu — Hiroz Union¶
hu is the command-line companion to the hiroz stack. It replaces ros2 topic, ros2 node, ros2 service, ros2 action, and ros2 param with a daemon-free, plugin-based tool that works directly over Zenoh — no DDS, no Python, no background process. Subcommands like meter and monitor are WASM plugins; you can ship your own by dropping a .wasm file into ~/.local/share/hu/plugins/.
Terminology¶
A few terms recur throughout this page:
- Zenoh router — the process (
zenohd, orrmw_zenohdwhen bundled withrmw_zenoh_cpp) that letshuand ROS 2 nodes discover and reach each other;hualways connects to one, it never uses peer-to-peer discovery. - Domain ID — a numeric namespace (default
0) that partitions independent ROS 2 graphs sharing the same router. - Liveliness — Zenoh's mechanism for announcing and detecting when a node, topic, or service appears or disappears, which is how
hubuilds its live graph view without polling. - CDR — Common Data Representation, the binary wire format ROS 2 messages are serialized to;
hu meterdecodes and encodes it directly. - RMW — the ROS Middleware interface;
rmw_zenoh_cppis the RMW implementation that lets standard ROS 2 nodes talk over Zenoh, which is what makes them visible tohu.
Installation¶
Pre-built binary¶
Releases publish the hu binary and the reference plugins. See Installing hu for the installer, the offline path, and how to verify a download.
| Artifact | What it is |
|---|---|
hu-<ver>-x86_64-unknown-linux-gnu.tar.gz |
hu binary, Linux x86_64 |
hu-<ver>-aarch64-unknown-linux-gnu.tar.gz |
hu binary, Linux aarch64 |
hu-<ver>-aarch64-apple-darwin.tar.gz |
hu binary, macOS aarch64 — built by the release job; CI checks on every PR that it packages and reports its version, but no macOS release has been cut yet |
hu_meter-<ver>.wasm, hu_monitor-<ver>.wasm |
the reference plugins (wasm32-wasip2, platform-independent) |
hu-plugins-<ver>.tar.gz |
both plugins, for offline install |
hu-plugins-<ver>.json |
release index — what hu plugin install <name> resolves a name against |
install-hu.sh |
the installer itself, so the documented one-liner fetches it from the release it installs |
SHA256SUMS |
verify every download against this |
hu has no ROS 2 dependency — it works with any rmw_zenoh_cpp or hiroz deployment.
The plugins are separate on purpose. meter and monitor are WASM components loaded from the plugin path, not code inside the hu binary, so they are versioned and installed independently. Install both and hu plugin list shows them; skip them and hu meter / hu monitor do not exist, while the TUI, stream, router, web and plugin commands still work.
Build from source¶
Requires Rust 1.85+ and the wasm32-wasip2 target (used to build the plugins). With the Nix dev environment, enter the wasm-capable shell — it already has the target:
Without Nix, add the target to your toolchain:
1. Build the hu binary:
cargo build -p hiroz-union --release
# → ./target/release/hu (or install it: cargo install --path crates/hiroz-union)
./target/release/hu --help
2. Build the meter / monitor plugins. These are WASM plugins, not part of the hu binary, so hu meter … and hu monitor … do nothing until their .wasm files are on the plugin path. Build them with the cargo hu-plugins alias:
That's shorthand for cargo build --manifest-path crates/hiroz-union/plugins/Cargo.toml --target wasm32-wasip2 --workspace. The plugins are a separate wasm workspace, so you can also cd crates/hiroz-union/plugins && cargo build --release (the target defaults to wasm32-wasip2 there).
3. Put the plugins on the plugin path — either point HU_PLUGIN_PATH at the build output, or copy the .wasm files into ~/.local/share/hu/plugins/ (the always-searched dir). The hu_/hu- prefix is stripped on discovery, so hu_meter.wasm becomes meter:
export HU_PLUGIN_PATH=$PWD/crates/hiroz-union/plugins/target/wasm32-wasip2/release
# or, to install permanently:
# mkdir -p ~/.local/share/hu/plugins
# cp crates/hiroz-union/plugins/target/wasm32-wasip2/release/hu_{meter,monitor}.wasm ~/.local/share/hu/plugins/
hu plugin list # verify — should list `meter` and `monitor`
If hu plugin list is empty, hu meter/hu monitor won't work — the plugins aren't being found on the path.
Quick start¶
This walks through a real end-to-end session: a router, a talker/listener pair, and hu observing them. Run each step in its own terminal.
Prerequisites
This uses hu meter and hu monitor, which are plugins — make sure hu plugin list shows meter and monitor first. If it's empty, see Installing hu, or build them and set HU_PLUGIN_PATH as described under Build from Source. It also needs a source checkout: terminals 2 and 3 below use cargo run --example, and a downloaded hu cannot stand in for them — see below.
hu observes a deployment; it cannot create one. That is worth stating before you start, because it shapes what a plain download can do. hu meter pub encodes a message by resolving its schema from a .msg file on HIROZ_MSG_PATH, or by discovering the type from a node already on the topic. A release ships neither message definitions nor nodes, so on an empty graph it reports:
Subscribing does not help either: hu meter echo needs a schema too, though it gets one a different way — see the note on echo below. So a downloaded hu is for observing an existing ROS 2 or hiroz deployment, which is what it is for. To generate traffic as well, you need message definitions on HIROZ_MSG_PATH (a ROS 2 installation provides these) or a source checkout, which is what the walkthrough below assumes.
Terminal 1 — start the Zenoh router:
Terminal 2 — start a hiroz listener:
Terminal 3 — start a hiroz talker:
Both examples connect to tcp/127.0.0.1:7447 (never bare peer discovery — see examples/z_pubsub.rs), and publish/subscribe on /chatter.
Terminal 4 — observe with hu:
# List all topics
# repro-expect: /chatter
hu meter list topics
# /chatter (std_msgs/msg/String)
# Measure the talker's publish rate
# repro-expect: [0-9]+\.[0-9]+ Hz
hu meter hz /chatter
# /chatter: 1.000 Hz (1 samples)
# Watch the live graph
# repro: timeout-quiet 8
hu monitor watch
# node appeared: /talker
# node appeared: /listener
# topic appeared: /chatter
The same graph supports the other measurement subcommands. These need the Quick Start running, since they observe the talker's traffic:
# Bandwidth over a sampling window
# repro-expect: (?i)(B/s|KB/s|bandwidth)
hu meter bw /chatter
# Full introspection of one topic
# repro-expect: (?i)std_msgs.+String
hu meter info topic /chatter
echo and delay need a schema for the topic's type
hu meter echo and hu meter delay decode message content, so they need the type's schema. They resolve it in two steps: first by querying the publishing node's ~/get_type_description service, then — if that fails — by loading the type named in the publisher's liveliness token from a .msg on HIROZ_MSG_PATH. Discovery stays authoritative; the disk is only a fallback, the reverse of hu meter pub, which reads disk first.
Standard ROS 2 nodes expose the type description service; a hiroz node exposes it only when built with .with_type_description_service() — the publishing examples in this repo do. Residual limitation: if the publisher advertises no type at all, neither source can help, because hu has no way to be told the type — subscribe carries only a topic name and these commands have no --type flag. In that case both commands now report the failure and exit non-zero, rather than printing nothing and exiting 0.
hz, bw, list and info still work on a topic whose type cannot be resolved. Their numbers never need a schema: hz and bw are backed by a wildcard subscriber in the plugin host that counts and sizes raw payloads, and list/info only read the graph. hu meter echo <topic> --raw also works regardless, since it hex-dumps the CDR bytes instead of decoding them.
hz and bw do open one ordinary subscription alongside that, purely so they announce themselves in the ROS graph — a publisher that waits for a subscriber before it starts will otherwise never publish, and the measurement would read zero. That subscription resolves a schema like any other, so two things follow on a topic whose type cannot be resolved: the first sample can be delayed by up to the discovery timeout, and the announcement does not happen. The counting is unaffected either way.
# repro-expect: (?i)hello hiroz
hu meter echo /chatter
# repro-expect: (?i)\[/chatter\] (delay:.*ms|no header\.stamp)
hu meter delay /chatter --duration 5
delay takes --duration <s> exactly as hz and bw do; without it the command runs until interrupted.
delay measures the gap between a message's header.stamp and its arrival, so it only produces a number on stamped messages. /chatter carries std_msgs/String, which has no header — against it delay decodes each message and says so per message (no header.stamp — cannot measure delay) rather than reporting a latency. Point it at a stamped topic (anything carrying a std_msgs/Header, e.g. sensor_msgs/LaserScan from the laser_scan example) to get delay: <n> ms.
By default hu connects to tcp/127.0.0.1:7447 and uses domain ID 0 — matching the talker/listener above. Override with flags or environment variables:
Or set them once for the session:
HU_CONNECT and HU_DOMAIN fully replace the --connect / --domain flags — once exported, every hu meter / hu monitor invocation reaches that router with no per-command flags, which is the recommended workflow for an interactive session:
export HU_CONNECT=tcp/127.0.0.1:7447
hu meter list topics # no --connect needed
hu monitor graph # same session, same router
More examples¶
The Quick Start covers list, hz, and watch. The subcommands below are the least self-explanatory ones — each transcript shows the exact command and the output line to expect. These paths are exercised by the integration suite (crates/hiroz-tests/tests/hu_meter.rs, hu_monitor.rs).
Call a service (against an AddTwoInts server on /add_two_ints):
# --yaml takes the request as inline YAML; --msg-type names the request type.
hu meter service call /add_two_ints \
--yaml '{a: 20, b: 22}' \
--msg-type example_interfaces/srv/AddTwoInts_Request \
--timeout 10
# {"sum": 42}
The response prints as JSON, so it pipes straight into jq:
hu meter service call /add_two_ints --yaml '{a: 20, b: 22}' \
--msg-type example_interfaces/srv/AddTwoInts_Request | jq '.sum'
# 42
Round-trip a parameter (set then read it back):
hu meter param set /talker publish_period_ms 500
# OK
hu meter param get /talker publish_period_ms --json
# {"publish_period_ms": 500}
Describe a parameter as JSON (every meter subcommand supports --json for scripting):
Stream action feedback while a goal runs:
hu meter action echo /fibonacci \
--msg-type example_interfaces/action/Fibonacci --count 3
# [/fibonacci/_action/feedback] {"partial_sequence": [0, 1, 1]}
# [/fibonacci/_action/feedback] {"partial_sequence": [0, 1, 1, 2]}
# [/fibonacci/_action/feedback] {"partial_sequence": [0, 1, 1, 2, 3]}
Get / set a node's log level with hu monitor:
# Read the current logger levels for /talker.
hu monitor log-level /talker
# log levels: {"levels": [{"name": "talker", "level": 20}]}
# Set it to DEBUG (subsequent /rosout traffic reflects the new level).
hu monitor log-level /talker DEBUG
# set log level to DEBUG
# Follow /rosout, stopping after 2 messages.
hu monitor log --count 2
# [rosout] ...
# [rosout] ...
# Omit --count to stream every /rosout message indefinitely (Ctrl-C to stop).
hu monitor log
# [rosout] ...
# [rosout] ...
# ...
hu monitor log-leveltalks to the target node'sget_logger_levels/set_logger_levelsservices (rcl_interfaces), so the node must expose them — standardrclcpp/rclpynodes do; the pure-hiroz demo nodes do not yet.
Why hu instead of ros2cli?¶
ros2cli carries a set of well-known pain points — a background daemon that goes stale or crashes, Python-bound rate measurement that undercounts at high frequency, silent QoS-mismatch drops, service calls with no timeout, slow startup on embedded hardware, and fragile nested-YAML publishing. hu addresses all of these with a daemon-free, compiled, Zenoh-native design.
See Why hu instead of ros2cli? for the full comparison with issue references and before/after examples.
Summary¶
| Pain point | ros2cli | hu |
|---|---|---|
| Daemon crashes / stale state | ❌ common | ✅ no daemon |
| Rate measurement accuracy | ❌ Python deserialization bottleneck | ✅ raw Zenoh bytes |
| QoS mismatch warning | ❌ silent drop | ✅ explicit warning |
| Service call timeout | ❌ hangs forever | ✅ --timeout flag |
| Startup time (embedded HW) | ❌ 7+ seconds | ✅ <10 ms |
| Nested YAML in topic pub | ❌ fails silently | ✅ CDR-aware encoding |
| Works without ROS 2 install | ❌ requires full ROS 2 | ✅ only needs a Zenoh router |
Subcommands¶
hu meter¶
Measurement and introspection:
| Command | Description |
|---|---|
hu meter hz <topic> |
Publish rate (sliding window) |
hu meter bw <topic> |
Bandwidth in KB/s |
hu meter echo <topic> |
Print arriving messages |
hu meter echo <topic> --raw |
Hex-dump raw CDR bytes, bypassing schema decode (requires the access-raw-cdr permission) |
hu meter delay <topic> |
End-to-end latency, from header.stamp to arrival (stamped messages only) |
hu meter pub <topic> |
Publish a message |
hu meter list <kind> [--find <substr>] [--count <n>] [--all] |
Enumerate graph entities. <kind> is topics (the default when omitted), nodes or services. Hidden entities are excluded unless --all is given: for topics and services that means any name with a path segment starting with _, but for nodes only the bare node name is tested, so a node whose namespace has an _-prefixed segment stays visible. --find matches name or type for topics and services, name only for nodes; --count truncates the result. |
hu meter list find-<kind> <substr> |
Shorthand for list <kind> --find <substr>, taking the filter as a positional argument: find-topics, find-services, find-nodes. |
hu meter info <kind> <name> |
Full entity introspection; <kind> is topic, node or service |
hu meter service list |
List services as name<TAB>[type] |
hu meter service find <substr> |
List the names of services whose name contains <substr> |
hu meter service type <name> |
Print a single service's type name, or exit 1 if it is not in the graph |
hu meter service call <name> [--timeout <s>] |
Call a service. The request is given either as --yaml <yaml> --msg-type <type>, or as raw hex-encoded CDR bytes with --payload <hex> |
hu meter param <verb> <node> [...] |
Read and write node parameters, or bulk-load them from a ROS-style YAML params file. <verb> is list, get, dump, describe, set or load (load <node> <yaml-file>). load is host-handled: hu reads and parses the YAML on the host (WASM plugins have no filesystem access) and hands the plugin pre-flattened parameter data. There is no delete verb — parameter deletion (ros2 param delete) is not implemented. |
hu meter action list |
List available actions |
hu meter action info <name> |
Show an action's type and server count |
hu meter action send-goal <name> --payload <hex> [--timeout <s>] |
Send a goal from raw hex-encoded CDR bytes, and poll for the result. This is the only way to send a goal — there is no JSON-goal verb; unlike hu meter service call --yaml, goals are not accepted in a human-authored form. |
hu meter action echo <name> --msg-type <type> [--count <n>] |
Echo action feedback messages |
hu monitor¶
Observation and diagnostics:
| Command | Description |
|---|---|
hu monitor watch |
Stream live graph change events |
hu monitor graph |
Snapshot the current graph |
hu monitor log [--count <n>] |
Tail /rosout |
hu monitor log-level <node> [<level>] |
Read or change a node's logger level |
hu plugin¶
Plugin management:
| Command | Description |
|---|---|
hu plugin list |
List discovered plugins as PLUGIN VERSION SOURCE PATH. SOURCE is download, local, installed, or unmanaged for a file hu did not install |
hu plugin validate <path> |
Validate that a .wasm file compiles as a WASM component |
hu plugin install <file\|url\|name> |
Install from a local path, a URL, or a name resolved against a release index (--registry, or HU_PLUGIN_REGISTRY). Validates before accepting |
hu plugin uninstall <name> |
Remove a plugin hu installed. Refuses one that lives on $HU_PLUGIN_PATH, since that is a build tree and not hu's to delete |
list reads filenames only — it never opens a component, so it cannot tell a
valid plugin from a corrupt one. validate is the check that does.
Multi-topic rate dashboard¶
For continuous monitoring of several topics at once, use the hu TUI. Select topics in the Topics panel and press m to add them to the Measure panel, which shows a live, per-second rate and bandwidth table for every topic you're tracking — all in one process, instead of one ros2 topic hz per topic:
Bare hu launches the TUI; use Tab/1–5 to reach the Measure panel. This is the primary advantage over ros2 topic hz, which needs a separate terminal per topic and no combined view.
TUI keybindings¶
| Key | Action |
|---|---|
Tab / Shift+Tab |
Cycle panels (Topics, Services, Nodes, Measure, Plugins) |
1–5 |
Jump directly to a panel |
↑/k, ↓/j |
Move selection |
Enter / Space |
Expand/focus the selected item's detail |
/ |
Enter filter mode (type-ahead search) |
r |
Quick rate check on the selected topic (Topics panel) or clear tracked rates (Measure panel) |
m |
Toggle the selected topic or service into the Measure panel's tracking list |
w |
Start/stop recording metrics |
e |
Export the current rate cache to a timestamped CSV file |
S |
Capture a screenshot of the current TUI state |
t |
Tick the selected plugin (Plugins panel) |
R |
Reload plugins — rescan the plugin directories live, no restart (Plugins panel) |
? |
Toggle the help overlay |
q / Ctrl+C |
Quit |
When a TUI plugin's output pane is focused (select it on the Plugins panel and press l/Enter), keystrokes are sent to the plugin as key-action events instead of driving the TUI — press Esc (or ←/h) to return control to the list.
JSON output¶
Every hu meter subcommand accepts --json for scripting:
hu meter hz /scan --duration 5 --json | jq '.rate_hz'
hu meter list topics --json | jq '.[].name'
# repro-expect: /chatter
hu meter info node /talker --json | jq '.publishers[].name'
Stream mode¶
hu stream streams graph change events to stdout without opening a TUI. Useful for piping into log aggregators, CI scripts, or dashboards that can't host a terminal.
It first prints the current graph as a snapshot, then one line per change event, each prefixed with a UTC timestamp. Type names appear in their DDS-mangled form (std_msgs::msg::dds_::String_), not the ROS std_msgs/msg/String form:
hu stream
# Discovered Topics:
# topic: /chatter (std_msgs::msg::dds_::String_)
# Discovered Services:
# service: /talker/get_parameters (rcl_interfaces::srv::dds_::GetParameters_)
# Discovered Nodes:
# node: //talker
# [2026-08-04 07:42:06] Node discovered: /talker
# [2026-08-04 07:42:06] Topic discovered: /chatter (std_msgs::msg::dds_::String_)
The doubled slash in the snapshot's node lines
Snapshot node lines are printed as <namespace>/<name>, and a node in the root namespace has the namespace / — so it renders as //talker, not /talker. The event lines below use the raw namespace instead and print /talker. hu itself joins the graph while streaming, so it appears alongside your nodes.
Add --json for structured output. Every record is one of two shapes: an object with an "event" key naming it, or a SystemEvent in serde's externally-tagged form, where the variant name is the sole top-level key. The first line is always "event":"initial_state"; graph changes after it are the externally-tagged form. Adding --echo interleaves two further "event"-keyed shapes, topic_subscribed and message_received, so a filter must not assume every record after the first has a variant-name key:
hu stream --json
# {"event":"initial_state","timestamp":{"secs_since_epoch":1785829316,"nanos_since_epoch":522800352},"domain_id":0,"topics":[{"name":"/chatter","type":"std_msgs::msg::dds_::String_","publishers":1,"subscribers":0}],"nodes":[{"name":"talker","namespace":"/"}],"services":[{"name":"/talker/get_parameters","type":"rcl_interfaces::srv::dds_::GetParameters_"}]}
# {"NodeDiscovered":{"namespace":"","name":"talker","timestamp":{"secs_since_epoch":1785829316,"nanos_since_epoch":522703282}}}
# {"TopicDiscovered":{"topic":"/chatter","type_name":"std_msgs::msg::dds_::String_","timestamp":{"secs_since_epoch":1785829316,"nanos_since_epoch":522693292}}}
The arrays are shown with one entry each for brevity; a real graph also carries /parameter_events and each node's parameter services.
Because the variant name is the key rather than a type field, filtering with jq selects on key presence:
The event variants are TopicDiscovered, TopicRemoved, RateMeasured, NodeDiscovered, NodeRemoved, ServiceDiscovered and MetricsSnapshot.
Two field-naming traps when writing filters:
- The snapshot's per-topic and per-service type field is
type; the event payloads usetype_name. - A root-namespace node is reported as
"namespace":"/"in the snapshot but"namespace":""inNodeDiscovered/NodeRemoved— the snapshot normalises the empty namespace, the events do not. Joining namespace and name naively will produce//talkerfrom the snapshot.
Add --echo <TOPIC> to also subscribe to a topic and interleave decoded messages. --echo can be repeated for multiple topics:
Note
hu stream replaces the deprecated --headless flag, which still works as a hidden alias for now.
Web mode¶
hu web starts an HTTP server (default port 8080) that dispatches requests to hu-web-plugin WASM plugins. It needs hu built with the web-plugins feature — the published release binaries are, so a downloaded hu has it:
It binds loopback only by default, so the plugin HTTP surface is not exposed on every interface. Set HU_WEB_BIND to widen it deliberately:
Each web plugin is reachable at /plugins/<name>/ and /plugins/<name>/<path...>. The plugin handles the full HTTP request/response cycle (see hu Plugin Authoring Guide).
There is no reference hu-web-plugin yet — the host is wired and the server runs, but until you write one there is nothing for it to serve.
Note
hu web replaces the deprecated --web [PORT] flag, which still works as a hidden alias for now.
Running a router¶
hu router starts an embedded Zenoh router configured to match rmw_zenoh_cpp, so you don't need a separate zenohd install or the cargo run --example zenoh_router helper for local development. It listens on tcp/[::]:7447 by default and runs until Ctrl-C:
# repro: skip the suite's router fixture already holds :7447
hu router # listen on tcp/[::]:7447
hu router --listen tcp/0.0.0.0:7448 # custom endpoint (repeatable)
# repro: skip router.json5 is an illustrative filename, not a shipped file
hu router --config router.json5 # full JSON5/YAML config, overrides --listen
Point every other hu command (and your ROS 2 / hiroz nodes) at it with --connect / HU_CONNECT. For production deployments, prefer the bundled rmw_zenohd or a standalone zenohd.
Additional flags¶
| Flag | Default | Description |
|---|---|---|
--connect <endpoint> |
tcp/127.0.0.1:7447 |
Zenoh router endpoint to connect to (also HU_CONNECT) |
--domain <id> |
0 |
ROS 2 domain ID (also HU_DOMAIN) |
--backend <name> |
rmw-zenoh |
Select the graph/RMW backend (currently only rmw-zenoh). Not a mode switch — pick a mode with the tui/web/stream subcommands |
--json |
— | Structured JSON output — affects hu stream event streaming, hu plugin list output, and log formatting |
--echo <TOPIC> |
— | Subscribe to topic and stream messages (hu stream, repeatable) |
--export <path> |
— | Write a graph snapshot to a file and exit (format from the extension — see below) |
--debug |
— | Enable verbose debug logging to stderr |
Export formats¶
--export <path> picks the output format from the file extension:
| Extension | Output |
|---|---|
.json |
Pretty-printed graph snapshot (nodes, topics, services) |
.dot |
Graphviz digraph — render with e.g. dot -Tpng graph.dot -o graph.png |
.csv |
Flat CSV rows of the graph entities |
Any other extension is rejected with an "Unsupported export format" error.
The run mode is chosen by subcommand — hu (or hu tui) for the TUI, hu web for the HTTP server, hu stream for JSON streaming. The old --web / --headless flags still work as hidden, deprecated aliases.
Plugin architecture¶
hu is a plugin host. meter and monitor are not built-in subcommands — they are WASM plugins compiled to wasm32-wasip2 and loaded at startup from $HU_PLUGIN_PATH and ~/.local/share/hu/plugins/. The hu binary itself provides the native pieces the sandbox can't: the frontends that run and render plugins (tui, web, stream, and the one-shot CLI — each the host side of a WIT world), and the management commands router (binds a socket to serve an embedded Zenoh router) and plugin (lists/validates installed plugins). See the platform overview for how frontends, plugins, and management fit together.
flowchart TD
H["hu binary<br>(host runtime + TUI shell)"]
H --> M["meter.wasm<br>hu meter hz / bw / echo / pub / list / info …"]
H --> Mo["monitor.wasm<br>hu monitor watch / graph / log / log-level"]
H --> C["custom.wasm<br>hu <name> <args>"]
HU_PLUGIN_PATH["$HU_PLUGIN_PATH<br>~/.local/share/hu/plugins/"] --> H
Any team can ship a hu-<name>.wasm file and it becomes a hu <name> subcommand with no build-system changes, no Python packaging, and no shared runtime state:
# Drop a .wasm file and it becomes available immediately
cp ./my-debug-tool.wasm ~/.local/share/hu/plugins/
hu plugin list # PLUGIN VERSION SOURCE PATH
hu my-debug-tool --help
Plugins are sandboxed: they declare the capabilities they need (subscriptions, raw CDR, additional Zenoh sessions) in a manifest, and the host refuses calls for anything undeclared. Plugins also never manage Zenoh connections directly — the host opens all sessions declared in the plugin's manifest before the first event fires. The same .wasm binary runs as a TUI panel and as a CLI subcommand.
See hu Plugin Authoring Guide for the WIT interface reference and a worked example.