Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Corvo is a lightweight native launcher built on Rust and GPUI. An extension is one launcher feature: an application search, a clipboard history, a pomodoro timer, a browser tab switcher. This book explains how extensions are structured and how to build one that passes review.

Three facts shape everything else:

  • Extensions are native Rust, not plugins. There is no runtime scripting or WASM. Every extension is a crate compiled into the binary, registered at link time. That is why Corvo stays fast and small — and why extension authors get the same tooling as core developers: cargo, clippy, unit tests, CI on three operating systems.
  • The UI layer is shared. Extensions do not draw their own windows. They return data (rows, actions, toasts) and the launcher renders it, so every extension looks and behaves like part of the app. Consistency is enforced by the type system, not by taste.
  • The patterns are the standard. The corvo-ext kit crate ships the recurring shapes — list rows and sections, action menus, TTL caches, preferences, routing — so an extension reads like the rest of the codebase instead of inventing its own plumbing.

Where to start

Reference

The Command trait and the workspace layout are specified in SPEC.md and AGENT.md. Rustdoc for the kit: cargo doc -p corvo-ext --open.

What extensions are

An extension is a Rust crate under commands/ in the corvo repository. It implements the Command trait from corvo-core, registers itself with one macro, and the launcher does the rest.

The anatomy

commands/<your-id>/
├── Cargo.toml        # package name corvo-<your-id>, deps: corvo-core, corvo-ext
├── src/lib.rs        # the Command impl, data loading, tests
├── README.md         # only if setup is needed (tokens, permissions)
└── CHANGELOG.md      # one entry per merged change

Registration is compile-time. In src/lib.rs:

#![allow(unused)]
fn main() {
corvo_core::register_command!(YourCommand);
}

