Extensions
Every palette in pal is an extension, the bundled ones included: a
directory with a pal.json manifest and an index.ts that Bun runs inside
one long-lived extension host. An extension declares palettes; a palette
lists rows, says what happens when one is picked, and can declare settings
the settings window renders and the config file keeps. An extension can
also put items on the bar ("Bar items", below).
The shapes below are provisional: sdk/src/protocol.ts (the types of
@zcag/pal, every one with a doc comment) is the contract and moves ahead
of this page while pali is being built.
Where they live
- Bundled:
extensions/<name>/in the repo (the app's resource tree in a release build). - The store, what
pal installand the settings window fill:~/Library/Application Support/pal/extensions/<name>/on macOS,~/.local/share/pal/extensions/<name>/on Linux ($XDG_DATA_HOME/pal/when set). One store for every config file. Not in the config directory: that is often a dotfiles checkout, and the store's install records, staging directory and the host'snode_modules/@zcag/pallink do not belong in one. - Yours, kept wherever you like: every directory in
general.extension_dirs(Config), one subdirectory per extension, for a set that lives in dotfiles.
The roots load in that order and a later one wins on a name: a directory
named like a bundled extension replaces it. The host loads every directory
under those roots that has an index.ts (or index.js), watches them, and
reloads an extension whose files change.
The manifest, pal.json
Read without running the code, so the settings window lists an extension whose code fails to load.
{
"name": "hello",
"title": "Hello",
"description": "One palette, three rows, one setting.",
"version": "0.1.0",
"icon": "π",
"author": "you",
"repo": "github.com/you/pal-hello",
"settings": [
{ "kind": "text", "id": "greeting", "label": "Greeting", "default": "Hello" }
],
"palettes": {
"hello": { "description": "What the palette is for." }
}
}
name: required, the directory name and the config key; lowercase letters, digits,-,_,..pal installrefuses anything else.version: required (a string).title,description,icon(an emoji, glyph or hex colour),author,repo: what the settings window shows.settings: extension-level settings,[extensions.<name>]in the config file. Kinds:text,secret,number,boolean,select,hotkey,path,list; each withid,label, optionaldescriptionand adefault.palettes.<key>: per-palettetitle(a fallback while the code fails),description,settings([palettes.<id>].settingsin the file) andttl. The key is the palette's key in the code'spalettesobject.bar.<id>: a bar item'stitle,descriptionandrefreshschedule (below, "Bar items"). The id is the key in the code'sbarobject.
The code, index.ts
The default export is { palettes: { <key>: Palette } }. A palette
(provisional shape, Palette in @zcag/pal):
import { defineExtension, settings, type Item } from "@zcag/pal";
export default defineExtension({
palettes: {
hello: {
title: "Hello", // the section label at the root
icon: "π",
list: (): Item[] => [ // rows; indexed unless `input: true`
{ id: "greet", name: "Hello, world", subtitle: "a row", icon: "π", actions: [{ id: "copy", title: "Copy" }] },
],
pick: (id, action) => ({ copy: "Hello, world" }), // an Effect
},
},
});
defineExtension only type-checks the object where it is written (it is
satisfies Extension under a name an editor completes); the bundled
extensions use satisfies directly.
list(query?, ctx?)returnsItem[], sync or async. Withoutinput: truethe host lists once, the core indexes the rows, and the root search matches them like everything else; the palette is listed again when its settings change, oncmd+r, or afterttlseconds at the next start. Withinput: trueit runs on every keystroke inside the palette and the root has only the palette's own row (a calculator).pick(id, action?, ctx?)returns anEffect:copy,copy_files(a list of paths: the files themselves, see the note below),open(url or path),paste,focus(a window id),hide,toast,hud(a line in the HUD capsule after the panel hides;copyalone shows "Copied" there),keep(stay open and list again),push(drill into a palette withargs),show(a detail-only level),view(a render tree, below),form(a prompt with fields, below).ctxcarriesfilter, theargsof thepushthat opened the level, and on a form's submit itsvalues.detail(id, ctx?): the detail pane's content for a row, asked lazily.- Palette flags:
live(arrival order, re-listed on every show, not twice within 2 s; with attl, only once the last listing is older than that),view: "grid"+columns,placeholder,showDetail,filters,ttl. Without attla palette is listed on every load: the bundled TypeScript extensions have none, they are cheap;scriptsdefaults its tables to an hour (Scripts). - An
Itemhasid(stable),name,subtitle,icon,keywords,url,accessories,detail,actions(first is Enter, second cmd+Enter; an empty list is an inert hint row).
Settings reach the code resolved: the manifest's defaults with the file's values on top, kept current on every config change (an extension whose values changed is listed again).
View palettes: a render tree
A palette can draw instead of list: give it view(ctx) in place of
list, returning a View, and pal opens it as a view level. The
bundled Blackjack is one (extensions/blackjack/). The tree is built from
a fixed vocabulary the app draws with its own tokens, never HTML, so a view
looks like the rest of the panel in both themes.
export default {
palettes: {
table: {
title: "Table",
view: () => ({
title: "Your turn",
keys: "actions",
tree: { type: "stack", padding: 4, gap: 2, children: [
{ type: "text", value: "Dealer", style: "muted", size: "xs" },
{ type: "stack", direction: "row", gap: 1, minHeight: 80, children: [
{ type: "image", key: "d0", src: svgDataUrl, width: 56, height: 80, transition: { enter: "slide-up" } },
] },
{ type: "badge", text: "17", color: "blue" },
] },
actions: [{ id: "hit", title: "Hit", shortcut: "h" }, { id: "stand", title: "Stand", shortcut: "s" }],
}),
pick: (_id, action) => ({ view: nextTree(action) }),
},
},
} satisfies Extension;
- In a view level the search input is gone: the view's
titlestands in its place, the footer shows the first action and "Actions βK", βK listsactionswith their keys, Escape (and ββ«) leaves. Enter runs the first action, βEnter the second, a modifier combo the action carrying it asshortcut. Withkeys: "actions"a bare key does too:h,space,+,-,up,down,left,rightasshortcut. One pick at a time: a key pressed while the reply is on its way is dropped, not queued, and the search row sweeps meanwhile. - A pick from the view is
pick(id, action, ctx)with the view'sid(viewunless the view sets one) and the action's id. Answer{ view }and the level's tree is replaced in place; any other effect works as from a row (toastshows over the view,copyhides). From a list row,{ view }pushes a new view level.ctx.argsare thepushthat opened the level,ctx.filterits filter. - Action ids may not start with
pal:(the shell's own); the host refuses such a view. A tree is refused past 2000 nodes or 24 levels deep, and when two siblings share akey. - The palette is reported
input: true(nothing of it is indexed; the root has only its own row) withview: "view". It still declaresicon,titleand settings like any other;detailandfiltershave no meaning for it.
The vocabulary (ViewNode in @zcag/pal; every node may carry key and
transition):
| node | fields | draws |
|---|---|---|
stack |
direction row/column, gap and padding in 4 px steps (0..6), align start/center/end/stretch, justify start/center/end/between, grow, minHeight px, children |
a flex box |
text |
value, style title/body/muted/mono/number, size xs..xl, weight regular/medium/semibold, color (tag palette, accent, success, destructive, muted, faint) |
one run of text |
image |
src (icon://β¦ or data:image/β¦, anything else is not shown), width/height px, mask rounded/circle, alt |
a picture the extension made or the app's icon scheme serves |
badge |
text, color (grey, blue, green, amber, red, violet, pink, teal) |
a tag, as on a row |
divider |
a hairline (vertical in a row) | |
spacer |
size px, else the free space |
space |
progress |
value 0..1, width px |
a bar |
keycap |
keys in the shortcut spelling (h, cmd+k, up) |
key caps, as in the footer |
Unknown node types are skipped, not errors, so a newer extension still
draws on an older app. key makes a node the same node across trees: the
app keeps its DOM, and animates one whose key is new or gone per
transition: enter fade, slide-up (6 px rise) or flip (a
horizontal unfold); exit fade (default) or none; delay staggers the
entrance in steps of 80 ms (0..8). Durations and easings are the tokens';
reduced motion turns them off. A row whose keyed children come and go wants
a minHeight so the layout holds still.
Forms: asking for values
A pick can answer { form } instead of doing something, and pal pushes a
form level: the fields in place of the list, the form's title in
the search row, the submit in the footer. Enter submits (βEnter from a
textarea), Escape leaves, Tab moves between fields. The bundled Quicklinks
and Snippets create and edit their rows this way (extensions/quicklinks/,
extensions/snippets/).
pick: async (id, action, ctx) => {
if (action === "edit") return { form: {
id, // what the submit is addressed to; default: the row's id
title: "Edit Quicklink",
fields: [
{ kind: "text", id: "name", label: "Name", required: true, default: link.name },
{ kind: "text", id: "url", label: "URL", required: true, default: link.url, description: "{query} stands for what you type." },
{ kind: "checkbox", id: "pin", label: "Pinned", text: "Show at the top" },
],
submit: { id: "save", title: "Save" },
} };
if (action === "save") {
const { name, url } = ctx!.values!; // a string per field, a boolean per checkbox
if (!/^https?:/.test(String(url))) return { form: { ...editForm(id), errors: { url: "Not a URL" } } };
await store(id, name, url);
return { keep: true, toast: { title: "Saved" } };
}
},
- Fields:
text,textarea,password,select(withoptions),checkbox(withtextbeside the box); each hasid,label, and optionallyplaceholder,default,required,description(a help line under the field). Arequiredfield left empty (unticked, for a checkbox) blocks the submit in the panel: the field is marked and focused, and no pick goes out. - The submit is
pick(form.id ?? rowId, submit.id, ctx)withctx.valueskeyed by field id (ctx.argsare the level's, as for any pick). Answer it with{ form }again carryingerrors: { field: message }and the same form stays with the messages under the fields, what was typed kept; any other effect closes the form and runs as from a row:keeplists the palette again (the new row is there),toastshows over the list,copyor nothing hides.cancelnames the cancel button ("Cancel"). - The host refuses a form without fields, with two fields of one id, a
field of a kind the panel cannot draw, a submit id starting with
pal:, orerrorsfor a field that is not there.
Bar items: glanceable state on the bar
An extension can put an item on the bar (the macOS menu bar, sketchybar,
later a Linux bar): a glyph, a short title, a badge, a fill, and something
that opens on a click. The design is docs/design/bar.md; this is the
extension's side of it. One model, BarItem, rendered by the core on
every target; the extension never talks to a bar.
The model. render(ctx) answers the item's whole state and the core
diffs it against the last one:
hidden: the rule. An item earns its slot by having something to say;hidden: truetakes no space on any target, andrenderkeeps running so it can come back.icon(a glyph from the bundled Nerd Font, an emoji,{ image },{ app }),title(short: 64 characters at most, the menu bar does not truncate),segments(up to 8 extra runs after the title, each with its owncolor),badge(a count, or"dot"),color(the tag palette plustext,muted,accent,destructive),urgent(drawn as an alarm),stale(muted, "could not refresh"),progress(0..1, a thin fill),tooltip,refresh(seconds until the nextrender, this once).menu: what a click, the item's hotkey or a hover peek opens, always in pal's own popover. An array ofBarMenuNodeis a menu level: rows ({ type: "item", id, title, subtitle?, icon?, shortcut?, checked?, disabled?, style?, action? }),sections,submenus (3 deep at most),separators; 64 nodes at most.{ palette: "name", extension?, args? }opens that palette level, the panel machinery unchanged.{ view: View }draws the tree as a view level. Without amenuthe click isonOpenand the extension answers an Effect.
The manifest. bar.<id> next to palettes, so the settings window
lists the item without running the code:
"bar": {
"notifications": { "title": "Notifications", "description": "Unread count", "refresh": { "every": 300, "on": ["show", "wake", "network"] } }
}
refresh.every is seconds between renders (10 at least), on adds
triggers: show (the panel shown), wake, network (back online),
focus (the front app changed), minute. The core renders every item
once at load and whenever the extension's settings change.
The code. bar.<id> in the default export, next to palettes:
export default defineExtension({
palettes: { /* ... */ },
bar: {
notifications: {
render: async (ctx) => { // ctx.reason: load | every | show | wake | network | focus | minute | settings | update | cli | open
const n = await notifications(); // the palette's own loader, one cache
if (n.length === 0) return { hidden: true };
return { icon: "\u{f09b}", badge: n.length, menu: [
{ type: "section", title: "Unread", children: n.slice(0, 5).map((x) => ({ type: "item", id: x.id, title: x.title, subtitle: x.repo })) },
{ type: "separator" },
{ type: "item", id: "read-all", title: "Mark all read", shortcut: "cmd+shift+r" },
] };
},
onAction: async (action, ctx) => { // a menu row was picked (its `action`, default its `id`); `segment:<id>` for a segment
if (action === "read-all") { await markAllRead(); return { keep: true, hud: "Marked read" }; }
return { open: urlOf(action) };
},
onOpen: async (ctx) => ({ copy: code }), // the click on an item that has no `menu`
onShown: async (ctx) => { /* the popover opened (a peek counts): warm a cache */ },
},
},
});
render(ctx)returns aBarItem, sync or async.ctx.reasonsays why (the manifest's timer isevery; a push asking for a render isupdate; a click that opened the popover isopen),ctx.anchorwhere a click came from (menubar,sketchybar,hotkey,cli). The host checks every answer (checkBarItem, the limits above, nopal:action ids, a{ view }menu throughcheckView); over the limits is an error the core marks the itemstalewith.onAction(action, ctx)andonOpen(ctx)answer anEffectlikepick:open,copy,hud,push(drill into a palette in the popover),view,form,keep(re-render the item, the popover stays).settings.get()works inside all of them without an argument.bar.update(id, item)in@zcag/palpushes an item from the extension's own side, for a webhook, a file watcher or a poll it runs itself: the core draws it as ifrenderhad answered.bar.refresh(id)asks for arenderwith reasonupdate. From a timer or a watcher (outsiderender/list/pick) pass the extension's name as the last argument, as forstorage. A push is checked like a render answer.- An extension that runs an interval or a watcher for its pushes declares
dispose()on the default export: the host calls it before the extension is reloaded or removed, since the old module stays resident and would keep pushing otherwise.
The four bundled items, each in the extension that already owns the data, so the strip and the palette share one loader and one cache:
- GitHub,
notifications(extensions/github/): the unread count as a badge, hidden at zero. The popover is a menu level with the newest five (a row marks the thread read and opens it), "Open all" (pushes the Notifications palette) and "Mark all read". Refresh every 300 s and onshow,wake,network; those triggers ask GitHub with the ETag (a 304 is free), the timer takes the cache. Signed out is hidden, not an error: the strip has no room for a hint. - Now Playing,
now-playing(extensions/media/): the playing track as the title, hidden while nothing plays; the popover has Pause, Next, Previous, Copy Track and Open. The core asks every 30 s; while a player was playing at the last look the extension polls the players every 5 s itself and pushes on a track or state change, so a skip shows within seconds and an idle machine costs nothing. - Verification Codes,
latest-code(extensions/otp/): the newest code as the title, green, for a minute after it arrived, then hidden; a click copies it (nomenu, soonOpen). The render setsrefreshto the seconds left in that minute, so the item leaves on time; the manifest asks every 10 s otherwise. - Timer,
timer(extensions/timer/, Palettes): the soonest timer's remaining time with aprogressfill, blue then amber then red, muted while paused,urgentonce it landed; hidden with no timer at all. A click opens thetimerspalette (menu: { palette }). The second-level ticks are the extension's own: anfs.watchon the CLI's state directory pushes on every change, and a 1 Hz interval pushes the countdown while a timer runs.
Storage
storage in @zcag/pal is a small per-extension key-value store:
get(key) (null when unset), set(key, value) (any JSON; null removes),
remove(key), keys(). The core keeps one file per extension,
<data dir>/pal/storage/<extension>.json (~/Library/Application Support/pal/storage/ on macOS, ~/.local/share/pal/storage/ on Linux),
written whole and atomically on every change and shared by every config
profile. It is capped at 256 KB serialised: a set that would cross the
cap rejects and nothing is written. For a bankroll, a cursor, a last-used
choice; not for a cache. Which extension is asking is known inside
list/pick/view and at import time; elsewhere pass the name as the
last argument.
The @zcag/pal package
import { ... } from "@zcag/pal" is pal's extension API: the calls into
the core and the types of everything above. It lives in sdk/ in the repo
and is the package of that name on npm (not yet published; see "Writing
one"). The host links it into <root>/node_modules/@zcag/pal in the store
and in every extension_dirs root, so the name resolves for an extension
there without a fetch (the host runs Bun with --no-install). An
extension that carries its own copy in node_modules (a bun add) gets
that one instead, which works the same: the package reaches the host
through a process-wide slot, not a shared module, so the version in your
node_modules only has to speak the same wire. Every call is one request
to the core.
settings.get<T>(): the extension's values,[extensions.<name>].settings.palette<T>(): the current palette's declared values.settings.onChange(cb): called with new values.clipboard.list({ query, kind, limit, offset }),get(id),pin(id),delete(id),clear(),copy(id)(back onto the clipboard),imageUrl(id, size)for an image entry.windows.list()(every window, front to back, withid,app,title,minimized,on_screen),close(id),minimize(id). Focus is the{ focus: id }effect frompick, so the panel hides first.system.commands()(sleep, lock, dark mode, volume, and so on, withavailableper machine),system.run(id)(hides the panel, then runs).apps.forFile(path): the applications the OS registers for a file (App[]:name,path(the.appor.desktop, usable asicon: { app: path }),bundle_id?,default), the default first; Launch Services on macOS,xdg-mime+mimeapps.list+mimeinfo.cacheon Linux.apps.openWith(path, app)opens the file with one of them.storage.get(key),set(key, value),remove(key),keys(): the extension's own key-value file (above).home(path): a leading~expanded.core.call(method, params): the raw bridge.xdg(name): a freedesktop icon name as the glyph the app draws it with (icon: xdg("dialog-error")), undefined for a name it does not know.bar.update(id, item): push a bar item now (above, "Bar items");bar.refresh(id): ask for a render.checkView(view),checkForm(form),checkBarItem(item): what the host runs on every answer (the limits above), for an extension's own tests.defineExtension(ext): the typed default export.audio.devices()(every output and input,AudioDevice[]:id,name,kind,default,volume,muted,transport),setDefault(id, kind),setVolume(id, kind, percent),setMute(id, kind, muted?)(omitted toggles; resolves with the state after). CoreAudio on macOS,wpctlelsepactlon Linux.bluetooth.devices()(paired,BluetoothDevice[]:address,name,connected,kind,battery,battery_detail; connected first),connect(address),disconnect(address)(synchronous, seconds).system_profiler+ IOBluetooth on macOS,bluetoothctlon Linux.wifi.status()(interface,powered,currentwithssid,signal,channel,security,ip;ssidnull when macOS hides it),known(),scan(mode)(cachednever runs the tool,autoreuses a scan under a minute old,freshscans now;networksstrongest first plushidden, the count of nameless ones),join(ssid, password?),forget(ssid),password(ssid)(the keychain prompts on macOS),setPower(on).networksetup/ipconfig/system_profileron macOS,nmclion Linux.media.nowPlaying()(players:MediaPlayer[]withid,name,state,title,artist,album,artwork,url,app,position,duration, playing first;system_wide: whetherplayerctl/nowplaying-cliis there),control(player, command)(play_pause,play,pause,next,previous; the panel stays up). Spotify and Music over AppleScript plusnowplaying-clion macOS,playerctlon Linux.
The protocol's types ride along: Extension, Palette, Item, Action,
Icon, Effect, Ctx, Detail, View, ViewNode, Form,
FormField, FormValues, BarItem, BarMenu, BarMenuNode,
BarSegment, BarColor, BarCtx, BarSource, Manifest,
SettingSpec, and the API's own
(ClipboardEntry, Window, WindowLayout, SystemCommand, App,
AudioDevice, BluetoothDevice, WifiStatus, WifiNetwork, WifiScan,
MediaPlayer, NowPlaying).
Dependencies: a package.json next to index.ts is honoured; pal install runs bun install --production in the copy it makes. List
@zcag/pal under devDependencies (it is for the editor; the host
provides it at runtime), so that install skips it.
Install, update, remove
From the CLI (pal install SPEC, see CLI) or the settings
window's Extensions page (the box at the top takes the same spec; Update
and Remove are on each extension). A spec is:
github:user/repo,github:user/repo/sub/dir, either with@ref(a branch, tag or commit; the default branch otherwise);https://github.com/user/repo, or.../tree/<ref>/sub/dir;- a local directory, which is copied (without
node_modulesand.git).
Install fetches the codeload tarball (20 s), validates the manifest, runs
bun install --production when there is a package.json (120 s), and
renames the finished copy into the store's <name>/ in one step, so
nothing ever sees it half-done. A name already installed is refused:
update it instead. .pal-install.json in the directory records
the source, the ref, the time and the commit it was fetched at.
pal update NAME fetches the recorded source again and replaces the
directory; pal update with no name does every extension that has a
source. The Extensions page checks GitHub once when it opens (the commit
at the recorded ref, unauthenticated, 10 s each) and marks an extension
whose branch moved with an update tag.
pal remove NAME deletes the directory. Its keys in the config file stay
([extensions.<name>], [palettes.<id>]), so a reinstall finds its
settings; delete them by hand if you want them gone.
After each of these the extension host is restarted, so the new set is loaded and a removed extension is gone from the root.
Writing one
An extension is a directory; nothing else is needed. The walkthrough
below is what examples/hello-extension/ is, step by step.
-
A directory and its manifest:
mkdir hello && cd hello cat > pal.json <<'EOF' { "name": "hello", "title": "Hello", "version": "0.1.0", "icon": "π", "settings": [ { "kind": "text", "id": "greeting", "label": "Greeting", "default": "Hello" } ] } EOF -
The API, for the editor.
@zcag/palis not on npm yet, so link it from a checkout of pal (once:bun run buildinsdk/produces the.d.tsthe editor reads,bun linkregisters the package under its name):(cd path/to/pal/sdk && bun run build && bun link) echo '{ "name": "hello", "private": true, "type": "module" }' > package.json bun link @zcag/palThat puts the symlink under
node_modulesand writes nothing topackage.json. Once the package is published this isbun add -d @zcag/pal: a dev dependency, since the host provides the package at runtime andpal installskips dev dependencies. -
The code:
// index.ts import { defineExtension, settings, type Item } from "@zcag/pal"; type Settings = { greeting: string }; export default defineExtension({ palettes: { hello: { title: "Hello", icon: "π", list: (): Item[] => [ { id: "greet", name: `${settings.get<Settings>().greeting}, world`, icon: "π", actions: [{ id: "copy", title: "Copy greeting" }] }, { id: "time", name: "What time is it", icon: "π°", actions: [{ id: "tell", title: "Tell me" }] }, ], pick: (id) => (id === "greet" ? { copy: `${settings.get<Settings>().greeting}, world` } : { toast: { title: new Date().toLocaleTimeString() } }), }, }, }); -
Install it:
pal install .copies the directory into the store (withoutnode_modules), and the host loads it. Typehelloin the panel. -
Edit and reload.
pal update hellocopies the directory again (the install recorded where it came from;pal install .a second time is refused, the name is taken), and the host reloads the extension: the store is watched, a changed file reloads its extension in place. For a real edit loop skip the copy: add the directory you are editing togeneral.extension_dirs(Config) and every save reloads it, the rows in the panel following on the next open.cmd+rin the panel lists it again by hand.
The example's full form (three rows, a setting with a description, the
manifest's palettes block) is examples/hello-extension/. Install it
from a checkout with pal install path/to/pal/examples/hello-extension,
or from GitHub with pal install github:zcag/pal/examples/hello-extension@main;
change the greeting under Settings, Extensions, Hello and the row follows.
Trust
An extension is code that runs with your user's rights inside the host.
There is no signing and no registry yet: install what you would run from
a terminal. bun install also runs the dependencies' install scripts.
Rendered from docs/extensions.md in the pal repo, 2026-09-16.