Build plugins
Plugin reference
The full Stackbar plugin authoring reference: views, commands, triggers, capabilities, host services and previews. The same text agents read as llms.txt.
A Stackbar plugin is one Swift file that imports StackbarKit. Stackbar builds it, runs it in its own process, and draws what it declares natively in the macOS menu bar, a panel, a window, a widget and its own page in Settings. Agents can call its tools.
Shape
import StackbarKit
import Foundation
@main
final class Weather: StackbarPlugin {
static let manifest = Manifest(
id: "dev.example.weather", // reverse-DNS, stable forever
name: "Weather",
summary: "Today's temperature.",
surfaces: [.menuBar, .panel, .settings], // also .window, .widget
triggers: [.launch, .minutes(15), .wake],
capabilities: [.network("api.open-meteo.com")]
)
var temperature: Double?
@Stored("unit") var unit = "C" // saved by the host across restarts
func refresh() async throws { // runs when a trigger fires
struct Reply: Decodable { struct Current: Decodable { let temperature_2m: Double }; let current: Current }
let reply: Reply = try await Stackbar.getJSON("https://api.open-meteo.com/v1/forecast?latitude=52.5&longitude=13.4¤t=temperature_2m")
temperature = reply.current.temperature_2m
}
var menuBar: ViewContent {
Label(temperature.map { "\(Int($0))°" } ?? "–", symbol: "thermometer.medium")
}
var panel: ViewContent {
Text("Berlin").style(.headline)
Text(temperature.map { "\(Int($0))°\(unit)" } ?? "–").style(.title)
}
var settings: ViewContent { // Stackbar Settings → Weather
Section("Units") {
Picker("Unit", options: ["C", "F"], selection: unit) { self.unit = $0 }
}
}
var tools: [AgentTool] {
AgentTool("temperature", description: "Current temperature in Berlin") { _ in "\(self.temperature ?? .nan)" }
}
}
People can turn any tool off in Settings → Agents. A tool that returns
private data (clipboard contents, messages) should be .optIn(): it stays
off until the user turns it on, and so does agents reading the plugin’s
panel (0.21.0).
State is ordinary properties. After refresh(), an action, a tool call or a
topic message, Stackbar re-renders and sends only surfaces that changed. Code
never runs concurrently with itself, so no locks. Throwing from refresh()
logs the error and keeps the last render.
Keep the panel for doing things: it opens many times a day. Preferences
people set once go in settings, which Stackbar shows on the plugin’s page in
its Settings window (the gear in the panel footer, or ⌘, while the panel is
open). Quick controls used mid-task, like Pause, stay in the panel.
A left click on the menu-bar item opens the panel. To make it do something
instead (start or stop a timer), return an action from menuBarClick; a
right click still opens the panel, and the title is the item’s tooltip:
var menuBarClick: MenuBarAction? {
running ? MenuBarAction("Click to stop") { self.stop() } : MenuBarAction("Click to start") { self.start() }
}
A menuBar that draws nothing at all takes the item out of the menu bar
until it draws something again (0.21.0). A background service (window
layouts, a key layer) stays out of the bar and shows an icon only when the
user should act; meanwhile its panel opens from the palette.
The widget shows the widget surface (the panel when you don’t write one),
cut to fit and refreshed by the host. Buttons, toggles, tappable content and
rows act there too: a press reaches the plugin through Stackbar like a panel
click, while Stackbar runs (0.21.0). Fields, pickers and sliders are drawn
disabled, so give a widget one or two big actions, not a form.
Commands
Commands are what people find by name in the palette (⌃⌥Space), give an
alias or a hotkey in Settings → Commands, start from a
stackbar://commands/<plugin-id>/<command-id>?arg=value link, and (unless
.stackbarOnly()) from Spotlight and Shortcuts: commands show in ⌘Space
results, and “Run Stackbar Command” in Shortcuts takes any of them, with its
first argument as the input. A plugin can have commands
and no menu-bar item at all: leave .menuBar out of surfaces.
var commands: [Command] {
// A run command does its work and says what happened.
Command("toggle", title: "Toggle Stay Awake", symbol: "cup.and.saucer") { invocation in
try await self.toggle()
return .message(self.awake ? "Staying awake" : "Sleep allowed")
}
// Up to three arguments, typed inline in the palette.
Command("convert", title: "Convert Temperature") { invocation in
.copy(self.convert(invocation["degrees"] ?? "0", to: invocation["unit"]))
}
.argument("degrees", placeholder: "20")
.argument("unit", required: false, default: "F", options: ["C", "F"])
// A view command opens a live view in the palette. `search` runs when the
// query or an argument changes (fetch here); `body` only reads state.
Command("search", title: "Search Pull Requests", keywords: ["github"])
.view(search: { invocation in
self.results = try await self.find(invocation.text)
}) { _ in
List(self.results) { pr in
Row(pr.title, key: pr.id)
.action("Open") { Stackbar.open(pr.url) }
.navigate("Details", to: "details", arguments: ["id": pr.id])
}
}
Command("details", title: "Pull Request").view { invocation in
Text(invocation["id"] ?? "")
}
.stackbarOnly()
}
Two modifiers bring a command into the palette’s own results. .rootSearch()
on a view command runs its search with whatever the user types at the root
and shows the view’s top three rows as a section under the command’s title,
or .rootSearch(section: "Applications"). Stackbar ranks its rows with
everything else on one scale and learns which row people pick for what they
type, so keep each row’s key stable; the best row of all leads, and
sections follow by their best row. Applications, Calculator,
File Search and Clipboard History do this; keep that search quick: one that
takes longer than 0.4 s three times running sits out for ten minutes. Give it
matching: when it only answers text of one shape, and other text never
reaches the plugin; a section that matched leads the results (0.21.0):
Command("issue", title: "Jira Issue").view(search: { invocation in try await self.load(key: invocation.text) }) { _ in … }
.rootSearch(section: "Jira", matching: #"[A-Za-z][A-Za-z0-9]*-\d+"#) // the whole text: ABC-123
People can turn a command’s root search or fallback off in Settings →
Commands. .fallback()
offers the command under the results for any text (“Search the web for …”):
the text fills its first argument, or a view’s search. A view whose list is a
Grid of rows shows them as tiles the arrow keys move through (an emoji
picker), and a selected row’s preview shows beside the list.
Results: .done, .message(text), .open(url:app:), .copy(text),
.paste(text, cursorFromEnd:) (needs .clipboard; puts the user’s clipboard
back afterwards and keeps the paste out of clipboard history), .failure(text).
The host performs them under the plugin’s capabilities. Ids are lowercase letters,
digits and -, stable forever (aliases, hotkeys and links remember them);
titles can change. invocation["name"] is nil when an argument was left
empty; invocation.source says where it started (.palette, .hotkey,
.link, .spotlight, .shortcuts, …) and invocation.context carries the
front app, window title and selected text for plugins with .context, read
when the command was triggered, before Stackbar’s palette took focus.
A form in a view (or a panel) submits every field at once:
Form(submit: "Add") {
FormField.text("Name", name: "name", error: nameError)
FormField.multiline("Body", name: "body")
FormField.picker("Kind", name: "kind", options: ["a", "b"])
FormField.toggle("Pinned", name: "pinned")
FormField.application("Open with", name: "app")
} onSubmit: { values in
let name = values.string("name") ?? ""
self.nameError = name.isEmpty ? "Give it a name" : nil
}
Templates (quicklinks and snippets)
Template fills Raycast-style placeholders, so links and text can ask for
input and pick up what the user copied or selected:
let template = try Template("https://github.com/search?q={argument name=\"query\"}&type={argument name=\"type\" default=\"code\"}")
template.arguments // query (required), type (default code): use them as the command's arguments
let url = try template.render(invocation.arguments, in: Template.Environment(clipboard: copied, selection: invocation.context?.selectedText), encoding: .url).text
Placeholders: {argument name= default= options="a, b"} (3 at most),
{clipboard}, {selection}, {date}, {time}, {datetime}, {day} (each with
format="yyyy-MM-dd", offset="+2d -3h", locale="fr-FR"), {uuid},
{cursor} (render(...).cursorFromEnd for .paste(text, cursorFromEnd:)),
{snippet name=} (from environment.snippets, one level). Modifiers chain
after |: uppercase, lowercase, trim, percent-encode,
json-stringify, raw. With encoding: .url values are percent-encoded
(not the template’s own text) unless raw. A brace group that isn’t a
placeholder stays as written; template.unrecognized lists likely typos
for a form to flag. Errors name the placeholder that failed.
Views (ViewContent bodies, SwiftUI-like; if, for, switch work)
Text(s).style(.title|.headline|.body|.caption|.monospaced).role(r)Label(title, symbol: "sf.symbol").role(r)VStack(spacing:) { },HStack { },Section("Title") { },Divider()List(items, searchable: true) { item in Row(...) },ForEach(items) { }Row(title, subtitle:, symbol:, key: stableID).badge(text, role:).progress(v).action("Open", shortcut: "cmd+return") { }The first action is the click; all actions are in the context menu. In a searchable list the host adds the keyboard: typing filters, ↑↓ select, Return runs the first action, ⌘1–⌘9 the nth row’s, and action shortcuts apply to the selected row. Don’t declare ⌘1–⌘9 yourself. Matching is fuzzy (“gthb” finds “GitHub”) and plain rows are ranked best first..preview(text:imageID:caption:)shows the full text or a kept clipboard image under the list while the row is selected; search looks through it.Row(...).appIcon("com.apple.Safari")draws that app’s icon instead of the symbol (the symbol stays as the fallback).Row(...).fileIcon(path)draws a file or folder’s Finder icon (a path fromStackbar.findFiles).Row(...).hiddenFromPalette()keeps the row out of the command palette’s own results; it still shows and acts in your panel. Use it on rows of your data (history, items) that would flood the palette, and offer them with a root-search command instead (minimumStackbar 0.31.0).- Anything that loses work or data takes
confirm::.action("Stop", role: .critical, confirm: "stop \(name) (pid \(pid))") { }, and the same onButton,ActionButtonandIconButton. The text finishes “Press again within 10 s to …”. Stackbar holds the first press, says so in the HUD and to VoiceOver, redraws the control as armed and forgets it after 10 s, if the text changes, or on Esc; only a second press runs your closure, so don’t keep your own armed state. A widget opens the panel for it, andsoak --pressnever presses it. Writeconfirm: .noneon a.criticalaction that needs none (the store checks). On hosts before 0.35.0 the kit asks itself, without the redraw. Badge(text, role:),ProgressBar(label, value:, total:, style: .ring(lineWidth: 6)),LabeledContent(label, value:)Chart(label, values: [Double], kind: .line|.bar|.area|.donut|.heatmap, role:, labels: ["Mon", …], unit: "h", goal: 4, highlight: 6)labelsname the points (or a donut’s slices),unitfollows values,goaldraws a target line,highlightemphasizes one point..heatmaptakes one value per day, oldest first and today last, and draws week columns like a contribution graph (minimumStackbar 0.11.0). Large values are shortened on axes (1.9B), and a donut lists each slice’s share. ASectionwithout rows is drawn as a card, so group charts and values in one; anHStackof such sections makes a row of stat tiles.Button(title, symbol:, role:) { },Toggle(title, isOn:) { newValue in }TextField(placeholder, text:) { submitted in },Picker(title, options:, selection:, style: .segmented) { choice in }Slider(title, value:, in: 0...10, step:) { value in }sends on release;live: truealso sends while dragging (keep the handler cheap),Link(title, url:)ContentUnavailableView(title, symbol:, message:)
Roles: .primary .secondary .accent .success .warning .critical. Without
styling the host picks colors and sizes per surface and appearance, which is
right for most plugins. In the menu bar .accent and .success draw in the
bar’s own color (blue vanishes on it), so show an active state with the
symbol, such as a filled variant.
Custom look (minimumStackbar 0.12.0)
When a plugin wants its own look, style it like SwiftUI. Modifiers apply in the order written:
.font(size:, weight: .heavy, design: .rounded, monospacedDigit: true),.foreground(paint).padding(8),.padding(horizontal:, vertical:),.frame(width:, height:, fillWidth: true, alignment: .center).background(paint, in: .capsule | .circle | .rectangle | .roundedRectangle(cornerRadius: 12)),.border(paint, width:, in:).clipShape(shape),.cornerRadius(12),.shadow(radius:, x:, y:)(cast by the background’s shape when there is one),.opacity(0.5),.animation(.spring).accessibilityLabel("Start"): give one to every button without text- Paints:
.hex("#F28C38", dark: "#FF9F4F")(always give a dark variant for backgrounds),.role(.accent),.gradient(["#F28C38", "#FFB36B"], angle: 90) ZStack { }layers views;VStack(alignment: .center),HStack(alignment: .start);Spacer()Circle(),Capsule(),Rectangle(),RoundedRectangle(cornerRadius:)with.fill(paint)and.stroke(paint, lineWidth:)Image(systemName: "play.fill"), sized by.font(size:)and colored by.foregroundButton(key: "start") { … } label: { Image(systemName: "play.fill").frame(width: 72, height: 72).background(orange, in: .circle) }makes anything a buttonTimerText(TimeRange(start:, end:))counts down to0:00on its own (countsDown: falsecounts up), andProgressBar(timing: range, style: .ring(lineWidth: 6))fills with the clock. Render once when the timer starts, not every second; an.every(.seconds(1))trigger letsrefresh()notice the end (re-renders that come out the same are not sent).Clock("EEE d MMM HH:mm", timeZone:)is the time of day the host keeps current, every second when the pattern shows seconds, every minute otherwise; no trigger needed. In the menu bar it keeps its widest width.Stackbar.play(.glass)plays a system sound (at most 10 a minute).
The shared look (minimumStackbar 0.13.0)
Store plugins share one design system, so build panels from it rather than
from raw modifiers. Pick one Theme for the plugin (.blue .indigo .violet .pink .red .orange .amber .green .teal .cyan .graphite); .success,
.warning, .critical, .neutral and .info are for states only. Every
theme passes 4.5:1 in both modes.
PanelHeader("3 running", subtitle: "Updated 10:42", symbol: "shippingbox.fill", theme: theme) { IconButton(…) }first in the panel. The footer names the plugin, so the title is the state.HeroCard(theme:) { }the one thing an instrument panel is about (a track, a timer, the next meeting).Card("Title", detail: "8") { }groups charts and values; nativeRows stay in aListorSection.StatTile("Today", value: "1.2M", caption: "$4.12", symbol: "bolt.fill", theme:)in anHStackof two or three.Metric("4:32", unit: "left", caption: "Until 17:30"),Gauge(value:, total:, theme:) { Text("40%") }(orGauge(timing:)),Meter("Memory", reading: "12 of 16 GB", value: 0.75, theme:),DayBar([0.2...0.3, 0.5...0.6], now: 0.4, theme:)(a day with its meetings or working hours filled; fractions of the span)TagChips(tags, selection: tag, theme:) { tag in }filters a library by tag: All, four chips, the rest under MorePill("Failing", symbol: "xmark", theme: .critical),StatusDot(.success),Banner("Showing numbers from 5 min ago")IconButton("arrow.clockwise", label: "Refresh", theme:, prominence: .plain | .tinted | .filled) { },ActionButton("Upgrade All", symbol:, theme:, prominent: true) { },Chip("Running", theme:, selected:) { }EmptyState("Nothing running", symbol:, message:, theme:) { ActionButton(…) }in place ofContentUnavailableViewin panels.typography(.hero | .metric | .stat | .title | .headline | .body | .caption | .captionNumber | .overline),Space.xs…xl(4–24),Radius.small | .medium | .large,Paint.surface | .raised | .hairline
Bolder looks (minimumStackbar 0.14.0)
Give each panel one signature hero, and make content the star: real pictures, light that follows the hue, numbers that move.
Image(url: "https://…", placeholder: "music.note")fills its.frame, cropped; the host fetches it from a domain in.network(declare the image host too), up to 4 MB.Backdrop(imageURL:, paint:, theme:, height: 160) { }blurred picture (orSky/vivid fill) under a veil, content in white;GlassBar { IconButton(…, prominence: .onMedia) }frosted controls over it.HeroCard(theme:, look: .glow | .vivid); paintstheme.glow,theme.vivid,theme.sweep(a ring that warms),theme.track,.radial([…], radius:),.angular([…]),.material(.glass | .thin | .regular),.onMedia,.scrim.Avatar("Ada Lovelace", imageURL:, theme:),AvatarStack([(name, url)], limit: 4).RingCluster([.init("CPU", value: 0.4, theme: .green), …]) { center }up to three concentric rings.HeroStat("Running", value: "3", theme:, live: true)big rolling counts, two to four across a glowing hero.Sparkline(values, theme:, height: 28);Chart(…, compact: true)is the bare marks.Grid(columns: 3) { Tile("Docs", subtitle:, symbol:, imageURL: favicon, theme:) { … } }lifts under the pointer.Sky(hour: 14.5)gives.paint,.symboland.colorsfor clocks and calendars; white text reads on all four..symbolEffect(.pulse | .breathe | .bounce | .rotate | .variableColor | .wiggle),.symbolRendering(.hierarchical | .multicolor | .palette, palette: […]),.numericTransition(),.hover(scale: 1.03, highlight: .hoverWash),.blur(20). Effects stop under Reduce Motion; widgets draw no hover and show image placeholders.
Leave text in the system’s .primary and .secondary roles and color it with
the theme only for emphasis. Keep the menu bar plain: a symbol, at most one
short value, .secondary when idle.
stackbarctl render flags text under 4.5:1 contrast in light or dark mode
(see-through backgrounds are measured over what is behind them) and buttons
without text. render --png <dir> draws it on a Mac, and
--dark draws dark mode. The menu bar ignores styling so the bar stays
legible, but shows symbols, timers and timed rings.
Action ids come from content: button/<title>, toggle/<title>,
row/<key>/<action title>, field/<placeholder>, picker/<title>,
slider/<title>. Pass key: when titles repeat or change.
Host services (Stackbar.*), checked against capabilities
| Call | Needs |
|---|---|
try await Stackbar.getJSON(url, as: T.self, headers:, auth:), Stackbar.postJSON(url, body: encodable, as: T.self, auth:) (POST unless method:), Stackbar.get(url, auth:), Stackbar.http(HTTPRequest, auth:) |
.network("host"), *.example.com for subdomains |
try await Stackbar.secret("NAME") returns String? (nil: the user hasn’t entered it; Stackbar asks them. They change or clear it on the plugin’s Settings page) |
.secret(name: "NAME"); names are letters, digits, _, -, . |
A server the user picks (a self-hosted proxy, GitHub Enterprise): read its URL with Stackbar.secret("NAME"), then Stackbar.getJSON to it |
.userServer(secret: "NAME"): the plugin may reach that URL’s host only (minimumStackbar 0.11.0) |
try await Stackbar.oauthToken("github") |
.oauth(OAuthProvider(name:, authorizeURL:, tokenURL:, clientID:, scopes:)) |
Stackbar.publish("topic", payload); receive with .topic(name:) trigger and func receive(topic:payload:from:) |
nothing |
Stackbar.notify(title, body:, actions: [NotificationAction("Join", url:)]), Stackbar.alert(title, body:, actions:) (full screen, one a minute), Stackbar.open(url), Stackbar.copy(text) |
nothing |
NotificationAction("Snooze", command: "snooze", arguments: ["id": id]) is a notification button that runs one of the plugin’s own commands (a view command, or one missing an argument, opens in the palette); at most 3 arguments; 0.26.0 |
nothing |
Stackbar.hud("Copied") (or isError: true) shows the HUD a command result’s message shows, after a panel button or a row acts; at most 30 a minute; 0.22.0 |
nothing |
try await Stackbar.open(url, with: "com.apple.Safari"); links other than https, http, mailto, tel, meeting apps and Stackbar’s own stackbar://store/<plugin-id> and stackbar://settings pages (0.25.0), and files or folders, with either open (Stackbar.open(url) from 0.33.0; before, it opened only the links that need nothing) |
.openURLs(schemes: ["slack"]); ["*"] for any scheme and paths |
try await Stackbar.run("brew", ["outdated", "--json=v2"], timeout: .seconds(120)) returns RunResult (status, stdout, stderr, text, truncated, timedOut); a non-zero exit is not thrown |
.run(commands: ["brew"]) |
try await Stackbar.openInTerminal(["docker", "exec", "-it", id, "sh"], title: "web") opens the user’s terminal app running that program; the host quotes every word, so nothing is read as shell; at most 5 a minute; 0.26.0 |
.terminal |
for try await event in Stackbar.stream("docker", ["logs", "-f", id]) { switch event { case .stdout(let data), .stderr(let data): …; case .exit(let exit): … } }; leaving the loop stops it |
.run(commands: ["docker"]) |
invocation.context in commands, or try await Stackbar.context() now: frontmostApp, windowTitle, selectedText (nil when the app doesn’t share it); try await Stackbar.finderSelection() returns paths |
.context |
@Stored("key") var x = default (any Codable, 1 MB per plugin) |
nothing; .storage(megabytes: 16) for up to 64 MB |
try await Stackbar.chooseFile(types: ["json"]) returns ChosenFile? (name, data; 5 MB at most), try await Stackbar.saveFile("x.json", data:) returns the path; nil when the user cancels |
nothing (the user picks) |
try await Stackbar.chooseFolder(prompt: "Where your code is", startingAt: "~/Developer") returns ChosenFolder? (path, name; nil on cancel) and lets your commands run in that folder and inside it until the user removes it on your Settings page; try await Stackbar.grantedFolders() lists those still allowed. ~, /, ~/Library and system folders are refused; 0.34.0 |
nothing (the user picks) |
try await Stackbar.resolveApplication("Google Chrome") returns a bundle id from an app’s name, path or id |
nothing |
try await Stackbar.clipboard() returns ClipboardItem? (kind, text, files, imageID, contentID for formatted copies, sourceAppID, concealed, changeCount); hear every copy with the .clipboard trigger |
.clipboard |
try await Stackbar.recognizeText(imageID: item.imageID!) returns RecognizedText (text, lines with confidence) read from a kept clipboard image with Vision, on the Mac; languages: ["fr-FR"] narrows it; 0.26.0 |
.clipboard |
try await Stackbar.captureScreenText() lets the user drag over part of the screen, like ⇧⌘4, and returns its RecognizedText, nil on Esc; macOS asks for Screen Recording the first time; 0.26.0 |
.screenText |
try await Stackbar.setClipboard(.text/.files/.image(id:)/.stored(id:)), try await Stackbar.paste(content, restoringClipboard: true, cursorFromEnd: n) (into the app the user was in; needs Accessibility for Stackbar; restoring puts the user’s copy back after half a second, marks the paste transient, and n ← presses place the caret) |
.clipboard |
try await Stackbar.keepAwake(display:, reason:), Stackbar.allowSleep(); the host releases it if the plugin stops |
.power |
try await Stackbar.systemStatus(): batteryLevel, onACPower, lowPowerMode, runningApps, externalDisplays, networkBytesReceived, vpnActive |
.power |
try await Stackbar.sensors() returns [SensorReading] (id cpu, gpu, ssd, battery, fan-0; name, kind .temperature in °C or .fan in rpm, value, maximum); a Mac without fans has none; 0.26.0 |
.sensors |
try await Stackbar.calendarEvents(from:, to:) returns [CalendarEvent] (title, start, end, location, notes, url, participation), 31 days at most |
.calendar |
try await Stackbar.saveCalendarEvent(CalendarEventDraft(title:, start:, end:, calendarID:, alertMinutes:)) adds an event (empty id) or changes one (a CalendarEvent.id; a repeating event changes on that day only) and returns it; Stackbar.writableCalendars() returns [CalendarInfo] (id, title, color, account, isDefault); at most 30 changes a minute; 0.26.0 |
.calendarWrite (and .calendar to read) |
try await Stackbar.reminders(in: [listID], completedSince: date) returns [Reminder] (id, list, title, notes, due, dueHasTime, priority, completed, recurrence, alarm, url); Stackbar.reminderLists() returns [ReminderList] (id, title, color, account, isDefault): use lists for categories, EventKit has no sections, tags or subtasks; Stackbar.saveReminder(reminder, fields: [.title]) adds (empty id) or writes only the named fields, so a change from another device survives; Stackbar.deleteReminder(id:), Stackbar.createReminderList("Home"); Stackbar.reminderAccess(ask: true) returns granted, denied, notDetermined or unavailable, so a plugin can fall back to @Stored; the .reminders trigger fires when reminders change on any device; 0.18.0 |
.reminders |
try await Stackbar.windows() returns WindowSnapshot (displays, windows front to back with app, title, frame, displayID, isMinimized; setupID names the monitor setup); try await Stackbar.arrangeWindows(WindowPlacement.capture(snapshot), launch: true) puts them back and returns ArrangeResult (moved, opened, missing, failed); 0.15.0, needs Accessibility for Stackbar |
.windows |
try await Stackbar.focusWindow(WindowTarget(window, in: snapshot)) brings that window forward (unminimized, raised, its app active), matched by app and title, then by its place among the app’s windows; 0.25.0 |
.windows |
try await Stackbar.runningApps() returns [RunningApp] (id, name, path, isActive, isHidden); try await Stackbar.activateApp("com.apple.Safari", launch: true), Stackbar.hideApp(id); 0.16.0 |
.apps |
try await Stackbar.installedApps() returns [InstalledApp] (id, name, path, kind .app or .settingsPane, keywords, url): open an app with activateApp(id, launch: true), a pane with Stackbar.open(url); 0.20.0 |
.apps |
try await Stackbar.findFiles("q3 plan", limit: 8) returns [FoundFile] (path, name, folder from ~, isFolder, lastUsed) from Spotlight, every word in the name, most recently used first; opening one needs .openURLs(["*"]); 0.20.0 |
.fileSearch |
try await Stackbar.system(.lockScreen) (and .sleep, .sleepDisplays, .startScreenSaver, .restart, .shutDown, .logOut, .emptyTrash, .toggleDarkMode, .hideOtherApps, .quitOtherApps, .ejectDisks); give it confirm: when action.isDestructive; at most 10 a minute; 0.22.0 |
.systemActions |
try await Stackbar.setKeyLayer(KeyLayer(trigger: .rightCommand, bindings: [KeyBinding(key: "s", apps: ["com.apple.Safari"], launch: true)], secondPress: .hide, assignCommand: "assign")): while Right ⌘ is held, S switches to Safari (the host decides each press itself; set the whole map again when it changes); Right ⌘ ⌥ + a key runs command assign with key and app; returns KeyLayerStatus (listening, needsAccessibility, secureInputApp, conflict), also Stackbar.keyLayerStatus(); 0.16.0 |
.keyLayer |
try await Stackbar.setExpansions([TextExpansion(keyword: ";sig", command: "sig")]): typing ;sig in any app deletes it and runs command sig (return .paste(text)); expands as the last character is typed, at the start of a word; the plugin never sees typing, so register every keyword and set them again when they change ([] removes them); keywords are 1 to 32 characters without spaces, quotes or backticks, the first plugin to register one keeps it; returns ExpansionStatus (listening, keywords, turnedOff, needsAccessibility, secureInputApp, taken), also Stackbar.expansionStatus(); 0.24.0 |
.textExpansion |
try await Stackbar.menuItems() returns MenuSnapshot (appID, appName, items: [MenuItemInfo] with path like ["File", "Export…"], shortcut like ⇧⌘S, isEnabled, isChecked) for the app the user was in, the Apple menu left out, at most 3000 items; try await Stackbar.pressMenuItem(appID: menus.appID, path: item.path) brings that app forward and chooses the item; 0.25.0, needs Accessibility for Stackbar |
.menus |
try await Stackbar.pickColor() shows the system magnifier and returns PickedColor? (red, green, blue, alpha from 0 to 1; .hex like #0A60C8, .rgb, .hsl; PickedColor(hex:)), nil on Escape; try await Stackbar.define("serendipity") returns Definition? (word, text) from the Mac’s own Dictionary, offline; 0.26.0 |
none |
Rate limits
Each plugin gets its own limits, counted over the last minute. Calls on one line share that line’s limit. Past it, the call throws a message like “too many app switches; at most 120 a minute”.
| Calls | At most, per plugin |
|---|---|
getJSON, postJSON, get, http |
300 a minute |
notify |
10 a minute |
alert |
one a minute |
play |
10 a minute |
hud |
30 a minute |
system |
10 a minute |
chooseFile, chooseFolder, saveFile, pickColor |
10 a minute |
activateApp, hideApp, focusWindow, pressMenuItem |
120 a minute |
saveCalendarEvent |
30 a minute |
openInTerminal |
5 a minute |
recognizeText, captureScreenText |
30 a minute |
arrangeWindows |
120 a minute |
| every host call together | 1200 a minute |
A host call that gets no answer fails instead of waiting for good: after two
minutes for most calls, after its timeout plus 30 seconds for run, after
five and a half minutes for http (the longest a request may take, plus 30
seconds), and after ten minutes for calls that can show a macOS permission prompt
(Reminders, Calendar, system actions, arranging windows). Calls that wait on
the user (OAuth sign-in, file panels, the color picker, screen text) have no
limit.
auth: reads a declared secret and adds the header: .bearer(secret: "GITHUB_TOKEN")
sends Authorization: Bearer …, .header("Authorization", secret: "LINEAR_API_KEY")
the bare key, and prefix: "DeepL-Auth-Key" puts a word before it. While the
secret is unset, the call throws StackbarError.missingSecret(name) and the
panel asks the user for it; ContentUnavailableView(error) shows any thrown
error the standard way, a missing secret as “Waiting for NAME”:
var problem: (any Error)?
func refresh() async throws {
do {
let reply: Reply = try await Stackbar.postJSON("https://api.linear.app/graphql",
body: ["query": "{ viewer { assignedIssues { nodes { title } } } }"],
auth: .header("Authorization", secret: "LINEAR_API_KEY"))
issues = reply.data.viewer.assignedIssues.nodes
problem = nil
} catch { problem = error }
}
var panel: ViewContent {
if let problem { ContentUnavailableView(problem) } else { ForEach(issues) { Row($0.title) } }
}
Don’t use URLSession, Process or files directly: plugins run sandboxed and only host calls are allowed through. Errors from host calls say which capability to add.
Commands run by the host, never through a shell: arguments arrive exactly as
given. A bare name is found in /opt/homebrew/bin, /usr/local/bin, /usr/bin,
/bin, /usr/sbin or /sbin (never the user’s PATH), and the user approves the
path it resolves to; if it later resolves elsewhere, the run fails until they
approve again. The environment is PATH, HOME, LANG and TMPDIR (the plugin’s
own temporary folder) plus environment:, and SSH_AUTH_SOCK with .sshAgent
(0.34.0), so git and ssh reach the user’s SSH agent; the working
directory is the plugin’s own folder unless workingDirectory: names one
inside a .files path or a folder the user chose with chooseFolder. At most 4 at once, 8 MB of output a channel, a 60 second timeout by
default and 10 minutes at most; on timeout the whole process group is
stopped. Declaring a shell or interpreter (sh, zsh, python3, node,
osascript, …) is allowed but the install sheet warns it can run any program;
prefer the tool itself.
Selected text comes through Accessibility and is cut to 100 KB. Apps built
on the standard macOS text views share it; others may not, so always handle
nil (offer the clipboard, or an argument, instead). On Linux and in stackbarctl, the context is simulated: pass
--selected-text and --frontmost-app (or selected_text and
frontmost_app to the MCP command tools). Folder panels pick the paths in
STACKBAR_CHOOSE_FOLDER (:-separated, one per panel), and the folders
chosen stay in stackbarctl’s scratch folder.
Triggers: .launch, .every(.seconds(30)), .minutes(5), .wake,
.file(path: "~/x"), .topic(name: "t"), .clipboard, .displays (a
display plugged in, unplugged or rearranged; 0.15.0), .apps (an app opened
or quit; 0.16.0), .reminders (reminders changed here or on another device;
needs .reminders; 0.18.0). Stackbar.refreshReason
says which one called the running refresh().
Manifest(..., hotkey: "cmd+shift+v") opens the panel from anywhere.
Add hotkeyCommand: "search" (minimumStackbar 0.27.0) and the hotkey opens
that command in the palette instead, and closes it on a second press, the way
Raycast binds a hotkey to Clipboard History; the panel stays on the menu bar
icon. The user’s own hotkey for the command, set in Settings, wins.
minimumStackbar: "0.10.0" names the oldest Stackbar whose StackbarKit the
source compiles against; a plugin index hides Install on older apps.
homepage: "https://..." links the plugin’s README from the store.
accent: .teal and symbol: "cloud.sun.fill" (minimumStackbar 0.14.0) give
the plugin its own colour and icon on its store card and its page on the
Stackbar site; Theme(.teal) is the same hue for its own views. Without them
the store uses the category’s colour and the name’s first letter.
replaces: ["dev.example.old-clock"] (minimumStackbar 0.25.0) takes other
plugins over, when two merge into one or one is renamed: the first time it
loads, the user’s aliases, hotkeys and favourites move from the old plugin’s
commands to this plugin’s commands with the same ids, the old plugin is turned
off and offered for uninstall, and stores stop listing it. Keep the old
command ids to keep the shortcuts.
To publish (stackbarctl sign, index build) a plugin also needs an x.y.z
version, a summary and a minimumStackbar.
Previews (minimumStackbar 0.17.0)
Screenshots in the store and on the Stackbar site come from previews: named scenes with sample data, so a page shows the plugin in use rather than its first-run “Connect Jira” screen. Give a published plugin two to five.
static let previews: [PreviewScene<Clipboard>] = [
PreviewScene("hero", "Everything you copied") { plugin in
plugin.items = Clipboard.samples
},
PreviewScene("pinned", "Pin what you paste every day") { plugin in
plugin.items = Clipboard.samples.map { $0.pinned(true) }
},
PreviewScene("insights", "What you copy, and from where", surface: .window) { plugin in
plugin.items = Clipboard.samples
},
]
A scene is about the panel unless surface: says otherwise; the store shows
that surface.
The closure runs on a fresh plugin whose @Stored values start at their
defaults and are never saved, so a scene can’t show or overwrite real data.
The plugin then never refreshes (triggers only re-render), so set every
property the scene shows. Events and copies still run, so --event and
--copy can act in a scene.
Name the first one hero: the site’s header uses it. Names are lowercase
with dashes; titles are short captions. Use made-up but believable data, never
real people’s names or emails. Previews never run in the app.
stackbarctl previews <file> lists them, and
stackbarctl render <file> --preview <name> [--png <dir> [--dark]] renders one.
--frames <out.png> records a short loop instead: a frame after the scene and
after each --event or --copy, --hold <seconds> for how long the last one
shows, --wait <seconds> for a clock to move. The store plays it as a demo.
Loop
stackbar_new(or write the file), thenstackbar_build: fix every diagnostic.stackbar_render(text, or png on a Mac) and read the action ids.stackbar_send_eventwith an id, render again;stackbar_call_toolfor tools. Commands:stackbar_list_commands,stackbar_run_command, andstackbar_render_commandfor a view (thenstackbar_send_eventinside it; a form’s value is an object of its fields).stackbar_logswhen something is off. Strayprintgoes to the log.
From a terminal: stackbarctl build|render|manifest <file.swift>
(manifest <file> --json prints {manifest, problems, publishingProblems} for scripts),
stackbarctl render <file> --event <id>, stackbarctl commands <file>,
stackbarctl run <file> <command> --arg name=value,
stackbarctl render <file> --command <id> --query <text>. Outside the app the clipboard, battery
and calendar are simulated: stackbarctl render <file> --copy "text" (or the
stackbar_simulate_copy tool) pretends the user copied something, and
STACKBAR_CALENDAR=events.json sets the calendar (a sample day otherwise).
STACKBAR_REMINDERS=reminders.json (SimulatedReminders: access, lists,
reminders) sets Reminders (two empty lists otherwise).
On macOS stackbarctl and the MCP tools run the plugin in the same App Sandbox
as the app, so a capability missing from the manifest fails here too
(--no-sandbox turns that off).