The build script scans commands/*/Cargo.toml and links every crate into the binary automatically. You never edit src/main.rs.

What a command can do

You provideThe launcher provides
id, keywords, priorityplacement in root search
search(query) → rowsthe result list, fuzzy ranking, rendering
execute(result_id) → an Actionrunning the action (open, copy, shell, toast…)
actions(result_id) → a menuthe ⌘K actions panel
data, loaded off the search pathbackground task execution

The full contract is on The command contract.

What an extension must not do

  • Never block search. Disk, network, AppleScript, and CDP run in a background task behind a cache. search reads the cache.
  • Never depend on corvo-ui. Extensions produce data; only the launcher renders.
  • Never reach for raw OS APIs in the crate. OS work goes through corvo-platform, which owns every #[cfg(target_os)] in the workspace.
  • No telemetry, no analytics, no opaque bundled binaries. Corvo sends nothing anywhere, and extensions must not either.

The kit

crates/corvo-ext ships the shared patterns:

ModulePurposeRaycast analog
listrows, sections, open entries, empty statesList, List.Item, List.Section
actionsaction-menu buildersActionPanel, built-in actions
cacheTTL cache for off-path loadingCache, useCachedPromise
prefsper-extension TOML settingsPreferences
routingquery and result-id dispatchcommand lifecycle
feedbacktoasts and notificationsToast, HUD

Each module has a chapter under Module reference.

Build your first extension

This tutorial turns the template into a working extension. It takes about ten minutes and touches every part of the contract once.

1. Copy the template

cp -R templates/extension commands/wordcount
mv commands/wordcount/src/lib.rs commands/wordcount/src/lib.rs   # already correct

2. Rename it

In commands/wordcount/Cargo.toml:

[package]
name = "corvo-wordcount"

In commands/wordcount/src/lib.rs:

#![allow(unused)]
fn main() {
const ID: &str = "wordcount";
const KEYWORDS: &[&str] = &["wordcount", "count words"];
}

The id must equal the crate directory name. It namespaces every result id, the page query, and the preferences file.

3. Register it

Two lines in the root Cargo.toml — nothing else:

[workspace]
members = [
    # ...
    "commands/wordcount",
]

[dependencies]
corvo-wordcount = { path = "commands/wordcount" }

4. Replace the demo data

The template’s catalog is a static table. Make it yours — parse a file, read a database, or fetch an API. The one rule: loading runs off the search path, behind a TtlCache.

#![allow(unused)]
fn main() {
fn cache() -> &'static TtlCache<Words> {
    static CACHE: std::sync::OnceLock<TtlCache<Words>> = std::sync::OnceLock::new();
    CACHE.get_or_init(TtlCache::new)
}
}

search reads cache().get(TTL) — possibly None, that is fine. The launcher calls fetch_*-style warm-ups through smol::unblock; follow commands/weather for the shape. Only execute may do slow work inline (wrapped in smol::unblock).

5. Shape the results

Rows come from the List module:

#![allow(unused)]
fn main() {
ListItem::new(word)
    .subtitle("used 42 times")
    .icon(Icon::File)
    .accessory("42")
    .section("Frequent")
    .build(format!("{ID}:word:{word}"), score)
}

Consecutive rows sharing a section render under one header.

6. Route and act

execute strips the {id}: prefix and answers:

#![allow(unused)]
fn main() {
let key = routing::key(result_id, ID)?;
match key {
    "open" => Ok(feedback::toast("Wordcount")),
    key => match key.strip_prefix("word:") {
        Some(word) => Ok(Action::Copy(word.to_owned())),
        None => Err(CommandError::NotFound),
    },
}
}

7. Verify

cargo test -p corvo-wordcount
cargo clippy --workspace --all-targets -- -D warnings
cargo run            # the extension is already in root search

CI runs the same checks on Windows, macOS, and Linux, plus the template check.

8. Document and ship

  • Fill README.md if your extension needs setup (tokens, enabled services, permissions).
  • Add a CHANGELOG.md entry: ## [Count words in text] - 2026-10-03.
  • Open a pull request against main. The PR template carries the review checklist; the full standard is in Contribution standard.

The command contract

Every extension implements one trait, corvo_core::Command:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Command: Send + Sync {
    fn id(&self) -> &'static str;
    fn keywords(&self) -> &'static [&'static str] { &[] }
    fn prefix(&self) -> Option<&'static str> { None }

    async fn search(&self, query: &str, ctx: &SearchContext) -> Vec<SearchResult>;
    async fn execute(&self, result_id: &str, ctx: &ExecutionContext)
        -> Result<Action, CommandError>;

    fn actions(&self, result_id: &str) -> Vec<CommandAction> { Vec::new() }
    fn priority(&self) -> u8 { 50 }
}
}

Identity

  • id — kebab-case, equal to the crate directory name. It is the namespace of every result id, the page query, and the preferences file. One registry-wide rule: ids never collide.
  • keywords — words that route a query to this command. The first word of the query, lowercased, is matched against them.
  • priority — wins ties when two commands answer the same query. Default 50; raise only with a reason.

Result ids

Rows and results carry namespaced ids:

ShapeMeaningExample
{id}:openopens the command’s pageweather:open
{id}:{action}a concrete actionmedia:toggle
{id}:{action}:{data}an action with dataweather:open:oslo
{id}:emptyan empty-state hint rownotes:empty

The data segment travels inside the id, so keep it stable and executable. Data that may contain : (a URL) is fine as the last segment — split on the first one:

#![allow(unused)]
fn main() {
let Some((action, data)) = key.split_once(':') else { ... };
}

The search path

search runs on every keystroke. The rules:

  1. No blocking work. No network, no processes, no file trees.
  2. Read the cache. TtlCache::get is the only source of slow data; it may answer None before the first warm-up lands.
  3. Keep answers stable. Empty query → usually nothing (root search is shared); page queries ({id}-page:{filter}) answer from the cache and never launch anything.
  4. Sections stay contiguous. Sort by section, then score, or the UI cannot group them under one header.

The execute path

execute may do slow work — wrap it in smol::unblock:

#![allow(unused)]
fn main() {
return smol::unblock(move || do_slow_thing(&data))
    .await
    .then_some(Action::CloseWindow)
    .ok_or(CommandError::Platform("failed".into()));
}

Errors are values: CommandError::NotFound for unknown ids, CommandError::Platform(String) for OS refusals, CommandError::Unsupported for not-yet-here platforms. The UI surfaces them; never unwrap, never panic.

Caching discipline

  • TTLs: 5 s for fast-changing OS state, 15 min for network payloads is the house style (see weather).
  • Invalidate after a mutation the cache would hide (cache.invalidate()).
  • Fetch coalescing: the UI debounces and guards with search_seq; commands only need thread-safe caches.

Views

An extension does not draw. It returns data in one of the shapes below, and the launcher renders it — on every platform, identically. This is the view catalog; each shape maps to the launcher-platform vocabulary contributors may already know.

The entry point is Command::page(query) -> Option<PageView>: returning Some gives the command a dedicated page for {id}-page:{filter} queries (root search routes {id}:open[:args] there); returning None keeps the plain results list. Builders live in corvo_ext::pages.

The implicit default. search returns SearchResult rows with titles, subtitles, icons, accessories, and sections; the launcher renders, filters, and ranks them. See the List module.

Blocks — composed live pages

A vertical stack of typed blocks, with a refresh cadence. The pomodoro page is Badge + Hero countdown + Progress + Buttons ticking every second; the weather page is Badge + Hero + a three-day Strip.

BlockRenders
Badgesmall status pill with a semantic tone
Herothe dominant datum: glyph, headline, one very large value, quiet subtitle
Progressdeterminate bar, 0.0..=1.0
Markdownlauncher-subset markdown (headings, lists, bold, italic, code)
Striprow of small stat cards
Buttonsclickable controls; clicks run execute("{id}:page:{action_id}")

Refresh::Every(secs) keeps the page alive — the launcher re-asks page() at that cadence while it is open. Keyboard: ←/→ cycles buttons, Enter presses the focused one.

Detail — a document

Rendered markdown plus a structured metadata panel (labels, links, tag rows, separators) — the shape for reference material, error reports, and read-only summaries.

Grid — tiles

A wall of tiles: emoji glyphs, color swatches, cached PNG images, or short text, in 1–8 columns with a per-page search filter. Clicks run execute("{id}:grid:{item-id}"). The emoji picker is the bespoke ancestor; new grids use this.

Form — typed input

Single-line text (with password masking), checkboxes, and selects, with per-field focus cycling (↑/↓) and a submit button. Values arrive at execute percent-encoded in the submit id: {id}:page:form:{field=value&...}. Keep values small and secret-free.

Declared commands

Beyond views, an extension’s manifest declares its command surface: title, description, icon, View or NoView mode, keywords, and typed arguments (text, password, dropdown) with placeholders. Root search renders declared commands from the manifest, shows the first argument’s placeholder in the search bar, and hotkeys can execute NoView commands directly without opening the launcher. See the manifest module.

Roadmap

Documented but not yet built: multi-line TextArea, DatePicker, FilePicker and TagPicker form fields, a push/pop navigation stack, menu-bar surfaces, and an AI/runtime-tool surface. Ask before building one by hand — these land in the kit, not in extensions.

Navigation

Root search is the home screen. A command that needs more than a handful of rows gets a dedicated page: the user lands there from the {id}:open result and leaves with Escape or backspace.

Opening a page

The launcher intercepts the opener id before execute runs. Add one branch to the result-activation handler in crates/corvo-ui:

#![allow(unused)]
fn main() {
// Every browser extension opens its own page.
for browser in corvo_browser_tabs::BrowserId::ALL {
    if result.id == format!("{}:open", browser.spec().id) {
        self.open_browser_page(browser, window, cx);
        return;
    }
}
}

The page wiring checklist

LauncherPage is an enum in crates/corvo-ui/src/lib.rs; a new variant must touch every site below. Two of them are exhaustive matches — the compiler finds those. The rest are explicit lists; grep for an existing variant to catch them all.

  1. The enum variant. A data-less variant or one carrying a Copy payload (LauncherPage::Browser(corvo_browser_tabs::BrowserId)).
  2. open_<name>_page — clears query, cursor, selection, results, and menus; calls the refresh fn; resyncs the palette size.
  3. The init match in Launcher::new (exhaustive).
  4. refresh_current_page (exhaustive).
  5. The escape chain in handle_escape — clear query, else back to root.
  6. The backspace-at-empty-query list — same fallback.
  7. refresh_<name> — spawns the off-path warm-up (smol::unblock(fetch)), then re-runs the search when it lands.
  8. The apply-back guard — the spawned task only applies results when search_seq == seq && page == LauncherPage::X; stale responses from another page or query must be dropped.
  9. The placeholder text in the search row chain.
  10. The render dispatch — the is_<name> flag and the .when(is_<name>, ...) wiring (search row + subheader + generic results_list).
  11. The {id}:open interception in the activation handler.
  12. open_launcher_with_page — the external-open match; its wildcard falls back to a Root refresh, so a new page needs its arm.
  13. A subheader — the bold, dim section title between search row and results.

A page whose extension is not installed must not appear in root search (see the browser extensions’ install check), but the page itself degrades gracefully: hint row, no crash.

External entry points

Hotkey intents (HotkeyIntent::ClipboardHistory, EmojiPicker) and the command-id mapping in execute_command_intent can open pages directly — for example a global hotkey that lands straight on the clipboard history. Adding an entry point means one arm there; pages reachable by hotkey are discoverable in Settings.

List

The List module builds the rows every command returns. Everything lives in corvo_ext::list.

ListItem

A row under construction:

#![allow(unused)]
fn main() {
use corvo_ext::list::ListItem;

ListItem::new("Camden Hells")
    .subtitle("0,5 Liter")
    .icon(Icon::Web)
    .accessory("4.6%")
    .section("Lager")
    .build("beer:open:camden-hells", 700)
}
MethodFieldNotes
new(title)titlethe row text
.subtitle(s)subtitleone line; truncates with …
.icon(i)iconsee Icons
.accessory(s)accessoryright-aligned hint
.section(s)sectiongroup header, see below
.build(id, score)—finishes the row

build takes the namespaced result id and the score. See the command contract for the id shapes.

Sections

Consecutive rows sharing a section render under one header. Two rules keep sections working:

  1. Keep sections contiguous. Sort by section first, then score:

    #![allow(unused)]
    fn main() {
    results.sort_by(|a, b| {
        a.section.cmp(&b.section)
            .then_with(|| b.score.cmp(&a.score))
            .then_with(|| a.title.cmp(&b.title))
    });
    }
  2. Name sections like the UI does: short, upper-case in rendering, plural nouns where natural (Formulae, Casks).

Rows without a section fall into the default group, rendered as Results.

open_entry

The standard opener row that exposes a command’s page:

#![allow(unused)]
fn main() {
let row = list::open_entry(
    "weather",
    "Weather",
    "Current conditions and forecast",
    Icon::Svg(phosphor_svgs::style::regular::CLOUD),
    1000,
);
// row.id == "weather:open"
}

The launcher intercepts {id}:open to open the page; execute’s open arm is the fallback.

empty_state

A quiet hint row for a page with nothing to show:

#![allow(unused)]
fn main() {
let hint = list::empty_state(ID, "No matches — add items in Settings");
}

It scores 1, so any real row outranks it, and it carries the {id}:empty id.

fuzzy_open

The shared fuzzy tail for root search. It scores the query against your aliases and answers with the opener row when they match:

#![allow(unused)]
fn main() {
list::fuzzy_open(
    trimmed_query,
    &["Weather", "weather forecast temperature clima"],
    &open_entry(0),
)
}

Score math: a fuzzy match yields match_score + 120; an exact keyword hit (routed before calling this) typically passes 1000.

Score conventions

ScoreMeaning
1000keyword-routed opener or quick command
1010live context row above the opener (now playing, timer state)
700rows on a dedicated page
score + 120fuzzy root matches
1empty-state hints

Pages

The declarative view model: what Command::page returns and how the launcher interprets it. Builders live in corvo_ext::pages; the types live in corvo_core::page.

The page hook

#![allow(unused)]
fn main() {
fn page(&self, query: &str) -> Option<PageView>
}
  • Called for {id}-page:{filter} queries once the page is open, and re-called at the page’s Refresh cadence.
  • Runs through the launcher’s unblock executor, so it may wait on a cache fill or one network round trip — but keep it bounded; the page cannot render until it returns.
  • query is the page filter or the arguments typed after {id}:open:.
  • Return None to close back to root search.

Blocks

#![allow(unused)]
fn main() {
use corvo_ext::pages::PageBuilder;

PageBuilder::new("Minutes for a custom focus, or leave empty for 25...")
    .ticking(1)                                   // Refresh::Every(1)
    .badge("FOCUS", Tone::Warning)
    .hero(None, "Focus", "24:59", "Till 15:00", Tone::Warning)
    .progress(0.04, Tone::Warning)
    .buttons(vec![PageButton {
        action_id: "pause".into(),
        label: "Pause".into(),
        tone: Tone::Neutral,
        hotkey: Some("enter"),
    }])
    .build()
}

Style — the design is the extension’s

The launcher’s components are generic; the design is yours. Every block carries an optional Style, and every Some field overrides the theme default:

FieldOverrides
colorthe block’s primary text or accent color (sRGB, e.g. 0xff6600)
backgroundthe block’s background fill
sizethe primary text size in px (badge label, hero value, card value, button label)
glyph_sizethe glyph size in px
width, heightfixed progress-track dimensions
boldbold primary text

Chain .style(...) after any builder call to restyle what it just pushed; strips and button rows cascade into every card and button inside:

#![allow(unused)]
fn main() {
PageBuilder::new("...")
    .badge("COUNTING DOWN", Tone::Neutral)
    .style(Style {
        color: Some(0x34d399),
        background: Some(0x11342a),
        ..Style::default()
    })
    .hero(None, "New year", "47 days", "2026-12-31", Tone::Accent)
    .style(Style {
        color: Some(0xfbbf24),   // amber value, the extension's call
        size: Some(64),
        ..Style::default()
    })
    .build()
}

None fields keep the theme — an extension that never styles looks native; one that styles everywhere looks like itself.

Keyboard: the page owns arrow and Enter semantics

On a Blocks page the launcher does not keep its own focus ring — the command renders the selection and decides what the keys mean:

KeySent to executePomodoroMedia
←{id}:page:leftprevious presetprevious track
→{id}:page:rightnext presetnext track
Enter{id}:page:enterstart / pause / resume (the primary action)play / pause

The selected item is ordinary styling — a filled chip via Style, not a UI highlight — so there is exactly one “selected” look and it means what the user thinks. Keys a page does not implement fail silently (the page just refreshes). Forms keep their own ↑/↓ field focus; Grids filter from the search bar.

Filters — the second dimension

.filters() declares a chip row above the content, the pattern list extensions use for a second axis (units, category, sort order). The command owns the selection and re-renders when a chip runs {id}:page:filter:{value}:

#![allow(unused)]
fn main() {
.filters(vec![
    PageFilter::new("c", "°C ●"),
    PageFilter::new("f", "°F"),
])
}
if let Some(value) = key.strip_prefix("page:filter:") {
    self.units.store(/* ... */);
    return Ok(Action::ShowToast(String::new())); // silent re-render
}

