Lumi
Extensions

The manifest

manifest.toml — identity, capabilities, commands, nodes, settings, windows, and the package it ships in.

manifest.toml is what Lumi's installer checks and what the review sheet reads to the person before anything is installed. Everything an extension contributes is declared here; code can only reach what the manifest names.

Every refusal names the field it is about, so a manifest Lumi will not accept tells you why the moment you install it.

[extension]

[extension]
id = "dev.you.thing"
name = "Thing"
version = "1.2.0"
description = "One line for the extension's card."
summary = "A paragraph for the About page."
author = "You"
homepage = "https://example.dev/thing"
about = "about.html"
settings-page = "settings.html"
min-lumi-version = "1.22.0"
capabilities = ["accessibility", "clipboard"]
KeyRequiredNotes
idyes1–100 characters of lowercase letters, digits, ., - and _, not starting with .. Reverse-DNS by convention, in lowercase: dev.you.thing, never Dev.You.Thing. It never changes: an update is the same id with a higher version.
nameyesWhat the person sees.
versionyesYour version, shown on the card. Write it as major.minor.patch — 1.2.0, not 1.2 or 1.2.0-beta: Lumi installs any version, but compares two only when both have that shape — so an update is offered, and a store package older than the installed one is refused, only between versions written that way. A file you install yourself may be older; the review sheet then says Downgrade.
descriptionnoOne line, shown in the Extensions list.
summarynoA paragraph that leads the About page Lumi writes for you. Falls back to description.
authornoShown on the card and the review sheet.
homepagenoAn https:// address of at most 512 characters, drawn as a link on the About page. Any other scheme is refused.
menuno"auto", "group" or "flat" — how the Action picker lists your commands. See how commands are listed.
aboutnoA page of your own under ui/, drawn in place of the About page Lumi writes — see About and Settings pages. The file must ship in the package.
settings-pagenoLikewise for the Settings tab, in place of the form Lumi draws from [[settings]].
settings-tabnofalse hides the Settings tab while keeping your [[settings]] — for fields that are storage for a page of your own rather than something the person sets. Your code and pages still read and write them. Cannot be combined with settings-page. Defaults to true; with no settings and no settings page there is no tab either way.
min-lumi-versionnoThe oldest Lumi this manifest was written for, as exactly major.minor.patch — 1.22 is refused. Installing on an older Lumi is refused with a sentence naming both versions, before anything is written to disk — the same install-time refusal a new capability or param kind gets, but worded so the person knows to update Lumi rather than wonder what teleport is. An update that needs a newer Lumi is listed as needing it rather than offered. Left out, there is no floor. Extensions first shipped in Lumi 1.22.0, so a value below that sets none.
capabilitiesnoWhat the code may reach — below.

Capabilities

Declare only what the code calls. A call whose capability is missing is refused by name when it runs, so an under-declared manifest fails loudly on your own machine, and an over-declared one costs trust on the review sheet.

CapabilityUnlocksThe review sheet says
accessibilityselection()reads what you have selected
clipboardclipboard_text(), set_clipboard_text(), and the ⌘C fallback inside selection()reads and writes the clipboard
applicationsopen_url()opens and quits applications, and sees which one you are using
networkfetch(), and web addresses in open_url()sends data over the network
configeverything in lumi::configreads your shortcuts, snippets and Lumi settings

Everything else in the SDK needs no capability — see what each call costs.

input, windows, files, system and shell are reserved: a manifest asking for one is refused with a sentence saying it is not available yet. Any other word is refused as unknown.

[[command]]

An action the person can bind to a shortcut, a leader menu step, a double-tap or an Fn combination.

[[command]]
name = "translate"
label = "Translate selection"
icon = "globe"

[[command.params]]
name = "target"
label = "Translate into"
kind = "select"
default = "vi"

[[command.params.options]]
value = "vi"
label = "Vietnamese"

