A walkthrough of the stado plugin lifecycle from "I want a custom
tool" to "the LLM can call it." Pulls together the surface area
documented across stado plugin --help, EP-0006, EP-0027, and
EP-0028 into one go-from-zero-to-shipping path.
stado's core stays small on purpose. Most agent-facing functionality belongs in WASM plugins (EP-0002), with the core providing only:
- the wasm runtime + capability sandbox,
- a few foundational primitives (config / FS / sandbox / git sidecar),
- the plugin lifecycle CLI (
init,sign,trust,install,run,gc,doctor), - and signed-distribution machinery.
Hooks in the core are deliberately thin passthroughs: the runtime calls into the plugin, not the other way round, and business logic is on the plugin side so it can be swapped, upgraded, or replaced without touching stado.
When designing your plugin, lean into this: capability-bound swappable units beat monolithic feature flags. If your plugin grows big, that's fine — sign it, ship it, and let the operator decide whether to install. If a feature feels like it should live in stado's core, double-check whether a plugin with the right capabilities can do it equally well. The bar for "this must be in core" is "no plugin capability can express this" — which is rare.
This document is for plugin authors (operators writing their
own plugins). For the trust model and signature security
properties, see EP-0006
and SECURITY.md. For the per-command
reference, see docs/commands/plugin.md.
Write a plugin when you want to add a tool the LLM can call — a
project-specific lookup, a service wrapper, a domain-aware command.
The plugin runs in a wasm sandbox with the capabilities its
manifest declares; the LLM sees it the same way it sees the bundled
tools (bash, webfetch, read, etc.).
Don't write a plugin when:
- A bundled tool already does the job.
bash+ a small shell script is often the right answer. - You want to override a bundled tool with a custom variant. Use
[tools].overrides = { webfetch = "webfetch-cached-0.1.0" }inconfig.tomlto point the bundled name at your installed plugin — no code change in the agent. - You need to integrate an external service that already speaks the Model Context Protocol. An MCP server is a much simpler integration than a wasm plugin for that case.
┌─────────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ stado plugin │ → │ build.sh │ → │ stado │ → │ stado │
│ init <name> │ │ (compile │ │ plugin │ │ plugin │
│ (Go scaffold) │ │ + sign) │ │ install │ │ run … │
└─────────────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
↓ ↓ ↓ ↓
plugin.go, plugin.wasm, state-dir LLM-callable
manifest manifest.sig plugin/<id> tool
Each step has a verb you'll recognise from the analogue ecosystem (npm/cargo/pip), but the artefact at every step is signed + capability-bounded.
stado plugin init my-plugin
cd my-plugin
ls
# build.sh go.mod main.go plugin.manifest.template.json README.mdinit creates a Go wasip1 project with the wasm ABI exports
(stado_alloc, stado_free, stado_tool_<name>) and host imports
(//go:wasmimport stado stado_log etc.) already wired. Replace the
greet demo tool with your real tool — the rest of the boilerplate
should work as-is.
The Go runtime overhead (~3 MB wasm output for a trivial plugin) is real. If size matters, write the plugin in Zig or Rust against the same ABI. Proven examples:
| Language | Example | Wasm size | Build |
|---|---|---|---|
| Go | http-session |
~3.5 MB | GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared |
| Zig | hello |
~800 B | zig build-exe -target wasm32-freestanding -fno-entry -OReleaseSmall |
| Zig | encode-zig |
~5 KB | same — full base64/hex/url/html encode+decode |
| Rust | (pending) | ~50–200 KB expected | cargo build --target wasm32-unknown-unknown --release |
Zig's wasm32-freestanding target needs no WASI or libc — the
stado ABI is the only interface. Rust requires declaring extern "C"
host imports and #[no_mangle] exports matching the same ABI surface.
Key ABI constraint for Zig/Rust: the host calls stado_alloc
twice per tool invocation — once for the args buffer and once for
the 1 MiB result buffer. Size your arena to at least 2 MiB to
accommodate both.
Plugins are Ed25519-signed JSON manifests. The signing key never needs to leave your machine.
stado plugin gen-key my-plugin.seed # one-time; chmod 600 + back up
./build.sh
# → plugin.wasm + plugin.manifest.json + plugin.manifest.sigbuild.sh is the scaffolded compile + sign script. It calls
stado plugin sign under the hood, which fills in the manifest's
wasm_sha256 and author_pubkey_fpr fields and writes the
detached signature.
Stado refuses to install plugins from un-pinned signers. First time you install a plugin from your own key:
# Print the pubkey hex from your seed to use in the trust call.
# (gen-key already printed it; check the seed file's adjacent
# author.pubkey if you forgot.)
stado plugin trust <pubkey-hex> "Your Name"
stado plugin install .Subsequent installs of newer versions from the same key just need
stado plugin install . — the trust pin survives.
Remote installs (stado plugin install github.com/owner/repo@1.2.3)
verify the owner's anchor key on first sight (interactive prompt, or
--trust-anchor for non-interactive). After an expected key rotation,
stado plugin untrust-anchor <host/owner> then reinstall. Signer-level
stado plugin trust still applies for local-directory installs.
Repos may ship wasm under .stado/plugins/. Stado does not autoload
them unless the operator sets in user config:
[plugins]
allow_project_plugins = trueDefault is false (EP-0044). The gate cannot be set from project
config. When off, stderr names the skipped directory once per process.
Global installs under $XDG_DATA_HOME/stado/plugins/ are unaffected.
stado plugin installed
# my-plugin-0.1.0 author=Your Name tools=1 caps=1
stado plugin doctor my-plugin-0.1.0
# Prints: which surfaces this plugin runs on, with the exact flags
# to pass. Use this when `tool run` returns errors and you want
# to know which knob to flip.
stado tool run <tool> '<json-args>'If tool run produces a message like stado_http_request returned -1 or stado_fs_read failed, run plugin doctor against the
plugin id — it will tell you whether you need --workdir,
--session, or to use the TUI / stado run instead. (The tool host
is always attached now, so bundled-tool imports no longer need a flag.)
The manifest declares capabilities that the host enforces at the wasm-import boundary. The full vocabulary is catalogued in the ABI reference §8; this table covers the most common groups and the plugin-run surface each requires.
| Capability shape | What it gates | Required surface |
|---|---|---|
fs:read:/abs/path, fs:write:/abs/path |
stado_fs_read / stado_fs_write to that path |
Any |
fs:read:., fs:read:./sub |
Same, but resolved against Workdir |
tool run --workdir=$PWD (default workdir is the plugin's install dir, not the operator's CWD — EP-0027) |
net:http_request[:<host>] |
stado_http_request and _request_stream |
Any (tool host always attached) |
net:http_request_private |
Loosens dial guard to RFC1918 / loopback / link-local / CGNAT. Off by default. | Any (tool host always attached) |
net:http_client |
Stateful HTTP client with cookie jar (stado_http_client_*) |
Any (tool host always attached) |
net:dial:tcp:<host>:<port>, :udp:, :unix:<path> |
Outbound stado_net_dial (TCP / UDP / Unix). Private addresses still need net:http_request_private. |
Any |
net:listen:tcp:<host>:<port>, :udp:, :unix:<path> |
Server-side stado_net_listen (verbatim host:port match — no implicit 127.0.0.1 ⊂ 0.0.0.0) |
Any |
exec:proc[:<binary-glob>] |
stado_proc_* and stado_exec; add bundled-bin:<name> for rg/ast-grep |
TUI / stado run when spawning subprocesses (sandbox runner needed) |
exec:pty, terminal:open |
PTY-backed shell sessions (stado_pty_* / stado_terminal_*) |
TUI / stado run (screen via shell.read mode: screen, not a separate screenshot tool — EP-0043) |
session:read, session:fork, session:observe |
Session reads + fork RPC | tool run --session <id> |
llm:invoke[:<token-budget>] |
Outbound LLM calls | tool run --session <id> (uses the session's provider) |
memory:propose, memory:read, memory:write |
Append-only memory store | tool run --session <id> (or any agent loop) |
state:read[:<key-glob>], state:write[:<key-glob>] |
Process-lifetime in-memory KV (stado_instance_*) |
Any |
secrets:read[:<name-glob>], secrets:write[:<name-glob>] |
Operator secret store (stado_secrets_*) |
Any |
tool:invoke[:<name-glob>] |
Plugin calls other registered tools (stado_tool_invoke) |
Any (depth-limited; v0.75.2 audit path when executor pinned) |
agent:fleet |
Sub-agent fleet (stado_agent_*) — bundled agent plugin only |
TUI / stado run |
dns:resolve |
stado_dns_resolve |
Any |
crypto:hash, compress |
Stateless format helpers (hash, hmac, gzip, zlib) | Any |
cfg:state_dir |
Read state-dir path (stado_cfg_state_dir) |
Any |
bundled-bin |
Read bundled binaries (stado_bundled_bin) |
Any |
ui:approval |
Approval bridge (stado_ui_approve) |
TUI / headless agent loop only |
stado plugin doctor automates this table — run it against any
installed plugin and the report will tell you exactly what flags
to pass.
| Field | Purpose |
|---|---|
requires |
Array of "<plugin-name>" or "<name> >= <ver>" — install fails if a dep is missing. |
tools[].categories |
Array of category tags (file, network, code-search, …). Operators can add [tools].autoload_categories = ["file"] to surface tools by category instead of by name. |
min_stado_version |
Refuses install on older stado. Set to the version that introduced any host import you call. |
Plugin authoring is bumpy on the first plugin (figuring out the ABI, getting capabilities right) but smooth after. The recommended loop:
- Edit
main.go. Bump the manifest'sversionfield if you want to install side-by-side with the previous build (rollback protection rejects identical-version reinstalls under the same signer). ./build.shstado plugin install .stado tool run [flags] <tool> '<args>'
Periodically:
stado plugin gc— sweep older versions per (signer, name) group. Default--keep=1. Dry-run by default; pass--apply. Trust-store entries and rollback pins are preserved, so a freshly-deleted older version still cannot be reinstalled.
//go:wasmimport stado stado_fs_read
func stadoFsRead(pathPtr, pathLen, bufPtr, bufCap uint32) int32
// In your tool's RunE:
const cveIndexPath = "notes/cve_index.md"
buf := make([]byte, 1<<20)
pathBytes := []byte(cveIndexPath)
n := stadoFsRead(
uint32(uintptr(unsafe.Pointer(&pathBytes[0]))), uint32(len(pathBytes)),
uint32(uintptr(unsafe.Pointer(&buf[0]))), uint32(cap(buf)),
)Manifest: "capabilities": ["fs:read:."]. Run with
stado tool run --workdir=$PWD <tool> ... so notes/cve_index.md
resolves against the operator's repo, not the plugin's install
dir.
Use net:http_request[:<host>] + stado_http_request (the legacy
stado_http_get / net:http_get caps are removed). A disk cache is
just fs:read / fs:write on a cache directory plus HTTP request caps
for the upstream host.
Tools that take more than ~2 seconds should emit progress so the
operator sees they're alive. The stado_progress import is a
no-cap, fire-and-forget operator-visibility channel:
//go:wasmimport stado stado_progress
func stadoProgress(textPtr, textLen uint32) int32
// Inside your tool:
msg := []byte(fmt.Sprintf("checking host %d/%d", i, total))
stadoProgress(uint32(uintptr(unsafe.Pointer(&msg[0]))), uint32(len(msg)))The TUI surfaces these as PROGRESS [plugin] text lines in the
sidebar; stado tool run prints them to stderr. No capability
needed; payload bounded to 4 KiB. The model only sees the final
tool result — progress is operator UX, not agent input.
stado_json_get extracts one value from a JSON document by dotted
path; saves ~50 KiB of bundled parser per plugin and runs at
native speed. Useful for picking one field out of an HTTP response:
//go:wasmimport stado stado_json_get
func stadoJSONGet(jsonPtr, jsonLen, pathPtr, pathLen, outPtr, outMax uint32) int32
// Pull "user.id" out of an API response.
out := make([]byte, 256)
n := stadoJSONGet(
uint32(uintptr(unsafe.Pointer(&body[0]))), uint32(len(body)),
uint32(uintptr(unsafe.Pointer(&pathBytes[0]))), uint32(len(pathBytes)),
uint32(uintptr(unsafe.Pointer(&out[0]))), uint32(cap(out)),
)
// out[:n] = `"alice"` (canonical JSON; strings keep quotes)Path syntax is dotted with array indices: user.tags.0. No
capability needed.
stado_instance_* is a per-Runtime in-memory KV store. State
survives across calls within one stado process; cleared at session
end. Per-plugin namespaced — you can't read another plugin's keys.
// Capabilities: state:read, state:write
sdkSet("session_token", tokenBytes)
sdkGet("session_token") // returns the bytes, or nilBound: 1 MiB per value, 16 MiB per plugin. For state that needs
to survive a stado restart, use the operator secret store
(stado_secrets_*, capability secrets:read[:<glob>] /
secrets:write[:<glob>]).
# config.toml
[tools]
overrides = { webfetch = "webfetch-cached-0.1.0" }When [tools].overrides is set, the bundled webfetch is
replaced by your installed plugin. The LLM sees one tool named
webfetch; the agent runtime routes to the plugin instead of the
built-in implementation. This is the strongest way to deploy a
plugin — the plugin doesn't need to be in the LLM's prompt, it's
just the webfetch.
stado's git trace ref (refs/sessions/<id>/trace, EP-0004) records
every tool call as part of the session's audit log. This invariant
is non-negotiable. Any code path that mutates session state must
commit through the trace path so the audit log stays complete; a
silent bypass — even an "optimisation" that batches commits or a
"convenience" that side-steps a write — voids the audit guarantee
for that session.
The session-compaction work briefly broke this invariant by accident; it was caught + remedied. The reminder is forward-looking: when you write a plugin (or, more importantly, when you propose a new core feature), ask:
- Does this mutate session state? (file writes inside the worktree;
fork rpc; memory-store appends from
memory:writecapability; llm:invoke responses) - If yes, does the mutation flow through the existing
trace-committing path? (
stadogit.Session.Commit, the agent loop's tool-call wrapper, etc.)
If the answer is "yes, mutates" + "no, bypasses trace", the design
is wrong. Plugins inherit this discipline by default — the host
imports that mutate (stado_fs_write, stado_exec_bash via the
agent loop, stado_session_fork, stado_llm_invoke) all commit
through the trace. A plugin that wires up its own out-of-band
mutation channel (e.g., shells out via stado_exec_bash to a
network sink that writes elsewhere) is the regression vector.
Capability gating bounds what a plugin can do; the trace ref
records what it actually did.
For operator-tooling commands (gc, doctor, install) the trace-ref invariant doesn't apply — they're operator actions, not agent actions, and they live outside the per-session ref namespace. But any new agent-callable tool (whether bundled, plugin, or MCP) must respect this.
For a plugin you didn't write (a teammate's, an example from foobarto/stado-plugins, or one you forgot the details of):
stado plugin doctor <id> # surfaces + capabilities + suggested invocation
stado plugin verify <plugin-dir> # signature + sha256 + rollback verificationdoctor is the operator-facing UX — it tells you what to do with
the plugin. verify is the security-facing UX — it tells you
whether to trust the plugin.
| Error | Cause | Fix |
|---|---|---|
plugin host has no tool runtime context |
Historic error when a plugin imported a bundled tool without --with-tool-host (EP-0028) |
No longer reachable: stado tool run always attaches the tool host (EP-0038). |
stado_fs_read failed: ... no such file or directory |
Capability is fs:read:. but Workdir resolved to the install dir, not your repo |
Pass --workdir=$PWD. EP-0027. |
verify: rollback detected — manifest <new> < last seen <old> |
You're trying to install an OLDER version after a newer one | Bump the version. Rollback protection is intentional — once a higher version exists for a signer, lower versions can't be reinstalled. |
installed: trust: signer <fpr> not pinned |
First local install from this signer | stado plugin trust <pubkey-hex> "<name>" then retry. Remote installs use owner anchor TOFU — see plugin.md. |
Plugin works one-shot via tool run but not in TUI |
TUI loads plugins listed under [plugins].background or invoked via /plugin:<id>; [tools].overrides is what you want for replacing bundled tools |
Add the override to config.toml. |
docs/plugins/abi-reference.md— systematic ABI reference (memory model, return-code conventions, typed handles, JSON envelope, capability vocabulary index, manifest schema, lifecycle). Read this end-to-end once when you start writing plugins.docs/plugins/host-imports.md— function-by-function reference for every wasm host import (~70 in total), grouped by Tier, with capability gates and ABI signatures. The first place to look when "I need the WASM tool to do X but the host only exposes Y."docs/commands/plugin.md— exhaustive per-command reference.- EP-0002 — why every tool is a plugin (architecture rationale).
- EP-0006 — the signing + verification protocol.
- EP-0027 — repo-root
discovery and why
--workdirexists. - EP-0028 — historical note on
--with-tool-host(now default); PTY/exec plugins need TUI/stado run. SECURITY.md— plugin-publish cookbook, trust model, distribution recommendations.- foobarto/stado-plugins — opt-in
example plugins covering
hello,hello-go,session-inspect, theapproval-*-gofamily for bundled-tool approval wrappers, andsession-recorder.