Row accessories — facts with tooltips

Rows carry one primary accessory plus structured facts, each with its own hover tooltip — the grammar Kill Process uses for CPU and memory and Hacker News for points and comments:

#![allow(unused)]
fn main() {
ListItem::new("chrome")
    .accessory("12.4%")
    .accessories([
        SearchAccessory::new("1.2 GB").with_tooltip("Memory: 1.24 GiB"),
        SearchAccessory::new("412").with_tooltip("% CPU: 12.40"),
    ])
}

Recently used

corvo_ext::recents backs the “Recently used” section the most-installed extensions render first:

#![allow(unused)]
fn main() {
// In execute, after acting on an item:
corvo_ext::recents::push(ID, "recent-items", &item_id, 10)?;
// In page()/search(), before everything else:
let recent = corvo_ext::recents::list(ID, "recent-items");
}

Pushed details — the Show Details pattern

A row’s execute can push a Detail view onto the launcher’s navigation stack; Escape (or backspace on an empty query) pops back to the page:

#![allow(unused)]
fn main() {
return Ok(Action::ShowDetail {
    title: format!("{} — {}", item.name, item.date),
    markdown: item.report,
    metadata: vec![
        Metadata::Label { title: "Status".into(), text: "Done".into(), tone: Tone::Positive },
        Metadata::Link { title: "Source".into(), text: item.url.clone(), url: item.url.clone() },
        Metadata::Separator,
    ],
});
}

