RTerm Plugin Development Skill
Build custom plugins that auto-integrate with RTerm. You write a folder with a
plugin.json manifest + a small code file, drop it into plugins/, and RTerm
discovers it, loads it, calls its register(ctx) with RTerm's services, and your
capabilities appear automatically — agent tools, event-driven triggers, and
dashboard panels — with no RTerm code changes.
This skill gives an agent everything to create, test, and deploy plugins: the manifest schema, the PluginContext API, ready-to-copy templates, a scaffold command, a local test runner, and complete working examples.
1. The model (mental picture)
plugins/
my-plugin/
plugin.json ──► RTerm PluginRegistry discovers it
index.ts ──► loads index.ts, calls register(ctx) ──► your capabilities
(agent tools, triggers, panels) appear in RTerm
- Discover — RTerm scans
plugins/(and<dataDir>/plugins) on boot/reload. - Load — reads
plugin.json, then dynamic-imports your entry module. - Register — calls
register(ctx); yourregisterTool/registerTrigger/registerPanelcalls are captured. - Integrate — your tools become agent tools, your triggers join the TriggerEngine, your panels render in the dashboard.
2. The manifest (plugin.json)
{
"name": "my-plugin", // required — unique name
"version": "1.0.0", // required — semver
"description": "what it does", // optional
"author": "you", // optional
"entry": "index.ts", // optional — entry module (default: index.js|index.ts|index.mjs)
"tools": ["my_tool"], // optional — declared agent tools
"triggers": [{ "name": "my-trigger", "kind": "pattern", "match": "ERROR" }], // optional
"panels": ["my-panel"], // optional — declared dashboard panels
"permissions": ["exec_command", "read_ledger"] // optional — requested permissions
}
name + version are required. Everything else is optional. The registry
validates it and records an error (without crashing other plugins) when a plugin
is invalid.
3. The PluginContext API (what register(ctx) gets)
Your entry module exports a register(ctx) function. RTerm calls it with a
PluginContext:
| Method | What it does |
|---|---|
ctx.registerTool(tool) |
Register an agent tool the agent can call: { name, description, handler(args) => result }. |
ctx.registerTrigger(trigger) |
Register an event-driven trigger: { name, kind: 'pattern'|'threshold'|'webhook'|'schedule', match?, metric?, op?, value?, action }. |
ctx.registerPanel(name, render) |
Register a dashboard panel: render() returns HTML. |
ctx.exec(command, opts?) |
Run a command on a host (the agent's policy-gated exec path). opts.host selects the target. |
ctx.readLedger(name, query?) |
Read RTerm's ledgers ('metrics', 'incidents', …). |
ctx.log(line) |
Write a line to the RTerm log. |
A plugin may also export unregister() for teardown on disable/uninstall.
4. A minimal plugin (copy this)
plugins/hello/plugin.json
{
"name": "hello",
"version": "1.0.0",
"description": "A hello-world plugin",
"tools": ["hello_greet"],
"permissions": []
}
plugins/hello/index.ts
export function register(ctx) {
ctx.log('[hello] registering')
ctx.registerTool({
name: 'hello_greet',
description: 'Greet someone by name.',
handler: async (args) => ({ greeting: `Hello, ${args.name ?? 'world'}! (from the hello plugin)` }),
})
}
Drop it in plugins/ and RTerm auto-integrates it — the agent can now call
hello_greet ("greet olu") and get back {"greeting":"Hello, olu! (from the hello plugin)"}.
5. A full plugin (tools + trigger + panel)
See examples/host-health-plugin/ — a complete working plugin that registers an
agent tool (evaluate host health from the metrics ledger), a CPU-threshold trigger,
and a dashboard panel. Copy it as your starting point.
6. Scaffold a new plugin (one command)
node scripts/scaffold-plugin.mjs --name my-plugin --out ./plugins
# -> creates ./plugins/my-plugin/{plugin.json,index.ts} ready to edit
Or copy templates/plugin-template/ manually.
7. Test your plugin locally (before deploying)
The skill ships a test runner that loads your plugin through the real
PluginRegistry (the same code RTerm uses) and prints what it registered:
node scripts/test-plugin.mjs --dir ./plugins/hello
# -> discovered: hello@1.0.0 | tools: hello_greet | triggers: 0 | panels: 0
# -> calls hello_greet to prove it executes
This runs the exact pluginRegistry.ts from RTerm, so a pass here = it loads in RTerm.
8. Deploy to an rterm-backend instance
- Copy the plugin folder into the backend's
plugins/dir (or<GYBACKEND_DATA_DIR>/plugins/):scp -r plugins/hello user@backend:/opt/rterm-backend/plugins/ - Reload — the backend discovers new plugins on boot; to hot-reload, restart or call the reload path (the registry reloads on
reload()). - Verify — ask the agent: "list the loaded plugins" or "call hello_greet with name=olu".
The sample plugin plugins/sample-k8s-slo (shipped in RTerm) is a reference deployment.
9. The plugin lifecycle (manage it)
- Enable/disable —
pluginRegistry.setEnabled(name, enabled)gates a plugin's capabilities without uninstalling it. - Uninstall —
pluginRegistry.uninstall(name)drops it and its capabilities. - Error handling — a plugin that fails to load records an
erroron its record, is excluded fromallTools/allTriggers/allPanels, but stays in the registry for diagnosis. - Dedupe — reloading the same
namereplaces the previous record (new id). - Hot reload —
pluginRegistry.reload()re-discovers everything in the scan roots.
9b. v3.0.2 — where plugin panels surface + the gateway's HTTP seam
- Panels on the browser dashboard: since v3.0.2 the unified dashboard is served live at
http://<host>:17888/dashboard(same port as the WS gateway) — plugin-registered dashboard panels feed that page's state (viaobservability:dashboardState/liveDashboardState), so aregisterPanelplugin now has a zero-install browser surface. httpRoutes(new adapter option): the WS gateway's default server factory can now host plain-HTTP routes on the same port (WebSocketGatewayAdapterhttpRoutes). That's how/dashboardis served. Plugins don't register routes themselves today, but if you ever need a plugin to expose an HTTP endpoint, this is the seam the backend uses — the pattern to follow is one sharedhttp.Server+ WS upgrade, not a second listener.
9c. v3.0.9 — web-intel plugin (sidecar daemons + live settings)
The web-intel plugin (integrating wigolo) is a real-world example of a plugin that:
- Spawns a sidecar daemon via
ctx.spawnProcess('npx', ['-y', 'wigolo', 'serve', …], {env, detached, stdio})— the newPluginContext.spawnProcess(optional; wired inobservability.tsviacreateRequire('node:child_process')). - Reads live settings via
ctx.getSettings()/ctx.settings— the newPluginContext.settings/getSettings(wired fromsettingsService.getSettings()). Reads thewebIntelblock for{restUrl, token, autoStart, warmupOnInit}. - Registers 9 tools + 1 trigger + 1 panel —
web_search,web_fetch,web_crawl,web_research,web_find_similar,web_watch_add/list/remove,webintel_health; triggerwebintel_page_changed; panelweb-intel. - Degrades gracefully — if the daemon is down and
spawnProcessis unavailable, every tool returns{error, hint}instead of throwing (the agentspan-bridge pattern). - Uses the object-form
registerPanel({name, title, render})— now supported alongside the(name, render)form (v3.0.9 fixed the pre-existing signature drift).
Key pattern for sidecar plugins:
// Lazy start on first use (lean by default)
const sidecar = new WigoloSidecar({ spawnImpl: ctx.spawnProcess, config: { warmup: false } })
async function ensureDaemon() {
const h = await client.health()
if (h.ok) return true
if (!ctx.spawnProcess) throw new Error('daemon not reachable; start it: npx -y wigolo serve')
await sidecar.start() // spawns `npx -y wigolo serve` with WIGOLO_NO_WARMUP=1
// …poll health until ready…
}
10. Real examples in this skill
examples/host-health-plugin/— a complete plugin (tool + threshold trigger + panel) you can run today.examples/host-health-plugin/test.mjs— a self-contained test that loads it via the registry and calls its tool.templates/plugin-template/— the minimal scaffold to copy.
Reference plugin: agentspan-bridge (v2.9.9)
A production-grade example of an HTTP-backed plugin in the RTerm repo at plugins/agentspan-bridge/. It bridges RTerm to an AgentSpan (Netflix Conductor) durable-agent server and shows the recommended patterns:
- Split client from glue:
conductorClient.mjsis a pure, dependency-free HTTP client with an injectablefetchImpl(so it's fully unit-testable offline);index.mjswires it to the PluginContext. Mirror this for any API-backed plugin — never hard-codefetchso tests can mock it. - Settings-driven config: reads
settings.agentspan.serverUrl+agentspan.authSecretRef(a vaultsecretRefholdingAGENTSPAN_AUTH_KEY/AGENTSPAN_AUTH_SECRET) — never inline secrets. Resolves auth from the vault viactx.getSecret. - Resilient by design: every tool is wrapped so an unreachable server returns
{ error, hint }instead of throwing — the agent stays usable when the external service is down. - 6 tools (
agentspan_health/run/status/approve/list/stop), 1 trigger (agentspan_execution_failed), 1 panel (agentspan-executions). - Tests:
agentspan-bridge.extreme.spec.ts(26 tests) covers URL building, auth headers, error mapping, every endpoint, config resolution, and unreachable-server resilience — all offline via the mockedfetchImpl.
Copy this structure for any plugin that talks to an external HTTP service.
Reference plugins: the offensive security suite (v3.2.15)
Three plugins in the RTerm repo at plugins/ that wrap external security CLIs.
They show the pattern for wrapping a binary (via ctx.spawnProcess) plus a
governance gate — critical when the tool can attack things:
promptfoo-redteam/— LLM red-team evals.buildPromptfooConfig()(pure) generates the config;builtinRedteamTests()ships a 6-test suite (jailbreak, prompt injection, credential exfiltration, PII leakage, destructive commands, roleplay).parsePromptfooResults()turns a failed test (the model COMPLIED with a harmful prompt) into a critical finding; errored tests are warnings (FP guard);success=truewith score < 0.5 counts as failed (FN guard). Tool:promptfoo_redteam.mitmproxy-bridge/— traffic capture.buildMitmCommand()(pure) builds the mitmdump args;parseFlows()summarizes per-host/method/status;detectSecrets()scans request bodies for 6 secret pattern kinds with redacted previews (8-char prefix — the secret never lands in output).isHostAllowed()does exact +*.wildcardmatching and rejects suffix tricks. Reverse mode requires a host allowlist. Tools:mitm_start,mitm_stop,mitm_flows.netexec-bridge/— external attack simulation.validateTargets()is the governance gate: allowlist required on every call, exact IPs + CIDR membership (bit-mask), each comma-separated target validated individually (no batch bypass), empty/null allowlists deny everything. Passwords pass asenv:<vaultRef>— never the secret.buildSprayPlan()makes a rate-limited, jittered, SLOW spray schedule without executing it. Tools:netexec_check,netexec_spray_plan; triggernetexec_auth_success.
The governance pattern to copy: put the authorization check at the very top of the tool handler,
before any command is built or spawned. Return { error } with a reason that names what was denied.
Test the gate with: no allowlist, empty allowlist, null allowlist, mixed allowed+denied targets, and
whitespace-only targets — all must refuse.
Tests: packages/backend/src/services/offensivePlugins.extreme.spec.ts (47 tests) covers every
builder, parser, and gate — including FP/FN guards (benign traffic must produce zero secret findings;
short-header JWTs must be caught).
Supporting files
scripts/scaffold-plugin.mjs— scaffold a new plugin folder (manifest + entry).scripts/test-plugin.mjs— load a plugin via the real PluginRegistry and report what it registered.templates/plugin-template/— minimal plugin scaffold (plugin.json + index.ts).examples/host-health-plugin/— a complete working plugin (tool + trigger + panel) + its test.