name is what run_command receives, unique among your commands; label is what the action picker shows. Both are required, on a [[node]] too. Params become fields on the binding — each shortcut bound to the command keeps its own values — and reach your code as a JSON object, with any {{$…}} variables already rendered. A field the person left alone arrives at its default, the value the shortcut sheet showed them, so your code does not need a second copy of your manifest's defaults.

icon is the command's mark in the action picker and the shortcut list, in one of two spellings:

  • A Lucide icon name — icon = "globe": lowercase letters and digits in words joined by -, as on the Lucide site, 64 characters at most. Lumi ships a chosen set of Lucide icons — the ones its own icon picker offers — and a well-formed name outside it is drawn with your extension's icon.svg instead. A value that is neither a name of that shape nor an .svg path refuses the manifest.
  • An SVG file under ui/ — icon = "icons/translate.svg", 64 KiB at most. The file must ship in the package.

Without an icon, the command wears your extension's icon.svg.

How commands are listed

In the shortcut sheet, the Action picker lists extensions under an Extensions heading. By default an extension with two or more commands is one row that opens a submenu of them, and an extension with a single command lists that command directly — a submenu of one is a click spent on nothing. Set menu under [extension] to choose for yourself:

menuThe picker shows
"auto" (default)A submenu for two or more commands, the command itself for one.
"group"Always a submenu, even for one command — so the row does not move when you add a second.
"flat"Every command directly under Extensions, each with your extension's name beside it.

A search in the picker lists every matching command directly, whatever menu says.

[[node]]

A step in the Flow Editor's palette.

[[node]]
name = "shout"
label = "Shout"
summary = "Upper-cases each item's text field."

[[node.params]]
name = "suffix"
label = "Suffix"
kind = "text"
default = "!"

name may hold only letters, digits, - and _: it becomes part of the node's type in every flow that places it, so it cannot change once people use it. run_node receives the params as a JSON object and the incoming items as a JSON array, and answers with the outgoing items as a JSON array. An empty array ends that branch of the flow — the same rule every built-in node follows.

[[settings]]

A form for the extension as a whole, drawn under Settings → Extensions and read with lumi::settings().

[[settings]]
name = "defaultTarget"
label = "Default language"
kind = "select"
default = "vi"
scope = "profile"

[[settings.options]]
value = "vi"
label = "Vietnamese"

Lumi stores the values for you. A setting the person never touched reads as its default, so your code never has to tell a chosen value from a defaulted one.

One value per profile

Add scope = "profile" and Lumi keeps a separate value in each of the person's profiles. Your code does not change: lumi::settings() hands back the live profile's value. Leave scope out, or write "mac", for one value across every profile.

Changing a field's scope in an update is safe: made per-profile, the value chosen Mac-wide carries into every profile until one of them changes it; made Mac-wide again, it reads the Mac-wide value. scope is for [[settings]] only — a command's or a node's param is already per shortcut or per node — and no setting may be named byProfile.

The Settings tab lays settings out one under another. To arrange them, add a layout after your [[settings]] entries — the same keys as a command's layout: sections, rows of two, when and help.

[[settings.layout]]
section = "Service"
rows = [["service"], ["apiKey", "formality"]]
when.apiKey = { param = "service", equals = "deepl" }
help.apiKey = "From your DeepL account page."

Settings save as they change. A value that breaks required, pattern or the bounds is not saved: the field says why when it is left, and your code goes on reading the last value that passed. The file the values live in is still a plain file anybody can edit, so treat a stored value as unchecked.

Fields

Command params, node params and settings share one schema:

