Expand description
§Agent guide: writing a good plugin on the first try
This page is for coding agents that write Standard Code plugins. It gives the model to hold in mind, the mistakes real plugins made, a checklist to run before publishing, and the example that shows each pattern. The other guides are the reference; read getting started, the manifest and working together before you write code.
The pitfalls below cite files of the first-party plugins by their paths
in standardagents/standard-code-plugins, a private repository: read them
as pointers to where a pattern was learned, not as code to open. The
public, compiled counterparts are this crate’s examples/ (every pattern
the other guides show), which cargo build --examples --target wasm32-wasip2 builds and standard-plugin new scaffolds from.
§How to think about a plugin
§Two halves
- The UI half (kind
ui) runs in every open viewer: each native terminal viewer and each browser tab signed in to the account. It paints surfaces and reads shared state. It must be side-effect free, because three open viewers run it three times. It isno_stdand has no WASI. - The daemon half (kind
companion, same id, or kinddaemonalone) runs insidestandardd, the Standard Code daemon. It runs programs, watches files, reads panes and makes HTTP requests, each under a grant. Its placement decides how many copies run:fleet(the default): one per machine. Use it for work that belongs to a machine: its panes, its folders, its CPU (git-status, issue-counts, resource-monitor, quota-meters).singleton: one per account, on the machine that holds the lease. Use it for one outside source every viewer shares (builds-monitor reads one build coordinator).
- The UI half asks the daemon half to act with
calls(), only for what the user did. Everything else flows the other way, as values, live messages and events.
§Anchors: you name the place, the host owns place and size
A surface names an anchor (sidebar.card, project.before,
machine.after, pane.footer, column, panel.popover, stage, …).
The viewer decides where it goes and how big it is. The plugin can ask for
a size with request_size; the viewer clamps it and answers with
Event::Resize. Instance anchors (project.*, machine.after,
pane.*) have one instance per owner, <surface>@<owner>; use
Instances<Cells> to follow them. A plugin never computes screen
positions and never assumes what is next to its surface.
§Only data crosses the wire
The plugin paints into its own buffer; the viewer samples it. Between the halves and between viewers, only JSON data travels:
| Channel | Use it for |
|---|---|
Values (values()) | Durable state a viewer opened later must see: a project’s record, a draft, settings. |
Live (live()) | The newest reading only: one key per item, at most 16 KiB, withdrawn with live().delete when the item goes. |
Events (events()) | “Something changed” notices. A listener that is not running misses them. |
Calls (calls()) | The UI half asks the daemon to do one thing. Prefer send or call_async. |
The usual shape: the daemon writes a value and the UI half watches its
prefix. A viewer opened later reads the value in activate, and nobody
polls.
§Grants
Everything outside the plugin’s own <id>.* namespace is a grant with a
one-sentence reason the user reads at install. Ask for the narrowest grant
that works: process.exec:git covers a git reader. The UI half holds almost
none; call:<own id> and url.open:<host> are typical. A UI half that
listens to shared events and also holds an effect grant is the
double-play mistake: move the effect to the
daemon half.
§Pitfalls from real plugins
Each entry gives the symptom a user saw, the cause, and the pattern to use.
§1. A collapsed anchor never gets frame()
- Symptom: issue-counts showed nothing under any project.
- Cause: the
project.beforerows start atheight: 0, so no surface was visible. The plugin sized its rows insideframe(), and the host callsframe()only while a surface is visible. Nothing ever asked for a row. - Do this: size zero-height anchors from
activate()and from every event that changes what they show (ValueChanged,Live,AccountChanged). UseInstances::instance(owner).request_size(0, rows), which works before the viewer has shown the instance. Paint inframe(). A commit made ineventalso needscx.request_frame()(rendering). - Example:
issue-counts/ui/src/lib.rs,Issues::size_bars, called fromactivateand from theValueChangedarm ofevent.
§2. Writes fail until the account connection opens
- Symptom: a project’s issue record never appeared after a daemon restart.
- Cause: the daemon starts before its account connection opens.
Its first
values().setfailed, and the plugin never wrote again because nothing had changed. - Do this: treat a failed write as not written. Keep the last record
that succeeded, and call
publish()again on later events (account changes, interest, the next reading). Makepublish()idempotent: build the record, compare it to the last written one, write only when it differs, and store it only when the write returnsOk. - Example:
issue-counts/daemon/src/lib.rs,Inner::publishand itswrittenfield.
§3. Popovers and hovers belong to their opener
- Symptom: a popover drawn at a computed spot sat on top of an open column, or detached from the button that opened it.
- Cause: the plugin placed it from its own idea of the layout, which assumed no column was open.
- Do this: open popovers with
cx.open(id)while you handle the press or key that asked for them. The host records that surface as the opener and places the popover beside the sidebar card or row, or under the pressed cell in a column or stage. Apanel.hovernames its target inhoversand the host places it. Never compute or cache a popover’s position. - Example:
quota-meters(the card opensdetails),issue-counts(the bar’s icon and the column’s New issue button openfiling),resource-monitor(detailhoversresources).
§4. One column at a time
- Cause: a
columnis like thestage: one shows per viewer across every plugin. Opening another column slides the open one shut first. - Do this: handle
Visibility { visible: false }on your column at any time and keep its state so it reopens where it was. A second open from the same row closes it; an open from another row moves it there and takes that row’s project colour. Open it from the row’s press so the host records the opener. - Example:
issue-counts/ui/src/lib.rs, thebrowsercolumn opened from each project’sbar.
§5. Daemon children get a reduced environment
- Symptom: “Issues: Unavailable” and “gh did not answer within 60 s” on one machine, with a child process busy on a CPU core.
- Cause: children inherit the login
PATH, reduced toHOME,USER,LOGNAME,PATH,LANG,LC_*,TMPDIRandSHELL(daemon plugins). Shell activation variables are absent. On Omarchy,~/.local/bin/ghis a stub that runsmise x; without mise’s activation it resolved back to itself and looped. - Do this for every
process.execchild:- Do not depend on shell activation, aliases or rc files. Extend
PATHwith the usual install folders after the inherited ones. - Leave stdin at its default,
Stdio::Null, so no tool waits for input. Turn off prompts (GIT_TERMINAL_PROMPT=0,GH_PROMPT_DISABLED=1). - Pipe stdout and stderr, and cap how much you keep. Kill the child when it passes the cap.
- Race every child against a timeout (
cx.sleep(ms)), and kill it when the timeout wins. - Put the tool’s last non-empty stderr line in the error:
gh did not answer within 60 s. gh: <line>. - Remove variables that redirect the tool (
GIT_DIR,GIT_WORK_TREE, …) withenv_remove.
- Do not depend on shell activation, aliases or rc files. Extend
- Example:
issue-counts/daemon/src/lib.rs:child_path,Inner::run,last_line,GIT_ENV_REMOVE.
§6. Never retry a failing command in a loop
- Symptom: a broken tool (signed out, missing, looping) costs a CPU core or floods the log.
- Do this: after a failure, record the error, report it with
health::degraded(msg), show it in the UI, and stop. Read again on the user’s Refresh or on a real change. At most one delayed retry after the first failure of a read is acceptable (git-status waits 2 s once). A daemon that restarts mid-read must not read again at once: persist a guard before the read (quota-meters). - Example:
issue-counts/daemon/src/tests.rs,a_failed_read_names_what_gh_said_and_only_refresh_reads_again;quota-meters/daemon/src/lib.rs, theguardedread.
§7. Event-driven only
Plugins must not poll. React to pushes: ValueChanged, Live,
Plugin events, FileChanged, PaneChanged, Interest, CallResult.
These timers are the only accepted ones:
- A UI
frame.wake_atfor text that counts time on screen (a clock, an age, a running duration). It fires only while the surface is visible (builds-monitor, issue-counts ages). - A trailing debounce after a burst of changes (git-status: 120 ms per folder).
- Sampling a source that has no change event, only while
cx.interest()says a viewer shows the surface, withcx.next_event_until(deadline)so an event cancels the wait (resource-monitor every 5 s, quota-meters everyrefreshSeconds).
A UI half never keeps its daemon busy with a heartbeat or repeated
send. In tests, assert that an idle DaemonHarness::drive returns
None (no wake).
§8. A pane’s cwd can move
- Cause: a daemon pane’s
cwdis the directory its shell or agent works in when the daemon knows it, else the project root. It changes asPaneChangeKind::Changed. - Do this: key readings by folder. On a
changedpane, compare itscwdwith the folder you used and move the pane to the new folder’s reading. Never readcwdonce at creation and keep it. - Example:
git-status/daemon/src/lib.rs, thePaneChangedhandling aroundpublish_folder.
§9. Text input
- A field looks editable: a control background from the theme (a blend of the background toward the text colour), placeholder text in a muted tone, and a stronger background while it has focus.
- The first field has focus when the form opens.
- Enter submits. Shift+Enter starts a new line. A native viewer reports Shift on Enter only while the field’s surface has set a caret, so set it. Accept Alt+Enter too, because some terminals send Shift+Enter as plain Enter.
- Use the host caret:
surface.set_caret(Some((col, row)))(orcx.set_caret(id, ...)) on every repaint of the field, andset_caret(None)when no field has it. The viewer draws its own cursor there and its cursor trail moves into the field. Never draw a fake cursor. See input. - Escape closes the form; keep the draft (a value keyed by project, for example) so reopening restores it.
- Example:
issue-counts/ui/src/lib.rs(thefilingandsetuppopovers),issue-counts/ui/src/text.rs, and the testsenter_files_the_description_and_shift_enter_starts_a_lineandtext_fields_place_the_viewers_caret_and_the_setup_starts_in_a_prompt.
§10. Icons
- Use Nerd Font glyphs whose picture says what the control does: a
compose or note icon for “new issue” (issue-counts uses U+F1782);
a bare
+says too little. Use arrows for ahead and behind (git-status uses U+F062 and U+F063). - Write the standard Nerd codepoint. The host maps Nerd codepoints in plugin cell surfaces to its bundled Standard Code Symbols font where the terminal needs it. Do not ship your own font or pick codepoints by terminal.
- Follow every Nerd icon with a space cell:
"\u{f062} 2", not"\u{f062}2", and a space between two icons. Ghostty and kitty draw a private-use glyph at full size only when a plain space follows it; before text or another icon, Ghostty shrinks it to one cell. A no-break space counts as text. - Write Legacy Computing characters (U+1FB00 block) at their real codepoints. The host draws fallbacks where a terminal cannot draw them: the checkerboard U+1FB95 becomes the closest pattern that terminal can draw.
- Example:
git-status/ui/src/render.rs,resource-monitor/ui/src/render.rs,quota-meters/ui/src/meter.rs(EXCESS, the checkerboard).
§11. Colours
- Prefer theme slots (
Colour::FG,ACCENT,RECEDE_FG, …) and the terminal palette (Colour::Indexed) so the plugin follows the user’s terminal. - Blend from
view::theme()for surfaces and text tiers: muted and faint text are the foreground receded toward the background withstandard_plugin::recede. - “Grayed out” means receded toward the terminal background
(
theme.recede(rgb, GRAYED_OUT_PERCENT)), never a fixed gray. - Rows that belong to a project or machine carry its colour:
view::surface_tint(id), elseview::identity_tint(owner). - Fixed hues are fine for status tones (ok, warn, error) when their backgrounds are blended from the theme.
- Example:
issue-counts/ui/src/draw.rs(Palette),resource-monitor/ui/src/lib.rs(labels receded toward the machine’s tint),issue-counts/ui/src/lib.rspaint_bars(the project’s tint).
§12. One idea per row
Put each piece of information on its own line and keep related items
together. builds-monitor showed its Overview tab in the same row as the
per-target tabs, and it read as one more target; the fix gave Overview its
own line above the target row. Truncate to the width you have (a fit
helper), and check the narrowest sidebar and column widths.
Example: builds-monitor/ui/src/detail.rs.
§13. Browser viewer parity
The browser viewer runs the same component. Test in both viewers. Known differences:
- The browser runs only components whose UI half imports the
ui-pluginworld and nothing else: no WASI, nostdimports, one core module asrustc --target wasm32-wasip2builds with the SDK.standard-plugin buildalso writes a jco build tobundle/browser/whenjcois installed, to inspect the component’s JavaScript form; viewers do not run it. - Without JSPI,
calls().callanswersunavailable. Usesendorcall_async, which work in every browser. view::machine_id()isNonein a browser.- The browser reports exact pointer pixels; a terminal reports cell centres. Hit-test by cell.
- Shared slots (the sidebar’s plugin region, project rows, machine rows, pane footers) order plugins by plugin id in both viewers. Do not depend on your position relative to another plugin.
§14. Lockfiles and a local SDK patch
A [patch] that points standard-plugin-sdk at a local checkout (for
example .cargo/config.local.toml) rewrites every Cargo.lock it
touches and drops the registry or git source line. Never commit those
lockfiles. Before committing, move the patch aside, restore each changed
Cargo.lock, re-resolve with cargo metadata --format-version 1, and
check that each lock names the published source.
§15. Effects from the UI half
A UI half runs once per viewer. Send calls only while handling a gesture
(a key, press or command), which reaches one viewer. For any other effect,
move it to the daemon half, or act only when view::is_driving() and
inside claims().once(...). See working together.
§Pre-publish checklist
Tests (standard_plugin::testing, cargo test in each half)
-
A UI test activates the plugin with
Harness::activateand checks what a zero-height anchor asks for before anyframe(). -
A daemon test starts with no interest and asserts
drivereturnsNone(no wake, no child, no request). - A daemon test fails a write or a child and asserts the next event publishes again, and that no read repeats until Refresh.
-
Input tests cover Enter, Shift+Enter, the first focused field and
the caret (
host.carets).
Build
-
standard-plugin build,check(no warnings) andpacksucceed. -
Versions match in every
standard-plugin.jsonandCargo.toml. -
No committed
Cargo.lockcarries a local path patch.
Both viewers (how to load a bundle in each: testing)
- Every surface shows and works in the native viewer and in the browser viewer.
- Project rows show with the project expanded and collapsed.
- Popovers stay beside their opener with a column open and closed.
- Opening another plugin’s column closes yours, and yours reopens where it was.
- The layout holds at the narrowest sidebar and column widths; long text truncates.
- Colours read on a light and a dark theme; grayed text follows the background.
- Icons render in a terminal with Nerd glyphs and in one without.
- A space cell follows every Nerd icon, before text and between icons.
Behaviour
- No timer runs outside the accepted list in pitfall 7.
- Daemon reads stop when no viewer shows the surface.
- Every child has a timeout, an output cap, null stdin and an error that names the tool’s last stderr line.
-
Every error the user sees says what failed and what to do
(“Refresh to retry”, “sign in with
gh auth login”). -
Grants are the minimum, each reason is one plain sentence, and
checkreports no unused grant. -
Effects happen once: in the daemon half, or behind a gesture,
is_driving()and a claim.
§Where each pattern lives
| Pattern | Example |
|---|---|
| Zero-height instance rows sized from events | issue-counts/ui/src/lib.rs (size_bars) |
| Idempotent publish that retries after a failed write | issue-counts/daemon/src/lib.rs (Inner::publish) |
| Child process hygiene: PATH, timeout, cap, stderr | issue-counts/daemon/src/lib.rs (Inner::run, last_line) |
| One retry, then wait for a change or Refresh | git-status/daemon/src/lib.rs (RETRY_MS) |
| Restart-safe read guard | quota-meters/daemon/src/lib.rs |
| Reading only while someone looks | resource-monitor/daemon/src/lib.rs, quota-meters/daemon/src/lib.rs |
| File watches with a trailing debounce | git-status/daemon/src/lib.rs |
| Singleton daemon, live keys per item | builds-monitor/daemon/src/lib.rs |
| Column opened from project rows | issue-counts/ui/src/lib.rs (browser) |
| Popover forms, host caret, drafts | issue-counts/ui/src/lib.rs (filing, setup), issue-counts/ui/src/text.rs |
| Hover panel over machine rows | resource-monitor/ui/src/lib.rs |
| Borderless card with a details popover | quota-meters/ui/src/lib.rs |
Pane and child pane (over) surfaces | builds-monitor/ui/src/lib.rs |
| Theme palette and tints | issue-counts/ui/src/draw.rs, resource-monitor/ui/src/render.rs |
| Nerd and Legacy Computing glyphs | git-status/ui/src/render.rs, quota-meters/ui/src/meter.rs |
| Stage in pixels with a cells fallback | flappy-mux/src/lib.rs (AnySurface) |
| Pane overlay from a pane command | pong/src/lib.rs, pong/bundle/standard-plugin.json |
Tests with Harness and DaemonHarness | issue-counts/ui/src/tests.rs, issue-counts/daemon/src/tests.rs, pong/src/tests.rs |