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"]| Key | Required | Notes |
|---|---|---|
id | yes | 1–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. |
name | yes | What the person sees. |
version | yes | Your 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. |
description | no | One line, shown in the Extensions list. |
summary | no | A paragraph that leads the About page Lumi writes for you. Falls back to description. |
author | no | Shown on the card and the review sheet. |
homepage | no | An https:// address of at most 512 characters, drawn as a link on the About page. Any other scheme is refused. |
menu | no | "auto", "group" or "flat" — how the Action picker lists your commands. See how commands are listed. |
about | no | A 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-page | no | Likewise for the Settings tab, in place of the form Lumi draws from [[settings]]. |
settings-tab | no | false 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-version | no | The 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. |
capabilities | no | What 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.
| Capability | Unlocks | The review sheet says |
|---|---|---|
accessibility | selection() | reads what you have selected |
clipboard | clipboard_text(), set_clipboard_text(), and the ⌘C fallback inside selection() | reads and writes the clipboard |
applications | open_url() | opens and quits applications, and sees which one you are using |
network | fetch(), and web addresses in open_url() | sends data over the network |
config | everything in lumi::config | reads 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'sicon.svginstead. A value that is neither a name of that shape nor an.svgpath 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:
menu | The 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:
| Key | Notes |
|---|---|
name | Required; unique within its table. The key in the JSON your code receives. |
label | Required. What the form shows beside the field. |
kind | One of the kinds below. |
default | Written 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. |
required | true marks the field as needing a value; a shortcut will not save with it empty. A required multiselect needs at least one option ticked. |
options | For 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. |
search | For 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, step | For 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. |
pattern | For 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. |
scope | Settings only — see above. |
Every value reaches your code as a string, whatever the kind:
kind | Lumi draws | The value |
|---|---|---|
text | A text field | The text |
textarea | A text field of several lines | The text |
number | A number field | The number, as text |
bool | A switch | "true" or "false" |
select | A pop-up list of options | The chosen value |
segmented | The options side by side — five at most; more is refused, use a select | The chosen value |
multiselect | A tick beside each of up to five options, or a pop-up list that stays open while ticking | A JSON array of the ticked values, in the order options lists them, such as ["en","vi"] |
slider | A track from min to max | The number, as text |
template | A text field with Insert variable beside it | The text, with its {{$…}} variables filled in |
app | Lumi's application picker | A bundle identifier, such as com.apple.Safari |
keys | Lumi's key recorder | A 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
selectis a pop-up list. If any option has anoteand 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
segmentedwhose options carry anoteis drawn as radio buttons too, for the same reason; without notes it stays side by side. - A
multiselectof 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.
searchoverrides 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" }| Key | Notes |
|---|---|
section | The heading. Leave it out for a section with none. |
rows | Rows 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| Key | Notes |
|---|---|
name | Required. Letters, digits, - and _; what open_window addresses. |
title | The title bar, drawn as "title — extension name", or the title alone when it is exactly your extension's name. Defaults to name. |
path | The entry file under ui/. Defaults to index.html. It must ship in the package. |
width, height | In points, clamped to 240–1600 and 180–1200. Left out, 480 × 360. |
resizable, maximizable, minimizable | Default 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"| Key | Notes |
|---|---|
name | Required. Up to 64 letters, digits, - and _. Your run_ui sees it as :page:keytest. |
label | The 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. |
path | The file under ui/. Defaults to index.html. It must ship in the package. |
function-keys | true 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-key | true 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. |
focus | true 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| Key | Notes |
|---|---|
page | Required. The installer's file under ui/; it must ship in the package. |
title | The title bar. Lumi adds your extension's name if the title does not already hold it. Defaults to "Install name". |
width, height | In 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.
| File | Required | Limit |
|---|---|---|
manifest.toml | yes | 256 KiB |
extension.wasm | yes | 48 MiB |
icon.svg | no | 64 KiB. Drawn on the card; its scripts never run. |
ui/… | no | 200 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.