KeyNotes
nameRequired; unique within its table. The key in the JSON your code receives.
labelRequired. What the form shows beside the field.
kindOne of the kinds below.
defaultWritten as a string, except for multiselect: a list of option values, such as default = ["en", "vi"], each one of its options; left out, nothing is ticked.
requiredtrue marks the field as needing a value; a shortcut will not save with it empty. A required multiselect needs at least one option ticked.
optionsFor select, segmented and multiselect: a list of { value, label }, at least one, each value different. An option may add note, a line of explanation under its label.
searchFor select and multiselect only: true or false forces a search box in the list on or off. Left out, a list longer than twelve gets one.
min, max, stepFor number and slider only; on any other kind they are refused. step must be above zero. A slider needs both min and max, with min below max.
patternFor text, textarea and template only: a regular expression the value must match before a shortcut saves. Unanchored — write ^…$ to match the whole value. It is matched against the value with its leading and trailing spaces removed, and an empty value is not matched at all — so a pattern does not make a field required; required does. Character classes, groups, alternation and repetition are supported; lookaround and backreferences are not, and a pattern using either is refused at install. At most 512 characters.
scopeSettings only — see above.

Every value reaches your code as a string, whatever the kind:

kindLumi drawsThe value
textA text fieldThe text
textareaA text field of several linesThe text
numberA number fieldThe number, as text
boolA switch"true" or "false"
selectA pop-up list of optionsThe chosen value
segmentedThe options side by side — five at most; more is refused, use a selectThe chosen value
multiselectA tick beside each of up to five options, or a pop-up list that stays open while tickingA JSON array of the ticked values, in the order options lists them, such as ["en","vi"]
sliderA track from min to maxThe number, as text
templateA text field with Insert variable beside itThe text, with its {{$…}} variables filled in
appLumi's application pickerA bundle identifier, such as com.apple.Safari
keysLumi's key recorderA combination in Lumi's spelling, such as Super+Shift+KeyZ

A multiselect is not available to [[node.params]] yet: the flow editor has no control for several answers, so a node that declares one is refused. Use one bool per option there.

Which control a choice gets

You say what the answers are, and Lumi chooses how to draw them, so a form looks like the rest of Lumi and changes with it. There is no field for picking a widget. The rules:

  • A select is a pop-up list. If any option has a note and there are five options or fewer, it is drawn as radio buttons instead, each note under its label, so the notes are read before choosing rather than after. With more than five, it stays a pop-up list and each note is drawn under its row.
  • A segmented whose options carry a note is drawn as radio buttons too, for the same reason; without notes it stays side by side.
  • A multiselect of five options or fewer is a column of ticks. Longer, it is a pop-up list that stays open while you tick.
  • A pop-up list of more than twelve options opens with a search box, which matches labels and notes. search overrides this either way.
[[command.params]]
name = "tone"
label = "Tone"
kind = "select"
default = "neutral"

[[command.params.options]]
value = "neutral"
label = "Neutral"
note = "Keeps the register of the original."

[[command.params.options]]
value = "formal"
label = "Formal"
note = "For email and documents."

[[command.params]]
name = "also"
label = "Also translate into"
kind = "multiselect"
default = ["en"]

[[command.params.options]]
value = "en"
label = "English"

[[command.params.options]]
value = "ja"
label = "Japanese"

Reading a multiselect in Rust:

let also: Vec<String> = serde_json::from_str(&value).unwrap_or_default();

Lumi reads a manifest leniently where it can and strictly where it must. A key it does not know is ignored, so a key a later Lumi adds is skipped by this one. A value it does not know — a new kind, a new capability — refuses the manifest and names the value, because drawing a field it cannot draw, or running code that needs something it cannot give, would be a guess.

[[command.layout]]

By default a command's params are drawn one under another beside the Action field. A command that needs more room declares a layout, and Lumi then draws its form at the full width of the shortcut sheet, in sections:

[[command.layout]]
section = "Languages"
rows = [["from", "to"], ["code"]]         # one or two params per row
when.code = { param = "to", equals = "other" }
help.code = "A BCP 47 code, as the translation service expects it."

[[command.layout]]
section = "Result"
rows = [["output"], ["keep"], ["text"]]
when.keep = { param = "output", equals = "replace" }
when.text = { param = "output", not-equals = "replace" }
KeyNotes
sectionThe heading. Leave it out for a section with none.
rowsRows of one or two param names; two sit side by side. Each param may be placed once.
when.<param>Show that param only while another param equals — or does not equal, not-equals — a value. Write exactly one of the two. Both params must be placed in the same section, and a param cannot depend on itself. A hidden param keeps its value and is not required.
help.<param>A line of help under that param, which must be placed in the same section.