The content rides in the action (build it from the command’s cache — never fetch inside execute).

Grid sections

Grid items carry an optional section; consecutive items sharing one render under a header with the item count — the Unsplash pattern. Build them with pages::grid_sectioned.

Buttons and page actions

Every click becomes execute("{id}:page:{action_id}") in your crate — the page never mutates the UI directly. Route them:

#![allow(unused)]
fn main() {
if let Some(action) = key.strip_prefix("page:") {
    let message = run_page_action(action)?;
    return Ok(Action::ShowToast(message));
}
}

Return an empty toast string to act silently; a toasts sentence is the visible confirmation. action_id may carry per-render data (chip:40) because the page rebuilds every render.

Refresh

CadenceUse for
Refresh::Manualforms, detail documents, grids
Refresh::Every(1)countdowns, timers
Refresh::Every(5)now-playing surfaces

The launcher stops ticking the moment the page closes or the returned cadence changes; an idle launcher pays nothing.

Grid and Form

#![allow(unused)]
fn main() {
corvo_ext::pages::grid(
    vec![pages::grid_glyph("party", "🥳", "Party")],
    Some(6),          // columns 1..=8
    "Pick a glyph...",
    Refresh::Manual,
)

corvo_ext::pages::form(
    "New event",
    vec![
        pages::text_field("name", "Name", "Ada Lovelace", "", false),
        pages::checkbox("pin", "Pin to top", false),
        pages::select("tone", "Tone", vec![("soft".into(), "Soft".into())], "soft"),
    ],
    "Create",
)
}

Grid clicks: {id}:page:grid:{item-id}. Form submits: {id}:page:form:{field=value&...} percent-encoded.

Manifest and quick commands

Declare the extension identity and command surface with the corvo_ext::manifest builders:

#![allow(unused)]
fn main() {
fn manifest(&self) -> ExtensionManifest {
    manifest::extension(
        "countdown", "Countdown", "Days until a date", icon(),
        &["Productivity"],
        vec![
            manifest::view_command("view", "Countdown",
                "Open the countdown page", &["countdown"])
                .argument(manifest::required_text_argument(
                    "date", "Date: 2026-12-25, Dec 25...")),
            manifest::action_command("saved", "Saved Countdowns",
                "Open the saved strip", &["saved"]),
        ],
    )
}
}
  • View commands open the page; NoView commands act and confirm with a toast — including straight from a hotkey (command:<extension>:<name>), without opening the launcher.
  • The first declared argument’s placeholder becomes the search bar hint when the command is selected in root search.
  • Root search and Settings render declared commands from the manifest; keep titles and descriptions user-facing.

Reference implementation

commands/countdown — a live ticking page, saved state, declared arguments, and zero UI code. The migrated pomodoro, weather, and media-control crates show the same model over OS state.

Manifest

What an extension declares about itself: identity plus its command surface. Root search, the Settings command list, and hotkey binding render from the manifest — no hand-written plumbing. Builders live in corvo_ext::manifest; the types in corvo_core::page.

#![allow(unused)]
fn main() {
fn manifest(&self) -> ExtensionManifest {
    manifest::extension(
        "countdown",                    // == Command::id
        "Countdown",                    // display title
        "Days until a date",            // one-line description
        icon(),
        &["Productivity"],
        vec![/* CommandSpec entries */],
    )
}
}

Command specs

FieldMeaning
namethe command’s name inside the extension
titledisplay name in root search and Settings
descriptionwhat it does, one sentence
modeView opens a page; NoView acts and confirms with a toast
iconoptional override of the extension icon
argumentstyped argument slots, required first, up to 3
keywordsroot-search words

Builders: view_command, action_command, then chain CommandSpecBuilder::argument(spec, arg) and ::icon(spec, icon).

Arguments

KindBehavior
text_argument(name, placeholder)free text
required_text_argument(name, placeholder)the launcher keeps asking until filled
password_argument(name, placeholder)masked input
dropdown_argument(name, placeholder, options)fixed (value, title) choices

The first argument’s placeholder becomes the search bar hint when the declared command is selected in root search; the typed value rides into the command as the page query / argument segment.

Quick commands

Every declared command is a quick command: root search lists it, and a hotkey can target it:

  • command:<extension>:<name> executes a NoView command directly — no launcher window; the command’s toast is the confirmation.
  • View commands open their page from the hotkey.

Declare the small verbs of your extension (Play / Pause, Next Track, Saved Countdowns) as NoView commands so users can bind them.

Actions

A result’s actions menu opens with ⌘K (or the platform’s primary modifier + K). The command supplies the menu; the UI runs each Action directly. Builders live in corvo_ext::actions.

Building a menu

#![allow(unused)]
fn main() {
use corvo_ext::actions;

fn actions(&self, result_id: &str) -> Vec<CommandAction> {
    let Some(url) = result_id.strip_prefix("hackernews:story:") else {
        return Vec::new();
    };
    vec![
        actions::open_url("hackernews:open", url),
        actions::copy("hackernews:copy", url),
    ]
}
}

The first item is the menu’s default; give it a hotkey when one is obvious:

#![allow(unused)]
fn main() {
let mut open = actions::open_url("a:open", url);
open.hotkey = Some("cmd+o");
}

Built-in builders

BuilderRunsGroup
copy(id, text)Action::Copy — clipboard + feedbackStandard
open_url(id, url)Action::OpenUrl — system handlerStandard
open_path(id, path)Action::Open — file or folderStandard
reveal_path(id, path)Action::RunShell — Finder / Explorer / xdg-openStandard
custom(id, label, action, icon)any concrete ActionStandard
destructive(id, label, action)removal-class actionsDestructive

