A Rhai script is the smallest thing that can put an interactive, searchable list in the launcher: a
folder with a manifest and a .rhai file, no Node, no npm, no build step. It runs in-process in a
sandbox that has no filesystem, network, environment or process access unless the manifest asks for
a capability and the host grants it. Why this tier exists, and what it is not, is in
PLAN.md §2.2 and ADR-0005.
| You want… | Use |
|---|---|
| a one-shot command whose output is text | a script command (shell, Python, …) |
| a searchable list with actions, some state, no dependencies | a Rhai script |
| React, npm packages, HTTP, OAuth, forms, grids | a TypeScript extension |
The language itself is documented in the Rhai book. This page covers what Compass adds and expects.
Five first-party scripts live in extensions/rhai-examples/.
Each opens with a comment saying what it demonstrates; pick the closest one and copy the folder.
| Example | Shows | Capabilities |
|---|---|---|
web-search |
the smallest useful script: a table, url::encode, open actions |
application.open, clipboard.write |
unit-converter |
parsing the query, sections, accessories, copy actions, an empty state |
clipboard.write |
epoch-converter |
the time:: helpers, a detail pane with metadata, try/catch |
clipboard.write |
generators |
random::, sub-commands parsed from the query, paste actions |
clipboard.write, clipboard.paste |
quick-notes |
persistent state with storage::, actions that run script functions, returning effects |
storage.read, storage.write, clipboard.write |
mkdir -p ~/.local/share/compass/scripts
cp -r extensions/rhai-examples/web-search ~/.local/share/compass/scripts/my-search
$EDITOR ~/.local/share/compass/scripts/my-search/main.rhaiScripts are found in $XDG_DATA_HOME/compass/scripts/<name>/ (usually
~/.local/share/compass/scripts/, which Compass creates) and then in compass/scripts/ under each
$XDG_DATA_DIRS entry and beside the installed binary (../share/compass/scripts). A script in
your own directory shadows a packaged one with the same folder name. Folders starting with a dot
are ignored.
Each script is a command in root search, found by its title, description and keywords. Enter opens
it: the launcher shows what search returns and calls it again as you type. Compass watches the
script directories, so saving a file is enough: an open script re-renders with the new code, a new
folder appears in root search, and a script that no longer compiles says why when opened. The five
examples are installed with Compass (share/compass/scripts), so they are in root search from the
start.
Every example is also exercised by cargo test -p compass-script --test examples; if you change
one, that test says whether it still works.
my-search/
├── script.toml the manifest (required)
└── main.rhai the entry file (the default name)
title = "Web Search" # required: the root-search title
description = "Search the web" # the root-search subtitle
icon = "globe" # a built-in icon name
keywords = ["google", "duckduckgo"] # extra root-search terms
capabilities = ["application.open"] # what the script may reach; see below
entry = "main.rhai" # optional; must stay inside the folderUnknown fields are an error, so a typo in a field name is reported rather than ignored. The
script's id is script. plus the folder name, which is also the namespace its storage lives in.
The manifest is read without running the script: a host can show what a script asks for, and ask you, before any of its code runs.
Every script defines fn search(query). It is called with the text in the search bar, on every
keystroke, and returns what to show. A script without it does not load.
The simplest return value is an array of items:
fn search(query) {
[
#{ title: "Hello", subtitle: query },
#{ title: "World", icon: "globe" },
]
}Anything richer is a map:
fn search(query) {
#{
placeholder: "Type a city",
sections: [
#{ title: "Favourites", items: [ #{ title: "Lisbon" } ] },
#{ title: "Everything else", items: [ #{ title: "Oslo" } ] },
],
empty: #{ title: "No cities", description: "Try another name" },
}
}Return a map with markdown instead of items/sections to show a detail view.
| Field | Type | Meaning |
|---|---|---|
items |
array of items | rows in an untitled section |
sections |
array of #{ title, subtitle, id, items } |
titled groups, after items |
title |
text | navigation title |
placeholder |
text | search-bar placeholder |
empty |
text, or #{ title, description, icon, actions } |
shown when there are no rows |
filtering |
bool, default false |
true: the launcher fuzzy-filters your rows itself, and you can return everything |
show_detail |
bool | show the detail pane; defaults to on when any row has a detail |
loading |
bool | show a spinner |
actions |
array of actions | actions for the list as a whole |
| Field | Type | Meaning |
|---|---|---|
title |
text, required | the row's text |
subtitle |
text | secondary text |
id |
text | a stable key; set it when rows can move, so the selection follows the row |
icon |
text | a built-in icon name |
keywords |
array of text | extra terms for the launcher's own filtering |
accessory |
text, or #{ text, tag, color, icon, tooltip } |
one trailing decoration |
accessories |
array of the above | several |
detail |
markdown text, or #{ markdown, metadata: [#{ title, text }, …] } |
the right-hand pane; () in metadata is a separator |
actions |
array of actions | the first one runs on Enter |
"Text" accepts strings, numbers, characters and booleans. Any other type, a missing title or an
unknown field is an error naming exactly where it is — for example
invalid view at sections[1].items[3].title: every item needs a title — rather than a row that
silently disappears.
markdown, metadata (as above), title, loading, actions.
An action is a map with a title and exactly one of:
| Key | Does | Needs |
|---|---|---|
copy: text |
copies text, closes the window | clipboard.write |
paste: text |
pastes text into the focused app, closes the window | clipboard.paste |
open: url_or_path |
opens it with the default app, closes the window | application.open |
run: "name" |
calls fn name() in your script |
nothing |
run: "name", args: value |
calls fn name(value) |
nothing |
run: || … |
calls the closure | nothing |
plus, optionally, id, icon, style: "destructive" and shortcut: "ctrl+shift+c" (modifiers
ctrl, alt, shift, meta; cmd means Control, as in the TypeScript tier).
A copy, paste or open action whose capability the script does not hold is refused when the
view is built, with an error naming the capability — not when the user presses Enter.
run actions are how a script changes its own state. The function's return value says what the
launcher should do next:
fn add(text) {
let notes = storage::get("notes") ?? [];
notes.push(text);
storage::set("notes", notes);
#{ rerender: true, toast: "Saved", style: "success" }
}
fn search(query) {
[#{ title: `Add "${query}"`, actions: [#{ title: "Add", run: "add", args: query }] }]
}| Key in the returned map | Effect |
|---|---|
rerender: true |
call search again with the current query |
toast: text (+ message, style: info / success / failure) |
a transient message |
hud: text |
a heads-up message, and the window closes |
close: true |
close the window |
pop: true, pop_to_root: true |
navigate back |
Return () (or nothing) for no effect. Closures are convenient for per-row actions, and capture the
row they were made in:
for name in names {
rows.push(#{ title: name, actions: [#{ title: "Say it", run: || #{ hud: name } }] });
}What a script may reach is decided before any of its code runs, from its manifest and where it is installed:
- Scripts installed with Compass — under an
$XDG_DATA_DIRSentry or beside the binary (the Flatpak's/app/share, a package's/usr/share, an AppImage's own tree) — are granted every capability their manifest declares. Installing them was the decision; they are ours or the distribution's. - Your own scripts, in
$XDG_DATA_HOME/compass/scripts, are granted nothing until you allow it. The first time you open one that declares capabilities, the launcher asks — "Allow Quick Notes to: read its saved data, save data, copy to the clipboard" — before a line of it runs. Enter allows and is remembered; Escape refuses, grants nothing, remembers nothing, and it asks again next time. A script that declares no capabilities is never asked about. - A copy of a packaged script placed in your own directory shadows it, and is yours: it is asked about like any other.
- Allowing covers the capabilities listed at the time. A script that later starts declaring another one is asked about that one when next opened; one it stops declaring is no longer granted.
- Answers are kept in
$XDG_CONFIG_HOME/compass/script-grants.json({"scripts": {"script.quick-notes": ["clipboard.write", …]}}). The launcher's Script Permissions command lists what each script was allowed and revokes it (Enter); deleting an entry, or the file, does the same by hand. It is read again whenever a script is opened, so no restart is needed, and a view open on a script whose permissions were revoked closes.
Capability names this build does not know are never granted and never asked about.
A capability is a line in the manifest that makes a module of functions exist. A script that did
not declare clipboard.write, or declared it and was not granted it, has no clipboard::copy:
the script does not load, with clipboard is not available to this script. There is nothing to try
and catch, and nothing to probe.
| Capability | Functions |
|---|---|
clipboard.write |
clipboard::copy(text) |
clipboard.read |
clipboard::read() → text or () |
clipboard.paste |
clipboard::paste(text) |
application.open |
application::open(url_or_path) |
storage.read |
storage::get(key) → value or (), storage::keys() → array |
storage.write |
storage::set(key, value), storage::remove(key) |
notification.send |
notification::send(title, body) |
Each capability brings only its own functions: clipboard.write gives clipboard::copy but not
clipboard::read. Storage values are anything JSON can hold (strings, numbers, booleans, arrays,
maps, ()), are private to the script, and survive restarts: they live in Compass's encrypted
extension database, in a namespace of the script's own, so storage needs the login keyring as an
extension's does. The clipboard is the one extensions use (the GNOME Shell extension, or
data-control on wlroots, where paste copies and you paste). A host function that fails (the
clipboard is unavailable, say) raises an error the script can catch.
Other capabilities in compass-extension-api (network, processes, windows, OAuth…) have no Rhai
functions yet. Declaring one is allowed and does nothing.
These need no capability; none of them reads anything outside the script.
| Function | Returns |
|---|---|
print(x), debug(x), log(text) |
writes to Compass's log, not to a terminal |
parse_json(text) |
a value; map.to_json() goes the other way |
time::now(), time::now_ms() |
Unix time in seconds / milliseconds |
time::format(seconds) |
RFC 3339 in UTC, e.g. 2023-11-14T22:13:20Z |
time::format(seconds, pattern) |
a time crate format, e.g. "[day] [month repr:long] [year]" |
time::parse(text) |
seconds, from RFC 3339 |
time::parts(seconds) |
#{ year, month, day, hour, minute, second, weekday, ordinal } |
random::uuid() |
a v4 UUID |
random::int(low, high) |
an integer in low..high |
random::float() |
a float in 0.0..1.0 |
url::encode(text) |
percent-encoded text for a query string |
Plus Rhai's pure standard library: arithmetic, logic, strings, arrays, maps, maths, iterators and
function pointers. Absent on purpose: eval, import, sleep, exit and timestamp.
Every call to search or to an action runs under these limits. Hitting one stops the call with an
error in the launcher; try/catch cannot catch it.
| Limit | Default |
|---|---|
| operations per call | 500,000 |
| wall-clock time per call | 2 s |
| call depth (recursion) | 48 |
| expression nesting | 64 (32 inside functions), checked when loading |
| string length | 1 MiB |
| array length, map size | 10,000 |
| variables in scope | 1,000 |
search runs on every keystroke, so aim for a small fraction of these. A script runs off the UI
thread, so a slow one makes its own results late but never freezes the launcher.
- Functions cannot see variables outside them. Top-level
consts are reached asglobal::NAME; everything else is passed in. (Top-level code also runs on every call, so keep it to constants.) - Back-tick strings are raw:
`a\nb`is four characters. Use"\n"in a quoted string, and back-ticks for${interpolation}. - Some string methods change the string in place and return nothing:
trim(),make_lower(),truncate(). Copy first:let q = query; q.trim();. (to_lower(),to_upper()return new strings.) - A
forvariable is a copy. To change an array's elements, index it:for i in 0..items.len() { items[i].x = 1; }. try/catchis a statement, not an expression: assign inside it.- Variables must be declared before use (strict mode), so a typo is caught when the script loads, not when a user reaches that line.
- Integer division truncates:
7 / 2is3; write7.0 / 2.0for3.5.
The tier is the compass-script crate; the engine's side is crates/compass/src/rhai_scripts.rs
(loading, the grant policy, the view session, hot reload) and rhai_host.rs (the real
ScriptHost). A script's root entry is rhai:script.<name>; the launcher lists them with
ListRhaiScripts (IPC v14) and opens one with RunExtensionCommand, after which it is followed
and driven with the same ExtensionView / ExtensionEvent / ExtensionAlertAnswer /
CloseExtension requests as an extension's view. The search bar's text arrives as the
rhai.search handler; a consent prompt is the view's alert. The crate's pieces:
discovery::search_paths()/scan()/load()— find scripts, with shadowing, ascompass_core::manifest::registryfinds Node extensions.ScriptManifest::declare_into(&mut CapabilityRegistry)— record declarations; the host then grants (after asking the user, or by policy).ScriptInstance::load(&script, ®istry, host, Limits::default())— build the engine and compile. Capabilities are resolved here, once; after a grant changes, build a new instance.instance.search(query).await→compass_extension_api::ViewTree, the same type the TypeScript tier produces.instance.invoke(&ActionRequest).await→ActionResponse. Use the seam'sActionIndexandPendingexactly as for a TypeScript extension.ScriptHost— the trait a host implements for the capability-backed functions. Every method takes aCapabilityGrantby value, which onlyCapabilityRegistry::checkcan produce.MemoryHostis the in-memory reference implementation used by the tests.ScriptWatcher+ScriptSet— hot reload: a debounced "something changed", and a diff of what did.
The negative sandbox tests in crates/compass-script/tests/sandbox.rs are the specification of the
sandbox; tests/seam.rs renders one fixture through the TypeScript tier's view_model::to_view and
through a script and requires the same view.
crates/compass-script/tests/examples.rs loads each example with discovery::load, grants what
its manifest declares, calls search and fires actions against a MemoryHost. Copy one of those
tests, point it at your folder, and cargo test -p compass-script --test examples shows the view
your script returns — or the exact path of what is wrong with it.