Plugin README Standard
Every agent plugin carries its own documentation beside its code: agents/plugins/<name>/README.md. A reader must be able to learn how a plugin works, on which operating systems, what it calls, and what data it sends and receives, without opening the source. The same file is the source of a machine-readable manifest the server serves to agentic clients over REST and MCP (rule 10), so its shape is a contract, not a style preference.
The skeleton to copy is docs/templates/plugin-README.md. The generator is tools/plugin-doc-gen/plugin_doc_gen.py; the capture tool is tools/plugin-capture. The gate is tests/test_plugin_readmes.py (meson docs suite) plus the plugin-readme-touch-rule job in .github/workflows/docs-lint.yml.
The standard, in ten rules
-
One
README.mdper plugin directory —agents/plugins/<name>/README.md. It is the plugin’s single human-facing document.docs/user-manual/agent-plugins.mdcarries a generated index that links here; the retrospective sweep moves its remaining per-plugin prose into the READMEs, after which the catalog carries none of its own. -
Eight sections, fixed order, fixed headings (the section contract below). A reader finds the same thing in the same place on every plugin page. All eight are mandatory. A section with nothing to say states that in one line (“Neither action takes parameters”) and is never omitted.
-
Generated fences are never hand-edited. Everything between
<!-- BEGIN GENERATED: plugin-doc-gen <block> -->and<!-- END GENERATED -->is emitted by the generator from code-adjacent sources: the CI-verified capability-matrix block indocs/os-capability-matrix.md(the plugin’s declared per-OS legs),content/definitions/<name>.yaml,server/core/src/capability_decls/*.hpp, and the plugin directory itself. CI byte-diffs the fences. To change one, change its source and regenerate. -
Hand-written sections describe the code as it is today, not the intent. Present tense. “Planned” and “will” appear only under Caveats and known gaps. A claim that cannot be verified by reading the cited source, or by running the capture, does not go in.
-
Sample output is a real capture on every leg the descriptor declares
supportedorconstrained, produced byplugin-capturethrough the agent’s realLocalDispatcher, stored atagents/plugins/<name>/docs/samples/<os>.txtand spliced by the generator. The first line is the stamp:captured: <os> <os-version> · <host-class> · <date> · <privilege> · leg-hash <hash>. Host class isbare-metal,vmorcontainer. Unsupported legs show the placeholder row the plugin actually emits. A leg that cannot honestly be captured is recorded as== action=<name>followed by one line[not captured] <class>: <reason>and no result-status line, where<class>is one of<DispatchClass>/<Mutability>(a destructive mutator that must never run on a live host — the gate cross-checks this class against the plugin’s own capability row),agent-context(the plugin’sinitneeds the agent’s KV store, trigger engine or server, whichplugin-capturedoes not provide — its own stderr says so), orhardware-absent(the leg needs hardware none of the capture hosts has, e.g. a Wi-Fi adapter in a container); the generator and the gate accept that marker as the sample. The latter two classes are not independently verified — nothing stops a lazyagent-contexton a leg that would in fact capture cleanly — so a reviewer sees the reason string and judges it the way any other claim in the README is judged (rule 4). Every other action the descriptor declaressupportedorconstrainedon that OS must appear in the file — the gate checks per leg, not per file. No fabricated rows, ever. Captures are taken only on hosts the contributor owns or is explicitly authorised to publish from — a personal machine, a lab host, a throwaway container — never a customer, pilot or production fleet host, and never a colleague’s machine without their agreement. A capture is published under the repository licence and served to everyInfrastructure:Readholder, so treat every row as public: host identifiers a device carries (volume GUIDs, drive models, installed-application rule names) may stay; secrets and personal data (account names, e-mail addresses, user-profile paths, anything tying a device to a person) are redacted, never a row’s structure, and the redaction is noted in the stamp’s privilege field. That field names a role or an id (euid 0,interactive user (elevated),NT AUTHORITY\LOCAL SERVICE), which is whatplugin-capturemeasures by default — never a person.Personal-data guard (CI-enforced,
docs-lint).scripts/ci/check-personal-data.pyscans the committed content of tracked files (read from git, so a symlink’s target string is scanned too; binary files also as UTF-16) and fails the PR on a new hit. NOT scanned:governance.d/(append-only),CHANGELOG.md(never edited), generated*.pb.*, vendored trees, blobs over 20 MiB, and submodules; every run prints a one-line count of skipped files by reason (excluded,oversize,unreadable,submodule). Output carries onlypath:lineand the detector, never any part of the value. Detectors:home-path(/Users/<u>/,/home/<u>/,C:\Users\<u>\; synthetic names such asjsmith,runner,Publicare allowed everywhere; persona names such asaliceandbobare allowed only undertests/unit/outsidefixturesandsamples),windows-sid(useS-1-5-21-1111111111-2222222222-3333333333),tailnet-ip(use100.64.0.xor100.100.100.100;fd7a:115c:a1e0::N),mac-address(samples, fixtures, unit tests, plugin READMEs andcontent/plugin-docs/only; use00:00:5e:00:53:xxor a locally administered02:..),private-key,personal-email(consumer mailbox domains; useexample.com) andtracked-session-artifact(.claude/plans/,.serena/,.claude/projects/). Two reviewed escape hatches: a same-linepersonal-data-ok: <reason>marker (reason of at least 10 characters), or an entry inscripts/ci/personal-data-baseline.json(pre-existing hits as an allowed count per path and detector,max_hits; no value or hash of a value is stored, because a hash of an IP, MAC or short username is brute-forceable). A file fails when its hit count exceedsmax_hitsor it has hits and no entry. Known, accepted limit: a baselined file can swap one value for another of the same detector without failing, until the baseline is emptied; the baseline is meant to shrink to empty, never grow. Regenerate with--update-baseline; fewer hits thanmax_hitsis reported as stale and fails under--strict-baseline. CI runs--strict-baseline --ratchet, so a PR that removes a baselined hit must also lower that entry, and the baseline may only shrink against its merge-base with the PR’s target branch: a baselined file that git detects as renamed (git diff -M, 50% similarity by default) keeps its allowance under the new path, and the per-path, per-detector count still may not grow; a move git does not detect as a rename (heavily rewritten, a copy, or past the rename limit) is a new entry, so fix the hit or mark the line withpersonal-data-ok:instead. The full allowlists are documented in the script header. Self-test:tests/test_check_personal_data_selftest.py. -
The leg-hash on a capture stamp must match the current legs and column schema. The generator computes it from the plugin’s declared legs (support, rung, mechanism per action per OS) and its definitions’
result.columns(name, type); the descriptor’s fallback prose andspec.parametersare excluded, so a wording edit or a new parameter never forces a recapture. A mismatch fails the gate until the leg is recaptured:plugin-capturewritesleg-hash pending,plugin_doc_gen.py --stamp <name> <os>writes the current hash, and the next--allsplices it.--stampcannot tell a fresh capture from a stale one — it trusts the author, and a reviewer checks the capture date.leg-hash pendingis accepted with a warning for a hand-authored capture that predates regeneration. Plugin versions are not used as the freshness signal because they are bumped inconsistently. -
Touch rule. A pull request that changes
agents/plugins/<name>/src/**must also changeagents/plugins/<name>/README.md, or carry a linedocs-unchanged: <section it would have touched> — <reason>in its body, outside any code fence or HTML comment so the reviewer sees it on the rendered page. Theplugin-readme-touch-rulejob prints the override; it is advisory until the repository owner adds it to the required checks (docs/ci-architecture.md), so a reviewer reads its log as part of the review. A plugin that has no README yet is exempt until it gains one; the README-existence ratchet intests/test_plugin_readmes.pygoverns that count and may only shrink. -
Output field tables come from the definition YAML.
spec.result.columns[]accepts four optional keys documented indocs/yaml-dsl-spec.md:description,values(the closed vocabulary a string column may carry),example, andplatforms(a list drawn fromwindows,linux,darwin). The generator validates their shapes and fails--all/--checkon a scalar where a list was meant, an unknown platform, orvalueson a non-string column. A parameter’svalidationblock (enum, pattern, length and range bounds) renders into the Inputs table’s Constraints column. A missing key renders as-. The server ignores keys it does not read. -
Regenerate with one command and no build.
python3 tools/plugin-doc-gen/plugin_doc_gen.py --allrewrites every README fence, the catalog index, the site navigation fragment and the manifests from the committed sources;meson compile -C <builddir> docs-regenwraps it. Only captures need a built plugin, and only on that leg’s operating system. The capability-matrix block itself is regenerated on its canonical Linux host, as before — and first: the generator reads that block, not the binaries, so after a per-OS leg change regenerate the block on Linux, then run--all, or the READMEs and leg-hashes will match a stale block and the two gates will disagree.--checkruns every generator-side check (fence byte-diff, section shape, per-leg sample coverage, leg-hashes); the docs suite adds the README-existence ratchet and the manifest-key binding on top. -
The README is the source of the machine manifest; the eight headings are the parse contract. The generator writes
content/plugin-docs/<name>.json(manifest_version1) from the generated blocks and from the hand-written sections, which it locates by heading. The server embeds those files at build time and serves them asGET /api/v1/discover/plugin-docsand the MCP resourceyuzu://plugin-docs, and joins a per-plugin summary intodiscover_plugins. Renaming a heading, or nesting a section differently, breaks that parse and fails the gate. Hand-written tables are parsed positionally, so a literal pipe inside a cell is written\|(GFM’s escape); the gate checks the contracted column count of the privileges (5) and result-status (4) tables. There is deliberately no dedicated MCP tool for the manifests: the REST route + byte-identical resource pair follows the specs-as-resources precedent (yuzu://scope-dsl), and the tool-level entry point is thedocssummarydiscover_pluginsalready carries. A per-plugin resource (rather than the one whole-catalog body) shipped once the retrospective sweep gave real size data to design against:GET /api/v1/discover/plugin-docs/{name}and the MCP resource templateyuzu://plugin-docs/{name}(#4108), sharing the whole-catalog builder’smanifest_by_nameindex.
Section contract
| # | Heading | Kind | Source | Content rule |
|---|---|---|---|---|
| 1 | (title block) # <name> + property table | GENERATED header | descriptor identity (name, version, description) from the plugin source; actions and per-OS support from the capability-matrix block; securable, operation, risk, dispatch class and gate from capability_decls; definition ids, roles and gather mode from the YAML | What it does · Version · Kind (collector or action; on-demand or gathered) · Platforms (✅ supported · 🟡 constrained or planned · ⛔ unsupported, per OS) · Actions with definition ids · Security · Roles |
| 2 | ## How it works | HAND | author | 5–10 lines, present tense: what each action reads or changes, in what order, and what it is deliberately not (scope rulings). One mermaid flowchart LR from trigger → OS call per leg → rows and status → server store → API. |
| 3 | ## OS capability | GENERATED capability | capability-matrix block | Compact action × OS matrix (support · rung · mechanism), then the declared limits per leg verbatim as a bullet list. |
| 4 | ## Privileges and prerequisites | HAND | docs/agent-privilege-model.md row and the leg source headers | A table with columns OS · Runs as · Extra grant needed · Measured · If the read is refused and one row per OS (Windows, macOS, Linux — “n/a” where the leg does not exist), then one line naming binaries, subprocesses and network use, or “none”. The gate checks the three rows and the five-cell width. |
| 5 | ## Data contract | GENERATED inputs + outputs, HAND subsections | YAML spec.parameters; YAML result.columns with the optional keys; hand tables for ### Result status and ### Where the data goes | Subsections in this order: ### Inputs (fence), ### Outputs (one hand paragraph on row format and placeholders, then the fence), ### Result status (hand table Status · Completeness · Provenance · When — one row per status/completeness pair the plugin can report, listing every provenance literal it emits for that pair, so a consumer can key on the column; the gate reads every token passed to a status call in the plugin’s sources — <os>:<token>, <plugin>:<token>, the literal prefix of a formatted token — plus the subprocess_runner:* tokens of runner_status.hpp when the plugin includes it, and fails on one the table does not name), ### Where the data goes (hand bullets, at least two: the instruction result with its store and retention, Not consumed by for daily-sync, Echo, DEX and metrics, a **Sensitivity.** bullet saying what in the rows could identify a device, a person or installed software (the gate checks it is present), and siblings). |
| 6 | ## Sample output | GENERATED samples | docs/samples/<os>.txt files written by plugin-capture | One block per OS, stamped. The generator trims each action to 12 rows and says so. |
| 7 | ## Caveats and known gaps | HAND | author, review threads | One to five numbered items, each opening with a bold lead and one or two sentences (the gate checks the count and the lead). Rejected mechanisms and “do not reintroduce” invariants live here. |
| 8 | ## Source and tests | GENERATED source | plugin directory, definition YAML, capability fragment, tests, privilege-model row, changelog fragments | Paths only, one per line. |
Tone: concise plain English readable at senior-executive level, present tense, no emojis in prose (the ✅ 🟡 ⛔ legend is the one exception, matching the capability matrix), no attribution lines.
Manifest schema (content/plugin-docs/<name>.json, manifest_version 1)
| Key | From | Shape |
|---|---|---|
manifest_version, name, version, description | header | scalars — read from the checked-out tree only, never from git history, so the byte-gate is host-independent |
kind | capability rows | {"collector": bool, "mutating": bool, "gathered": bool} |
platforms | capability-matrix block | {"windows"|"linux"|"macos": "supported"|"constrained"|"planned"|"unsupported"} (best support across actions) |
security | capability_decls | [{"action","securable","operation","risk_tier","dispatch_class","mutability","execute_gate"}] |
actions | capability-matrix block + YAML | [{"action","definition_ids":[…],"legs":{"<os>":{"support","rung","mechanism","fallback"}}}] |
definitions | YAML | [{"id","display_name","description","platforms","approval_mode","execute_roles","author_roles","gather"}] |
inputs | YAML spec.parameters | [{"definition_id","name","type","required","default","constraints","description"}] — constraints is the parameter’s validation object verbatim, or null |
outputs | YAML result.columns | [{"definition_id","columns":[{"name","type","description","values","example","platforms"}]}] |
how_it_works | README §2 | text with the mermaid fences removed, ### subsections included (everything up to the next ##) |
outputs_note | README §5 ### Outputs hand paragraph | text: the row format, discriminator field and placeholder convention a consumer needs to map samples[].rows onto outputs[].columns |
privileges | README §4 table | [{"os","runs_as","grant","measured","if_refused"}] |
result_status | README §5 table | [{"status","completeness","provenance","when"}] |
where_the_data_goes | README §5 bullets | [string] |
samples | samples files | {"<os>": {"stamp": {...}, "actions": [{"action","params","rows":[first 5],"row_count","result_status","not_captured","truncated","rc"}]}} |
caveats | README §7 | [string] |
source | source block | {"plugin":[…],"definitions":[…],"capability_rows":[…],"tests":[…],"privilege_row":bool,"changelog":[…]} |
readme | path | agents/plugins/<name>/README.md |
leg_hash | computed | the 12-hex digest of rule 6, so a consumer can tell which legs a sample stamp was taken against |
Capturing on each OS
Every capture is the built plugin-capture binary run on the OS it stamps (tools/plugin-capture/, target plugin-capture, built with -Dbuild_agent=true, the default). It measures the OS version and the privilege itself; pass --os-version/--privilege only to override.
- macOS and Windows — from the build tree of that host:
build-<os>/tools/plugin-capture/plugin-capture build-<os>/agents/plugins/<name>/<name>.{dylib,dll} --os <os> --host-class bare-metal --action <a> [--action <b>] [--param k=v] --out agents/plugins/<name>/docs/samples/<os>.txt(on Windows,build-windows\agents\coremust be onPATHfor the agent-core DLL). - Linux — a throwaway image built from the agent Dockerfile’s builder stage, which also builds the tool:
docker build -f deploy/docker/Dockerfile.agent --target builder --build-arg TRIPLET=<arch>-linux -t yuzu-agent-capture ., thendocker run --rm yuzu-agent-capture sh -c 'export LD_LIBRARY_PATH=/src/builddir/agents/core; /src/builddir/tools/plugin-capture/plugin-capture /src/builddir/agents/plugins/<name>/<name>.so --os linux --host-class container --action <a> --out /tmp/o.txt; cat /tmp/o.txt' > agents/plugins/<name>/docs/samples/linux.txt. A leg that reads the host’s network state needs--cap-add NET_ADMIN --network hostand says so in the stamp’s privilege field (pass--privilege "euid 0 with CAP_NET_ADMIN in the host network namespace"). A leg that needs hardware the container cannot see (Wi-Fi, Bluetooth, a battery) is recorded[not captured] hardware-absent: <reason>. - Then
python3 tools/plugin-doc-gen/plugin_doc_gen.py --stamp <name> <os>for each capture,--all, and--check.
Authoring a README before the generator has run
Fill each fence from its named source exactly as the generator will: the header from the plugin source and the capability fragment; the capability matrix from your own kActionDescriptors (support, rung, mechanism; fallback verbatim); inputs and outputs from your definition YAML (add the four optional column keys while you are there); samples from a real run through LocalDispatcher on each supported OS, stamped with leg-hash pending. The next --all run replaces the fences byte-for-byte and fills the hash.