custom exists for extension-specific work: express it as a concrete Action the UI can run — Action::RunProcess { program, args, title } to invoke a CLI, Action::RunNative for a system action, and so on.

The Action catalog

These variants exist today; the UI interprets every one on all three platforms:

Open, OpenUrl, OpenFileSearch, OpenAppUninstaller, Copy, PasteText, CopyImage, PasteImage, RunShell, RunProcess, RunNative(NativeAction), ShowToast, CloseWindow, TileWindow, AdjustBrightness, AdjustVolume, ConfirmProcessTermination, TerminateProcess, SetResultFavorite, SetResultHidden.

Conventions

  • Labels in Title Case, verb first: Copy Link, Stop Service.
  • Destructive group only for deletion-class actions; the UI renders them apart, in red, and asks for confirmation where the action is irreversible.
  • No chained shell tricks when a builder exists: actions::copy instead of Action::RunShell("pbcopy ...") — the clipboard path handles every platform and the concealment markers.
  • Menu ids follow the same namespace rules as result ids: {id}:{action}[:{data}].

Cache

corvo_ext::cache::TtlCache is the standard time-to-live cache. It exists because of one rule: search never blocks.

The pattern

Every command with slow data — HTTP, AppleScript, CDP, SQLite, file scans — uses the same three pieces:

  1. A static cache in the command crate:

    #![allow(unused)]
    fn main() {
    use corvo_ext::cache::TtlCache;
    
    fn cache() -> &'static TtlCache<Vec<Tab>> {
        static CACHE: std::sync::OnceLock<TtlCache<Vec<Tab>>> = std::sync::OnceLock::new();
        CACHE.get_or_init(TtlCache::new)
    }
    }
  2. A warm-up fetch the UI calls off the search path (through smol::unblock), which fills the cache:

    #![allow(unused)]
    fn main() {
    pub fn fetch_tabs() -> Vec<Tab> {
        cache().get_or_fetch(Duration::from_secs(5), load_tabs_from_os)
    }
    }
  3. A cache-only search, which may answer empty before the first warm-up lands:

    #![allow(unused)]
    fn main() {
    let tabs = cache().get(Duration::from_secs(5)).unwrap_or_default();
    }

API

MethodBehavior
get(ttl)the cached value when younger than ttl, else None
get_or_fetch(ttl, f)get, else f() stored under the same lock; blocking, off-path only
store(value)write and restart the clock
invalidate()drop the value so the next read refetches

House TTLs

DataTTLExample
OS state that changes while you watch5 sopen tabs, now playing
Documents and files60 snotes index
Network payloads15 minweather
Expensive installs24 h+application list (rebuilt on launcher open)

Rules

  • Invalidate after mutations. media-control clears its cache after a transport command so the next read reflects the change.
  • Never call get_or_fetch from search. That is the blocking bug the cache exists to prevent. The only exception is an instant, deterministic source — and then say so in a comment.
  • Errors are not cached. A failed fetch returns None/empty and tries again on the next warm-up; do not store failure states users cannot clear.

Preferences

Extensions that need user settings (a default city, a token, an enabled feature) read a typed TOML file through corvo_ext::prefs. Each extension owns exactly one file:

config_dir()/extensions/<extension-id>.toml

config_dir() is the per-user Corvo config directory (~/Library/Application Support/corvo on macOS, %APPDATA%\corvo on Windows, ~/.config/corvo on Linux).

Declaring and reading

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize, Default)]
#[serde(default)]
struct WeatherPrefs {
    city: String,
    units: Units,       // any Deserialize + Default type
}

impl Default for Units { /* ... */ }

let prefs = corvo_ext::prefs::load::<WeatherPrefs>("weather");
}

#[serde(default)] keeps unknown and missing keys harmless.

Semantics

SituationResult
File missingT::default()
Key missingthe field’s default
Unknown keysignored
File unreadable or unparseableload → T::default(); load_result → Err(String)

Use load_result when a broken file should surface to the user instead of silently reverting to defaults:

#![allow(unused)]
fn main() {
match corvo_ext::prefs::load_result::<WeatherPrefs>(ID) {
    Ok(prefs) => prefs,
    Err(message) => return vec![list::empty_state(ID, &message)],
}
}

Rules

  • Read off the hot path. Load once per search call at most — better once per warm-up — not inside a per-row loop.
  • No configuration commands. A command whose only job is editing settings is a smell; the file is plain TOML and Settings already covers global toggles. Document the keys in the extension README.
  • Secrets stay out. Preferences are plain files. If a token is required, store it per the README instructions and never log it. A standard secret store is planned.

Feedback

There are two ways an extension talks back, and choosing the right one is most of the UX work.

Toast — inline confirmation

corvo_ext::feedback::toast(message) returns Action::ShowToast(message). The launcher shows it while its window is up. Use it for transport-class feedback:

#![allow(unused)]
fn main() {
"toggle" => Ok(feedback::toast("Media control sent")),
}

Write a sentence the user reads, not a debug string. An empty or technical message ("ok", "result_id=7") fails review.

Desktop notification

Work that finishes when no launcher window is in flight — a pomodoro interval, a background download — calls corvo_platform::notify(title, body):

#![allow(unused)]
fn main() {
corvo_platform::notify("Pomodoro", "Focus interval finished")?;
}

It is blocking (AppleScript on macOS, an encoded PowerShell toast on Windows, notify-send on Linux): call it from the background thread that finished the work, never from search. Record a diagnostic on failure the way pomodoro does instead of failing silently.

Errors

execute returns errors as values and the UI renders them:

  • CommandError::NotFound — unknown or malformed result id.
  • CommandError::Unsupported — this platform cannot do it (yet).
  • CommandError::Platform(message) — the OS refused; write what a user can do about it (“could not focus that tab”).

Failures that happen off-path (a warm-up fetch) must not crash either: answer empty from the cache, and surface a hint row when the page resolves empty.

Anti-patterns

  • Toasts for state the user can see (Action::Open already closes the window and shows the result).
  • Notifications from execute — the window is up; a toast is enough.
  • println!/eprintln! debugging left in; use corvo_platform::diagnostics for local error logs.

Icons

SearchResult.icon is a corvo_core::Icon. One row, one icon; the launcher renders it identically everywhere.

The variants

VariantUse forExample
Icon::Svg(data)any conceptual glyph, from the bundled Phosphor setphosphor_svgs::style::regular::MAGNIFYING_GLASS
Icon::Image(path)a real application’s icon, extracted to a cached PNGbrowser tabs, app rows
Icon::Glyph(c)emoji-as-iconthe emoji picker’s numbered rows
Icon::Webfallback for web-shaped rowslinks, bookmarks
Icon::Linkbookmarks and quicklinks
Icon::File, Icon::App, Icon::Clipboard, Icon::Window, Icon::System, Icon::Calculator, Icon::Snippet, Icon::Emojigeneric fallbacks when no Phosphor glyph fits

