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-extkit 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
- New extension? Follow Build your first extension
from the template in
templates/extension/. - Reviewing a pull request? The contribution standard is the checklist.
- Letting a coding agent do the work? Give it the agent prompt.
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 provide | The launcher provides |
|---|---|
id, keywords, priority | placement in root search |
search(query) → rows | the result list, fuzzy ranking, rendering |
execute(result_id) → an Action | running the action (open, copy, shell, toast…) |
actions(result_id) → a menu | the ⌘K actions panel |
| data, loaded off the search path | background 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.searchreads 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:
| Module | Purpose | Raycast analog |
|---|---|---|
list | rows, sections, open entries, empty states | List, List.Item, List.Section |
actions | action-menu builders | ActionPanel, built-in actions |
cache | TTL cache for off-path loading | Cache, useCachedPromise |
prefs | per-extension TOML settings | Preferences |
routing | query and result-id dispatch | command lifecycle |
feedback | toasts and notifications | Toast, 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.mdif your extension needs setup (tokens, enabled services, permissions). - Add a
CHANGELOG.mdentry:## [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:
| Shape | Meaning | Example |
|---|---|---|
{id}:open | opens the command’s page | weather:open |
{id}:{action} | a concrete action | media:toggle |
{id}:{action}:{data} | an action with data | weather:open:oslo |
{id}:empty | an empty-state hint row | notes: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:
- No blocking work. No network, no processes, no file trees.
- Read the cache.
TtlCache::getis the only source of slow data; it may answerNonebefore the first warm-up lands. - Keep answers stable. Empty query → usually nothing (root
search is shared); page queries (
{id}-page:{filter}) answer from the cache and never launch anything. - 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.
List — rows from search
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.
| Block | Renders |
|---|---|
Badge | small status pill with a semantic tone |
Hero | the dominant datum: glyph, headline, one very large value, quiet subtitle |
Progress | determinate bar, 0.0..=1.0 |
Markdown | launcher-subset markdown (headings, lists, bold, italic, code) |
Strip | row of small stat cards |
Buttons | clickable 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.
- The enum variant. A data-less variant or one carrying a
Copypayload (LauncherPage::Browser(corvo_browser_tabs::BrowserId)). open_<name>_page— clears query, cursor, selection, results, and menus; calls the refresh fn; resyncs the palette size.- The init match in
Launcher::new(exhaustive). refresh_current_page(exhaustive).- The escape chain in
handle_escape— clear query, else back to root. - The backspace-at-empty-query list — same fallback.
refresh_<name>— spawns the off-path warm-up (smol::unblock(fetch)), then re-runs the search when it lands.- 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. - The placeholder text in the search row chain.
- The render dispatch — the
is_<name>flag and the.when(is_<name>, ...)wiring (search row + subheader + genericresults_list). - The
{id}:openinterception in the activation handler. open_launcher_with_page— the external-open match; its wildcard falls back to a Root refresh, so a new page needs its arm.- 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)
}
| Method | Field | Notes |
|---|---|---|
new(title) | title | the row text |
.subtitle(s) | subtitle | one line; truncates with … |
.icon(i) | icon | see Icons |
.accessory(s) | accessory | right-aligned hint |
.section(s) | section | group 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:
-
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)) }); } -
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
| Score | Meaning |
|---|---|
1000 | keyword-routed opener or quick command |
1010 | live context row above the opener (now playing, timer state) |
700 | rows on a dedicated page |
score + 120 | fuzzy root matches |
1 | empty-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’sRefreshcadence. - 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.
queryis the page filter or the arguments typed after{id}:open:.- Return
Noneto 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:
| Field | Overrides |
|---|---|
color | the block’s primary text or accent color (sRGB, e.g. 0xff6600) |
background | the block’s background fill |
size | the primary text size in px (badge label, hero value, card value, button label) |
glyph_size | the glyph size in px |
width, height | fixed progress-track dimensions |
bold | bold 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:
| Key | Sent to execute | Pomodoro | Media |
|---|---|---|---|
| ← | {id}:page:left | previous preset | previous track |
| → | {id}:page:right | next preset | next track |
| Enter | {id}:page:enter | start / 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
| Cadence | Use for |
|---|---|
Refresh::Manual | forms, 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"]),
],
)
}
}
Viewcommands open the page;NoViewcommands 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
| Field | Meaning |
|---|---|
name | the command’s name inside the extension |
title | display name in root search and Settings |
description | what it does, one sentence |
mode | View opens a page; NoView acts and confirms with a toast |
icon | optional override of the extension icon |
arguments | typed argument slots, required first, up to 3 |
keywords | root-search words |
Builders: view_command, action_command, then chain
CommandSpecBuilder::argument(spec, arg) and ::icon(spec, icon).
Arguments
| Kind | Behavior |
|---|---|
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 aNoViewcommand directly — no launcher window; the command’s toast is the confirmation.Viewcommands 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
| Builder | Runs | Group |
|---|---|---|
copy(id, text) | Action::Copy — clipboard + feedback | Standard |
open_url(id, url) | Action::OpenUrl — system handler | Standard |
open_path(id, path) | Action::Open — file or folder | Standard |
reveal_path(id, path) | Action::RunShell — Finder / Explorer / xdg-open | Standard |
custom(id, label, action, icon) | any concrete Action | Standard |
destructive(id, label, action) | removal-class actions | Destructive |
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::copyinstead ofAction::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:
-
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) } } -
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) } } -
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
| Method | Behavior |
|---|---|
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
| Data | TTL | Example |
|---|---|---|
| OS state that changes while you watch | 5 s | open tabs, now playing |
| Documents and files | 60 s | notes index |
| Network payloads | 15 min | weather |
| Expensive installs | 24 h+ | application list (rebuilt on launcher open) |
Rules
- Invalidate after mutations.
media-controlclears its cache after a transport command so the next read reflects the change. - Never call
get_or_fetchfromsearch. 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
| Situation | Result |
|---|---|
| File missing | T::default() |
| Key missing | the field’s default |
| Unknown keys | ignored |
| File unreadable or unparseable | load → 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::Openalready closes the window and shows the result). - Notifications from
execute— the window is up; a toast is enough. println!/eprintln!debugging left in; usecorvo_platform::diagnosticsfor local error logs.
Icons
SearchResult.icon is a corvo_core::Icon. One row, one icon; the
launcher renders it identically everywhere.
The variants
| Variant | Use for | Example |
|---|---|---|
Icon::Svg(data) | any conceptual glyph, from the bundled Phosphor set | phosphor_svgs::style::regular::MAGNIFYING_GLASS |
Icon::Image(path) | a real application’s icon, extracted to a cached PNG | browser tabs, app rows |
Icon::Glyph(c) | emoji-as-icon | the emoji picker’s numbered rows |
Icon::Web | fallback for web-shaped rows | links, bookmarks |
Icon::Link | bookmarks and quicklinks | |
Icon::File, Icon::App, Icon::Clipboard, Icon::Window, Icon::System, Icon::Calculator, Icon::Snippet, Icon::Emoji | generic 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::Imagecovers real apps. - Icon meaning is consistent across extensions:
Icon::Linkis always a bookmark-ish thing,Icon::Weba 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
| Function | What 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
| Area | Entry points |
|---|---|
| Processes | process_snapshot, terminate_process, listening_port_snapshot |
| Media | media_now_playing, media_transport (AppleScript / SMTC / MPRIS) |
| Browsers | browser_tabs, focus_browser_tab, browser_app_icon, browser_app_installed, browser_data_candidates, plus the cdp module |
| Windows & apps | platform_ops().list_windows()/focus_window(), list_apps_in_scopes |
| Permissions | permissions::is_granted, permissions::request (macOS TCC) |
| Updates | the 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’svolume25,like,copyEmbedexist 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.accessoryis 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+ asection("Recently used")row group. - Behavior is preferences, not settings commands: Kill Process
exposes
showPID,showPath,closeWindowAfterKill,clearSearchBarAfterKill; Google Search hasrememberSearchHistoryanduseClipboardFallback; Emoji hasprimaryAction: 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
Failurestyle, 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, andinstant-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-behaviorflips 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.
Computation in the search bar
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-targetspasses. -
cargo clippy --workspace --all-targets -- -D warningspasses. -
cargo test --workspacepasses, 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— nevercorvo-ui. - No
#[cfg(target_os)]in the command crate; OS work goes throughcorvo-platform(all three OS files in the same PR). -
searchnever blocks: slow work sits behind aTtlCache, 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
prefsmodule; 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.mdentry per user-visible change:## [Added X] - 2026-10-03(Keep-a-Changelog style). -
README.mdin the crate when setup is required (tokens, services, permissions). -
EXTENSIONS_PLAN.mdupdated 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 likenpmoriOS. - 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
- Small fixes go into the existing extension’s PR; get the maintainer’s sign-off for structural changes.
- A new extension for a different configuration of the same service
is its own extension (
GitHub CloudvsGitHub Enterprise), not a merged mega-extension. - The reviewer checks out the PR branch and runs the extension
locally (
cargo run) — review includes using it. - 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.
| Pattern | Read | What to learn |
|---|---|---|
| The standard extension | templates/extension | every contract part at once: routing, page, sections, actions, prefs, cache, tests |
| Declarative rich page | commands/countdown | manifest with typed arguments, a ticking Blocks page, storage, and zero UI code |
| Network + cache + debounce | commands/weather | TTL cache keyed by argument, debounced UI fetch, emoji-coded conditions |
| Dedicated page with state | commands/pomodoro + the pomodoro page in corvo-ui | a state machine behind a mutex, notifications from a watcher thread, a bespoke page |
| Platform abstraction | commands/media-control | a thin crate over corvo-platform (AppleScript / SMTC / MPRIS) with one result model |
| Many commands, one crate | commands/browser-tabs | a BrowserSpec table + a macro generating eight commands with install detection, real icons, and CDP/AppleScript backends |
| Actions menus | commands/brew | menu builders, destructive actions, subprocess timeouts |
| Sections in a list | commands/brew (upgrades mode) | contiguous sections rendered as Formulae / Casks groups |
| Persistence | commands/notes | file-per-item storage, atomic saves, a full editor window |
| Search ranking | commands/app-launcher | frecency ranking via the shared search core |
By module
corvo_ext::list— every crate; start withtemplates/extension.corvo_ext::cache—weather,media-control,browser-tabs.corvo_ext::prefs—templates/extension; global settings live incorvo-config.corvo_ext::actions—brew,text-utilities,kill-process.corvo_ext::routing—weather,browser-tabs,pomodoro.corvo-platformsubsystems —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
- An extension is one crate under
commands/<your-id>/where<your-id>is the command id, kebab-case. Start fromtemplates/extension/— copy it, rename the package tocorvo-<your-id>, and changeCommand::id,KEYWORDS, and the demo data. - Register the command with
corvo_core::register_command!once in the crate. Then touch exactly two lines outside the crate: one[workspace] membersentry and one[dependencies]entry in the rootCargo.toml. Never editsrc/main.rs. - Dependencies:
corvo-core(the Command trait and types),corvo-ext(the kit:list,actions,cache,prefs,routing,feedback), andcorvo-platformonly if the command does OS work. Never depend oncorvo-ui. - No
#[cfg(target_os = ...)]inside the command crate. OS work goes throughcorvo-platformfree functions; if a new OS capability is needed, implement it incorvo-platform’smacos.rs,windows.rs, andlinux.rsin the same change (OS parity is mandatory — degrade honestly withCommandError::Unsupportedor a hint row when a platform cannot do it).
Behavior
search(query)runs per keystroke and must never block: no network, processes, AppleScript, CDP, or file-tree walks. Slow data lives behind acorvo_ext::cache::TtlCachestatic, warmed by afetch_*function the UI runs throughsmol::unblock;searchreadscache().get(ttl)and may answer empty.- 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::toastfor inline confirmation. Keep sections contiguous (sort by section, then score). - Result ids are namespaced:
{id}:open(page opener),{id}:{action}[:{data}](actions),{id}:empty(hint rows).executestrips the{id}:prefix withrouting::keyand returnsCommandError::NotFoundfor anything else. Nounwrap/expect/panic outside tests. 7b. Rich pages are declarative: implementmanifest()(title, description, icon, declared commands with typed arguments) andpage(query)returning aPageView— composed Blocks with aRefresh::Everycadence for live state,corvo_ext::pageshelpers for Detail/Grid/Form. Page buttons and form submits route back throughexecute("{id}:page:{action}"). Never touchcorvo-uito render anything; readbook/src/views.mdandbook/src/modules/pages.mdfirst.commands/countdownis the reference implementation. - Slow work in
executeruns insidesmol::unblock; subprocesses run throughcorvo_platform::run_process_with_timeoutwith an explicit budget. - 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.mdwhen setup is needed (tokens, services, permissions) and aCHANGELOG.mdentry:## [Added X] - YYYY-MM-DD. EXTENSIONS_PLAN.mdupdated if the change moves the plan.- Conventional commit messages (
feat(extensions): ...). - Documentation lives in
book/src/(published via GitHub Pages): readconcepts.md, the module chapters undermodules/, andcontributing.mdbefore 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.