The layout is presentation only: the values a shortcut saves are the same one string per param either way, so you can change a layout in an update without touching anybody's shortcuts. A param the layout does not place is drawn after the sections. An empty row is refused.

Lumi draws every control itself, so your form always looks like the rest of the sheet. There is no way to add styling or controls of your own.

[[window]]

A dialog of the extension's own, drawn from files under ui/ in the package. See Windows for what a page can do.

[[window]]
name = "settings"
title = "My settings"
path = "index.html"
width = 440
height = 420
resizable = false
maximizable = false
KeyNotes
nameRequired. Letters, digits, - and _; what open_window addresses.
titleThe title bar, drawn as "title — extension name", or the title alone when it is exactly your extension's name. Defaults to name.
pathThe entry file under ui/. Defaults to index.html. It must ship in the package.
width, heightIn points, clamped to 240–1600 and 180–1200. Left out, 480 × 360.
resizable, maximizable, minimizableDefault true; write only the ones you turn off.

There is no way to hide the close button or the title bar, on purpose: a dialog the person cannot dismiss, or one that does not name the extension that drew it, is a way to impersonate something else.

[[page]]

A tab of the extension's own on its page under Settings → Extensions, drawn from a file under ui/ — a keyboard tester, a dashboard, a log. It sits between About and Settings and works exactly like an About or Settings page.

[[page]]
name = "keytest"
label = "Keyboard test"
path = "keytest.html"
KeyNotes
nameRequired. Up to 64 letters, digits, - and _. Your run_ui sees it as :page:keytest.
labelThe tab's text, up to 24 characters. Defaults to name. It may not be About, Settings or Permissions, in any case — those tabs are Lumi's.
pathThe file under ui/. Defaults to index.html. It must ship in the package.
function-keystrue takes F1–F12 from macOS's own shortcuts while the page is in front — F11 is Show Desktop, which otherwise moves every window aside — and sends them as lumi:key events. Defaults to false. See taking F-keys.
fn-keytrue sends the page fn / Globe pressed on its own — a key a web page is otherwise never sent — as a lumi:fn event. Defaults to false. See hearing fn.
focustrue gives the page the keyboard as soon as its tab is opened, so keys reach it without a click first — for a page whose whole use is keystrokes, like a keyboard tester. Defaults to false: a focused page receives what the person types.

At most four pages. Neither a name nor a label may be used twice.

[install]

An installer of your own: a page under ui/ that opens after Lumi's review of the package and asks whatever you need before the extension is installed. See An installer of your own.

[install]
page = "install.html"
title = "Set up My extension"
width = 420
height = 380
KeyNotes
pageRequired. The installer's file under ui/; it must ship in the package.
titleThe title bar. Lumi adds your extension's name if the title does not already hold it. Defaults to "Install name".
width, heightIn points, clamped to 240–1600 and 180–1200. The window cannot be resized, maximised or minimised.

The table is optional. Without it the extension installs in one press, with every setting at its default.

The package

A package is a gzipped tar. Lumi finds manifest.toml, extension.wasm and icon.svg by name wherever they sit, and a package holding two of any of them is refused. ui/ may sit at the top of the archive or inside one directory.

FileRequiredLimit
manifest.tomlyes256 KiB
extension.wasmyes48 MiB
icon.svgno64 KiB. Drawn on the card; its scripts never run.
ui/…no200 files, 5 MiB each, 24 MiB in all.

The whole package is at most 64 MiB. manifest.toml and icon.svg must be UTF-8. Paths under ui/ must be plain relative paths of at most 512 characters — letters, digits, ., - and _ in each segment, nothing hidden, no path twice — and a segment named __lumi__ is reserved for the bridge. A precompiled extension.cwasm is refused: a package ships WebAssembly, never native code.

The ._ files macOS's tar adds beside every file are ignored, so a package built on a Mac needs no special flags.

On this page