Prefer Icon::Svg with a Phosphor name — it scales, themes, and needs no assets. phosphor_svgs::style::regular::* is the standard weight; browse the names with cargo doc -p phosphor-svgs --open.

Real application icons

When a row represents an installed application, show the application’s icon — corvo-platform extracts it to a cached PNG on every OS:

#![allow(unused)]
fn main() {
let png = corvo_platform::browser_app_icon(
    "Google Chrome.app",                                // macOS bundle
    r"Google\Chrome\Application\chrome.exe",            // Windows exe
    "google-chrome",                                    // Linux .desktop
);
row.icon = png.map(Icon::Image).unwrap_or(Icon::Web);
}

Extraction runs once per icon and is cached on disk, but keep it off the search path anyway (warm-up task), exactly like the browser extensions do.

Rules

  • One icon per row; do not encode state in two icons.
  • Do not ship bitmap assets in the crate — Phosphor covers glyphs, and Icon::Image covers real apps.
  • Icon meaning is consistent across extensions: Icon::Link is always a bookmark-ish thing, Icon::Web a web page.

Platform

corvo-platform is the only crate allowed to contain #[cfg(target_os = ...)]. Extensions call its free functions and traits; they never run raw OS APIs inline. If your extension needs a new OS capability, add it to the platform trait and implement it in macos.rs, windows.rs, and linux.rs — the OS parity tests fail when one is missing.

Everyday helpers

FunctionWhat it does
open_url(url)system handler for URLs and app URIs
open_path(path)system handler for files and folders
run_shell(cmd)a shell line, CREATE_NO_WINDOW on Windows
run_process_with_timeout(program, args, timeout)blocking process run with a deadline and clean kill
notify(title, body)desktop notification
read_clipboard_text() / copy_image_to_pasteboard(...)clipboard

Richer subsystems

AreaEntry points
Processesprocess_snapshot, terminate_process, listening_port_snapshot
Mediamedia_now_playing, media_transport (AppleScript / SMTC / MPRIS)
Browsersbrowser_tabs, focus_browser_tab, browser_app_icon, browser_app_installed, browser_data_candidates, plus the cdp module
Windows & appsplatform_ops().list_windows()/focus_window(), list_apps_in_scopes
Permissionspermissions::is_granted, permissions::request (macOS TCC)
Updatesthe updates module (used by the app, not extensions)

Calling convention

Slow platform calls run through smol::unblock in execute, or in a warm-up fetch behind a TtlCache for search:

#![allow(unused)]
fn main() {
return smol::unblock(move || corvo_platform::focus_browser_tab(&app, focus, port, &url))
    .await
    .then_some(Action::CloseWindow)
    .ok_or_else(|| CommandError::Platform("could not focus that tab".into()));
}

Timeouts are the caller’s job: brew runs every subprocess through run_process_with_timeout with a 60 s budget. Copy that pattern — a hung subprocess hangs the async worker pool.

Parity rule

A capability that works on macOS must work on Windows and Linux in their native mode, or degrade honestly: CommandError::Unsupported, an empty list, or a hint row. The three OS files land in the same commit, and CI runs the tests on all three platforms.

Patterns from real extensions

Distilled from the Raycast extensions repository (3,300+ published extensions; a dozen representative ones studied in depth: Todoist, Spotify Player, Apple Reminders, Obsidian, Kill Process, Color Picker, Emoji, Hacker News, Wikipedia, Notion, Weather, Pomodoro). Each pattern names what the ecosystem does and how to apply it in Corvo.

Structure: many small commands beat one big command

Popular extensions are not one command with a menu — they are a family of commands: Todoist ships 10, Spotify Player 33, Color Picker 8, Apple Reminders 9. The mix is deliberate:

  • View commands explore data (tasks, library, processes).
  • NoView commands with required arguments capture without opening anything: quick-add-task text, quick-add-reminder text, append-task text dueDate. This “capture without UI” pattern is the highest-leverage command an integration can ship — one keystroke, type, Enter, done.
  • Niche commands ship disabledByDefault — Spotify’s volume25, like, copyEmbed exist but stay out of root search until the user opts in. Root search never saturates.

In Corvo: declare every verb as a manifest command; mark the niche ones and the one-line captures. A NoView command with a required text argument is the Corvo equivalent of the quick-add pattern.

The list row is the universal component

The single biggest lesson: Raycast extensions express almost everything as list rows with rich accessories — even the weather’s hourly forecast is one row per hour, with icon + text + tooltip accessories for rain, wind, cloud cover, and humidity. Kill Process shows CPU and memory as accessories with tooltips. Nobody builds bespoke canvases for data that fits rows.

Consequences for Corvo extensions:

  • Prefer a List page with well-designed rows over a Blocks page when the data is enumerable. Blocks are for a single live datum (timer, now playing), not for tables of things.
  • The subtitle grammar is X · Y · Z (middle-dot separated facts, most important first).
  • The section header carries the count: Raycast renders <List.Section title="Processes" subtitle="245 running">. In Corvo, put it in the section label: section("Processes · 245 running") — the header renders it for free.
  • Name one accessory per fact, right-aligned, short (“85%”, “3 min ago”). Corvo’s SearchResult.accessory is one string today; structured multi-accessories with tooltips are on the roadmap.

The dropdown is the second filter dimension

List pages with more than one natural axis add a dropdown in the search bar: Hacker News picks the topic (front page / Ask / Show), Kill Process picks the sort (CPU / memory), Emoji picks the category, Weather picks units. Text filters within the axis; the dropdown switches the axis.

In Corvo today the axis goes in the page query or the extension adds its own rows for each value; a first-class dropdown accessory is the top requested kit addition (roadmap: PageView::List { filters }).

Actions: grouped, hotkeyed, confirmed

  • Action menus are grouped in sections: open things first, then copy/export, destructive last and apart.
  • The common action gets a shortcut: Copy Link is ⌘. in Hacker News; Raycast publishes Common shortcuts (⌘S save, ⌘R refresh…) and asks extensions to reuse them.
  • Destructive actions confirm first — Kill Process shows an alert before killing and before force-killing, and its failure alerts offer “Open Help” as the primary action instead of a dead error.
  • After a mutation, the view goes somewhere useful on purpose: close the window, or pop to root with the search bar cleared — Kill Process makes both user preferences.

