# 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](getting-started.md),
[the manifest](manifest.md) and [working together](working-together.md)
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 is `no_std` and has no WASI.
- **The daemon half** (kind `companion`, same id, or kind `daemon` alone)
runs inside `standardd`, 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:
| 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](grants.md#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.before` rows start at `height: 0`, so no surface
was visible. The plugin sized its rows inside `frame()`, and the host
calls `frame()` 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`). Use `Instances::instance(owner).request_size(0, rows)`,
which works before the viewer has shown the instance. Paint in `frame()`.
A commit made in `event` also needs `cx.request_frame()`
([rendering](rendering.md#cadence)).
- **Example:** `issue-counts/ui/src/lib.rs`, `Issues::size_bars`, called
from `activate` and from the `ValueChanged` arm of `event`.
### 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().set` failed, 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). Make `publish()` idempotent: build
the record, compare it to the last written one, write only when it
differs, and store it only when the write returns `Ok`.
- **Example:** `issue-counts/daemon/src/lib.rs`, `Inner::publish` and its
`written` field.
### 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. A `panel.hover` names its target in
`hovers` and the host places it. Never compute or cache a popover's
position.
- **Example:** `quota-meters` (the card opens `details`),
`issue-counts` (the bar's icon and the column's New issue button open
`filing`), `resource-monitor` (`detail` hovers `resources`).
### 4. One `column` at a time
- **Cause:** a `column` is like the `stage`: 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`, the `browser` column opened
from each project's `bar`.
### 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 to `HOME`, `USER`,
`LOGNAME`, `PATH`, `LANG`, `LC_*`, `TMPDIR` and `SHELL`
([daemon plugins](daemon-plugins.md#identity-and-environment)). Shell
activation variables are absent. On Omarchy, `~/.local/bin/gh` is a stub
that runs `mise x`; without mise's activation it resolved back to itself
and looped.
- **Do this** for every `process.exec` child:
- Do not depend on shell activation, aliases or rc files. Extend `PATH`
with 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`,
...) with `env_remove`.
- **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`, the `guarded` read.
### 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_at` for 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, with
`cx.next_event_until(deadline)` so an event cancels the wait
(resource-monitor every 5 s, quota-meters every `refreshSeconds`).
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 `cwd` is the directory its shell or agent
works in when the daemon knows it, else the project root. It changes as
`PaneChangeKind::Changed`.
- **Do this:** key readings by folder. On a `changed` pane,
compare its `cwd` with the folder you used and move the pane to the new
folder's reading. Never read `cwd` once at creation and keep it.
- **Example:** `git-status/daemon/src/lib.rs`, the `PaneChanged` handling
around `publish_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)))` (or
`cx.set_caret(id, ...)`) on every repaint of the field, and
`set_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](input.md#text-fields-and-the-caret).
- 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` (the `filing` and `setup`
popovers), `issue-counts/ui/src/text.rs`, and the tests
`enter_files_the_description_and_shift_enter_starts_a_line` and
`text_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 with
`standard_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)`, else `view::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.rs` `paint_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-plugin`
world and nothing else: no WASI, no `std` imports, one core module as
`rustc --target wasm32-wasip2` builds with the SDK.
`standard-plugin build` also writes a jco build to `bundle/browser/`
when `jco` is installed, to inspect the component's JavaScript form;
viewers do not run it.
- Without JSPI, `calls().call` answers `unavailable`. Use `send` or
`call_async`, which work in every browser.
- `view::machine_id()` is `None` in 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](working-together.md#running-in-several-viewers).
## Pre-publish checklist
**Tests** (`standard_plugin::testing`, `cargo test` in each half)
- [ ] A UI test activates the plugin with `Harness::activate` and checks
what a zero-height anchor asks for before any `frame()`.
- [ ] A daemon test starts with no interest and asserts `drive` returns
`None` (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) and `pack` succeed.
- [ ] Versions match in every `standard-plugin.json` and `Cargo.toml`.
- [ ] No committed `Cargo.lock` carries a local path patch.
**Both viewers** (how to load a bundle in each:
[testing](testing.md#in-the-viewers))
- [ ] 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
`check` reports no unused grant.
- [ ] Effects happen once: in the daemon half, or behind a gesture,
`is_driving()` and a claim.
## Where each pattern lives
| 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` |