Skip to main content

Module agent_guide

Module agent_guide 

Source
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 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:

ChannelUse 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.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).
  • 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). 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.
  • 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.

§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)

  • 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

PatternExample
Zero-height instance rows sized from eventsissue-counts/ui/src/lib.rs (size_bars)
Idempotent publish that retries after a failed writeissue-counts/daemon/src/lib.rs (Inner::publish)
Child process hygiene: PATH, timeout, cap, stderrissue-counts/daemon/src/lib.rs (Inner::run, last_line)
One retry, then wait for a change or Refreshgit-status/daemon/src/lib.rs (RETRY_MS)
Restart-safe read guardquota-meters/daemon/src/lib.rs
Reading only while someone looksresource-monitor/daemon/src/lib.rs, quota-meters/daemon/src/lib.rs
File watches with a trailing debouncegit-status/daemon/src/lib.rs
Singleton daemon, live keys per itembuilds-monitor/daemon/src/lib.rs
Column opened from project rowsissue-counts/ui/src/lib.rs (browser)
Popover forms, host caret, draftsissue-counts/ui/src/lib.rs (filing, setup), issue-counts/ui/src/text.rs
Hover panel over machine rowsresource-monitor/ui/src/lib.rs
Borderless card with a details popoverquota-meters/ui/src/lib.rs
Pane and child pane (over) surfacesbuilds-monitor/ui/src/lib.rs
Theme palette and tintsissue-counts/ui/src/draw.rs, resource-monitor/ui/src/render.rs
Nerd and Legacy Computing glyphsgit-status/ui/src/render.rs, quota-meters/ui/src/meter.rs
Stage in pixels with a cells fallbackflappy-mux/src/lib.rs (AnySurface)
Pane overlay from a pane commandpong/src/lib.rs, pong/bundle/standard-plugin.json
Tests with Harness and DaemonHarnessissue-counts/ui/src/tests.rs, issue-counts/daemon/src/tests.rs, pong/src/tests.rs