In Corvo: actions::destructive + the confirm convention covers confirmation; pair every mutation with the post-action navigation the user would want, and prefer preferences over hard-coding it.

State: recents, preferences, and behavior toggles

  • Recently used is a first-class section: the Emoji extension keeps the last 10 used emojis in persistent state and renders them as the first section, before the category sections. Cheap, loved, copied everywhere. In Corvo: corvo_ext::storage + a section("Recently used") row group.
  • Behavior is preferences, not settings commands: Kill Process exposes showPID, showPath, closeWindowAfterKill, clearSearchBarAfterKill; Google Search has rememberSearchHistory and useClipboardFallback; Emoji has primaryAction: copy | paste — a preference that swaps the primary action. Small toggles shape the experience; no extension ships a “settings” command.
  • Primary action preference: when two actions are equally likely (copy vs paste), declare a dropdown preference and honor it in the menu order.

Feedback and error handling

  • Failures toast with Failure style, a short title, and an actionable message; Kill Process’s platform-specific kill errors open a help URL from the alert.
  • A mutation confirms with a success toast that says what happened (“Timer paused”), then the list refreshes — never leave a stale row.

Background surfaces

Menu-bar commands with refresh intervals are the second most common shape after views (Weather 30m, Todoist 10m, Pomodoro 10s — timer in the menu bar). Corvo’s roadmap includes menu-bar surfaces; until then, the interval pattern maps to ticking extension pages (Refresh::Every).

Operation families and delivery modes

Two shapes repeat across the most-installed utilities, both built from NoView commands:

  • One command per operation: Encoding Tools ships md5, sha1, sha256, base64-encode… and Change Case ships 22 conversions — each a separate root-search verb acting on the clipboard, the selection, or an argument. Users search the operation, not a menu of operations. Corvo’s text-utilities consolidates; when a family grows past a handful of verbs, split the high-frequency ones out as declared NoView commands.
  • Same operation, three delivery modes: Google Translate ships instant-translate-copy, instant-translate-paste, and instant-translate-view — translate the clipboard and deliver the result however the user wants it. Downloads Manager ships open/copy/paste/show/delete-latest-download. Model delivery as commands, not as options inside one command.
  • A command that toggles a preference: Downloads Manager’s toggle-deletion-behavior flips a setting from the keyboard without opening Settings. Small, surprising, loved.

Health and onboarding as a first-class page

Notion ships manage-connection: a Detail page that reports the connection state in plain language (“Your connection is working.”), with actions to Reconnect, Test Connection, and Open Preferences. Any extension with a token or an external dependency deserves the same page: state, explanation, and the fixing actions on one screen.

Currency Exchange turns the search text into the input — type 100 usd and every row is a converted amount, updated per keystroke, with the currency name as an accessory. The search bar is an input field, not just a filter: commands that transform (converters, calculators, encoders) should render their output as rows driven by the query.

Grids: images with sections and columns

Unsplash renders results as a Grid with Grid.Section (title + count), column count from a preference, and images as tile content. In Corvo, Grids filter from the search bar and take a column count per page; image tiles take a cached PNG path.

Adopted in the kit

The patterns above map to first-class kit support: filter chips and structured accessories (corvo_ext::pages), the Recently used helper (corvo_ext::recents — the emoji picker uses it for its strip), per-row pushed details (Action::ShowDetail), grid sections (GridItem.section), declared NoView capture commands (the template ships one), and clipboard quick verbs (text-utilities declares To UPPERCASE, to snake_case, Base64 Encode, SHA-256… as NoView commands).

Anti-patterns to avoid

  • One mega command with an internal menu of sub-features.
  • Bespoke visual canvases for enumerable data.
  • Settings implemented as commands instead of preferences.
  • Destructive actions without confirmation.
  • Stale rows after a mutation — always refresh the page.
  • Empty states that say nothing; say what would appear and how.

Contribution standard

Every extension change lands through a pull request on github.com/diegoleteliers10/corvo. Review is checklist-based: if all boxes pass, the PR merges.

The checklist

Correctness

  • cargo check --workspace --all-targets passes.
  • cargo clippy --workspace --all-targets -- -D warnings passes.
  • cargo test --workspace passes, and the extension has tests for at least: a search match, a search non-match, and execute routing (including a malformed id).
  • No unwrap/expect/panic outside tests.
  • Errors are values (CommandError); no swallowed failures.

Architecture

  • The crate depends only on corvo-core, corvo-ext, and (if needed) corvo-platform — never corvo-ui.
  • No #[cfg(target_os)] in the command crate; OS work goes through corvo-platform (all three OS files in the same PR).
  • search never blocks: slow work sits behind a TtlCache, warmed off the search path.
  • Result ids follow the namespace table ({id}:open, {id}:{action}[:{data}]), and the command id equals the crate directory name.

Platform parity

  • Every macOS behavior has a native Windows and Linux story — or degrades honestly (Unsupported, an empty list, a hint row).
  • CI is green on all three targets. Runtime checks that CI cannot do (permissions, installed apps) are listed in the PR description as “tested manually on …”.

UX

  • Titles follow the naming rules (below); labels are Title Case with an ellipsis … for anything that opens further UI.
  • Empty states are honest: a hint row, never a blank list.
  • Preferences use the prefs module; no configuration commands.
  • Toasts are sentences; notifications only from background work.

Safety

  • No telemetry, analytics, or outbound requests beyond the feature’s documented purpose.
  • No bundled opaque binaries; subprocesses are named, versioned, and downloaded from infrastructure the user can audit.
  • Secrets stay out of preferences files and logs.

Documentation

  • CHANGELOG.md entry per user-visible change: ## [Added X] - 2026-10-03 (Keep-a-Changelog style).
  • README.md in the crate when setup is required (tokens, services, permissions).
  • EXTENSIONS_PLAN.md updated when the change moves the plan (new extension, new platform support).

Naming rules

  • Extension titles: nouns over verbs, specific over general — Hacker News, Notion Search. Apple Style Guide capitalization; lowercase stays lowercase for names like npm or iOS.
  • Commands inside an extension: <verb> <noun> (Create Issue, Search Files) or a bare noun; no articles.
  • Ids: kebab-case, stable, never renamed without a migration note.
  • Keywords: include the extension name, a natural alias, and the domain noun; they are lowercase-matched on the query’s first word.

Review flow

  1. Small fixes go into the existing extension’s PR; get the maintainer’s sign-off for structural changes.
  2. A new extension for a different configuration of the same service is its own extension (GitHub Cloud vs GitHub Enterprise), not a merged mega-extension.
  3. The reviewer checks out the PR branch and runs the extension locally (cargo run) — review includes using it.
  4. Squash-merge with a conventional commit title (feat(extensions): ..., fix(weather): ...).

Versioning

Extensions ship with Corvo releases (per-OS installers and the Homebrew cask update automatically). A PR that should appear in the next release says so in its description; release notes are assembled from CHANGELOG.md entries.

Examples in the repo

The best reference is the shipped code. Each extension below is the canonical example of one pattern — read it before inventing a new shape.

PatternReadWhat to learn
The standard extensiontemplates/extensionevery contract part at once: routing, page, sections, actions, prefs, cache, tests
Declarative rich pagecommands/countdownmanifest with typed arguments, a ticking Blocks page, storage, and zero UI code
Network + cache + debouncecommands/weatherTTL cache keyed by argument, debounced UI fetch, emoji-coded conditions
Dedicated page with statecommands/pomodoro + the pomodoro page in corvo-uia state machine behind a mutex, notifications from a watcher thread, a bespoke page
Platform abstractioncommands/media-controla thin crate over corvo-platform (AppleScript / SMTC / MPRIS) with one result model
Many commands, one cratecommands/browser-tabsa BrowserSpec table + a macro generating eight commands with install detection, real icons, and CDP/AppleScript backends
Actions menuscommands/brewmenu builders, destructive actions, subprocess timeouts
Sections in a listcommands/brew (upgrades mode)contiguous sections rendered as Formulae / Casks groups
Persistencecommands/notesfile-per-item storage, atomic saves, a full editor window
Search rankingcommands/app-launcherfrecency ranking via the shared search core

By module

  • corvo_ext::list — every crate; start with templates/extension.
  • corvo_ext::cache — weather, media-control, browser-tabs.
  • corvo_ext::prefs — templates/extension; global settings live in corvo-config.
  • corvo_ext::actions — brew, text-utilities, kill-process.
  • corvo_ext::routing — weather, browser-tabs, pomodoro.
  • corvo-platform subsystems — media-control (media), browser-tabs (browsers/CDP), kill-process (processes).

Not yet covered

  • Grid and Form as first-class modules (the emoji picker and the notes editor are hand-built precursors).
  • Markdown Detail panes per row.
  • A secret store for tokens.

If you need one of these, open an issue first — the module should land in the kit, not in one extension.

Prompt for coding agents

Contributors increasingly let a coding agent write the first version. The prompt below is the standard input: it compresses the contribution standard into a paste-ready block. The same content ships as PROMPT.md at the repo root, and AGENT.md carries the equivalent rules for agents that read the repository automatically.

Paste the block below into any coding agent (ZCode, Claude Code, Codex…) before asking it to build or change a Corvo extension. It is the contract the review checklist enforces. AGENT.md at the repo root carries the same rules for agents that read it automatically.


You are contributing an extension (a command crate) to Corvo, a native launcher written in Rust. Work inside the repository corvo. Follow these rules exactly; they are the review standard.

Architecture

  1. An extension is one crate under commands/<your-id>/ where <your-id> is the command id, kebab-case. Start from templates/extension/ — copy it, rename the package to corvo-<your-id>, and change Command::id, KEYWORDS, and the demo data.
  2. Register the command with corvo_core::register_command! once in the crate. Then touch exactly two lines outside the crate: one [workspace] members entry and one [dependencies] entry in the root Cargo.toml. Never edit src/main.rs.
  3. Dependencies: corvo-core (the Command trait and types), corvo-ext (the kit: list, actions, cache, prefs, routing, feedback), and corvo-platform only if the command does OS work. Never depend on corvo-ui.
  4. No #[cfg(target_os = ...)] inside the command crate. OS work goes through corvo-platform free functions; if a new OS capability is needed, implement it in corvo-platform’s macos.rs, windows.rs, and linux.rs in the same change (OS parity is mandatory — degrade honestly with CommandError::Unsupported or a hint row when a platform cannot do it).

Behavior

  1. search(query) runs per keystroke and must never block: no network, processes, AppleScript, CDP, or file-tree walks. Slow data lives behind a corvo_ext::cache::TtlCache static, warmed by a fetch_* function the UI runs through smol::unblock; search reads cache().get(ttl) and may answer empty.
  2. Use the kit, not hand-rolled plumbing: list::{ListItem, open_entry, empty_state, fuzzy_open} for rows and sections; actions::{copy, open_url, open_path, reveal_path, custom, destructive} for menus; routing::{first_word, key, page_query} for dispatch; feedback::toast for inline confirmation. Keep sections contiguous (sort by section, then score).
  3. Result ids are namespaced: {id}:open (page opener), {id}:{action}[:{data}] (actions), {id}:empty (hint rows). execute strips the {id}: prefix with routing::key and returns CommandError::NotFound for anything else. No unwrap/expect/panic outside tests. 7b. Rich pages are declarative: implement manifest() (title, description, icon, declared commands with typed arguments) and page(query) returning a PageView — composed Blocks with a Refresh::Every cadence for live state, corvo_ext::pages helpers for Detail/Grid/Form. Page buttons and form submits route back through execute("{id}:page:{action}"). Never touch corvo-ui to render anything; read book/src/views.md and book/src/modules/pages.md first. commands/countdown is the reference implementation.
  4. Slow work in execute runs inside smol::unblock; subprocesses run through corvo_platform::run_process_with_timeout with an explicit budget.
  5. Preferences live in corvo_ext::prefs (one TOML file per extension, serde + defaults). No telemetry, no analytics, no secrets in files or logs, no bundled opaque binaries.

Quality gate (all must pass before you finish)

cargo test -p corvo-<your-id>
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings

The crate has unit tests for at least: one search match, one search non-match, execute routing, and a malformed id. If a human must verify something on real hardware (permissions, installed apps), say so explicitly in the final summary instead of claiming success.

Deliverables

  • The crate, with README.md when setup is needed (tokens, services, permissions) and a CHANGELOG.md entry: ## [Added X] - YYYY-MM-DD.
  • EXTENSIONS_PLAN.md updated if the change moves the plan.
  • Conventional commit messages (feat(extensions): ...).
  • Documentation lives in book/src/ (published via GitHub Pages): read concepts.md, the module chapters under modules/, and contributing.md before writing code if anything above is unclear.

Tips for agent-authored extensions

  • Review the diff like any PR — the agent has no taste, the checklist does. The contribution standard applies unchanged.
  • Ask the agent for the manual-verification list: CI cannot grant Accessibility permission or launch Spotify. A summary that claims everything “works” without naming what was actually run is a red flag.
  • Small, focused prompts beat mega-prompts: one extension, or one improvement to one extension, per session.