Browse the docs

App Kits

Add a suite of features around a Mac app you don't own (its unread count as a value, buttons pinned to its window, a drawer, menu shortcuts, settings presets) that looks like the app made it.

An App Kit is a set of features installed around a Mac app: Mail's unread count as a number your macros can use, a "Make a to-do" button beside a message's header, a drawer of notes docked to a window, a shortcut for a menu item the app gave none, a one-click "Presentation" set of settings. It is the Mac app's version of a Site Kit.

Switchboard cannot put code inside another app, and does not try to. It sees the app through Accessibility, draws its own native controls next to the app's, and reads and writes the places macOS leaves open to it. Those controls are real macOS controls in the app's own look (its appearance and accent, the height and baseline of its neighbours), ordered with its window and moving with it, so nobody can tell they were added.

Start with one number #

You don't need a kit for the first thing. Every app has a free reading of the red number on its Dock icon, {{ app.badge.Mail }}, in any step of any macro:

Say how many unread messages Mail has
Do
  1. Show a notification “Mail”
Open in Switchboard to try it

It works for nearly every app that counts something (Messages, Slack, Calendar), hidden or not. Try it with an app you use. Everything below is for when you want more than one number.

A kit in ten lines #

An App Kit is a group scoped to an app, with a kit folder. Open the group and choose the Kit tab to see its files and start a feature.

{
  "id": "kit.mail-plus",
  "version": "1.0.0",
  "app": { "bundleIds": ["com.apple.mail"] },
  "screens": { "message": { "window": { "main": true }, "anchor": "messageHeader" } },
  "anchors": { "messageHeader": { "locators": [{ "role": "AXGroup", "identifier": "message-header" }], "screens": ["message"], "required": true } }
}
// features/main.js
kit.screen("message", {
  async mount(ctx) {
    const header = await ctx.anchor("messageHeader");
    await ctx.pin({ kind: "button", title: "Make a to-do", symbol: "checklist", run: "Mail to To-do" }, header, "trailing");
  },
});

When a message window is up, the button is there beside the header; when it goes, so does the button. kit.json's app names the app by bundle id and can give minVersion and maxVersion. kit.json covers the keys a Site Kit and an App Kit share (id, version, settings); the App Kit keys are below.

A group with an app and a kit folder shows the app's icon. Its macros, hotkeys and typed strings follow the group's app, so they work only while the app is in front.

Readings #

A reading is a named value the kit reads from the app and keeps current. It can be a token ({{ kit.mail.unread }} in any step), a condition (A reading from an app is), a trigger (A reading from an app changes: "Mail's unread went up") and a menu bar item. Macros that read one ad hoc, with no kit, use Read a value from an app. MCP agents have get_reading.

"readings": {
  "unread":  { "from": "dockBadge", "as": "number", "default": 0, "menuBar": true },
  "subject": { "from": "anchor", "anchor": "messageSubject", "what": "value" },
  "errors":  { "from": "anchor", "anchor": "issueCount", "regex": "(\\d+) errors?", "as": "number" }
}

The sources, cheapest first. Use the first one the app offers:

fromReadsUpdates byCost
dockBadgeThe red number on the app's Dock iconThe Dock's own notificationFree
menuBarExtraThe title of the app's menu bar itemThe sameFree
windowTitleThe window's title, with regex (Slack's "(3) Slack")Title changesFree
anchorAn element's value, title or descriptionChanges to that element while the window existsCheap; stale when the window is gone
appleScript, jxaA script's resultevery (at least a minute) and when the app comes to the frontAsks for Automation access to the app
intentAn App Intent that returns a valueeveryCheap
fileA file's text or a read-only SQLite queryWatching the fileFull Disk Access for a file under ~/Library
notificationA field of a notification the app announcesThe notificationFree
ocrText in a region of the windoweveryScreen Recording; the last resort
  • as turns text into a value: number (3 unread, 1.2k and 1,234 all read as numbers), date, boolean, list or text. regex takes the first group first. Until the first read, and when the source fails, the value is default.

  • A reading that can't be read just now keeps its last value and is stale; a stale value is not a change.

  • A reading is read only while something uses it: a macro, a trigger, the menu bar. An unused one costs nothing.

  • The last 500 values are kept. The Kit tab's Readings section shows each reading with its source, its value, when it last changed and a small chart of its history.

  • "menuBar": true shows it in the menu bar, with the app's icon and the value in monospaced digits, dimmed while it is stale.

A reading's change is never taken at once: the source waits a second, reads again, and only a value that is still there counts.

Screens and anchors #

A screen is a state of the app, declared by what is true: a window's title (text, or /regex/), the main window, a document, an anchor being present, the focused element's role, a selected tab or toolbar item.

"screens": {
  "compose":  { "window": { "title": "/^(New Message|Re:|Fwd:)/" } },
  "message":  { "window": { "main": true }, "anchor": "messageHeader" }
}

Screens are checked when a window opens, closes, takes focus or changes its title, when the app comes to the front, and when an anchor changes. Nothing polls. Each window has its own mounts, and a feature's mount is undone, everything it pinned included, when the screen stops matching. The Kit tab's Screens section says each rule in words and which windows it is on now.

An anchor is a name for an element of the app's window, with several locators in order of preference. await ctx.anchor("messageHeader") tries each in turn and throws AnchorMissing if none is there. A locator is a role plus whatever identifies it (identifier, title, description, a path); give the app's own identifier first and a title as the fallback.

When the app updates. The first time Switchboard sees a new version of the app, it checks every anchor again, and the kit shows Paused: Mail 17 isn't checked yet until they all pass, which extends maxVersion. Before that, Save Screen for Checks keeps a redacted copy of one screen's window (text is blanked, like a trace) so every anchor can be tested offline; Check tests them. A miss gives one Activity row ("Mail 18.1: 2 of 7 anchors stopped matching") and Repair with AI proposes new locators from the saved or live window. You read them before anything is written.

Pins and the drawer #

ctx.pin(view, anchor, edge) puts a native control beside an element: leading, trailing, top, bottom or over. The view is a tree of button, text, image, toggle, field, list, table, progress and a few layout nodes; the kit never draws pixels.

  • It is a control of the kind the anchor sits among: a toolbar anchor gets a toolbar button, a sidebar row an inline one. as overrides.

  • Its height, baseline, control size, appearance (light, dark, the app's own) and accent come from the app. Check Fit and switchboard kit check list every difference from the neighbour.

  • It appears and leaves with a fade, counts roll with .numericText, symbols swap with a replace effect, and with Reduce Motion on they cross-fade.

  • A press never activates Switchboard. The control is a panel that never takes the keyboard, so the app stays in front and focused. A pinned button that runs a macro runs it in the background where the macro allows.

  • One pin per anchor per window, and every pinned action is also a kit command.

ctx.drawer.set(tree) docks a panel at the window's edge, drawn like the app's own sidebar: width 220 to 480 points, remembered per kit, with a handle to open and close it. It takes the keyboard only when you click in it, and ⎋ gives it back to the app.

An App Kit can change the app in two ways that need no code.

"menuShortcuts": { "Message > Archive": "⌘E", "Format > Style > Bold": "⌘B" },
"presets": {
  "Presentation": {
    "defaults": { "ShowSidebar": false, "FontSize": 18 },
    "controls": [ { "type": "setControl", "locator": { "role": "AXCheckBox", "title": "Show toolbar" }, "to": false } ]
  }
}

Turning the kit on writes each shortcut into the app's own list (NSUserKeyEquivalents), next to yours. The app shows them in its real menus and they work with Switchboard quit.

  • They appear the next time the app starts. The Kit tab's Shortcuts section says so and offers Relaunch Mail, which asks the app to quit and opens it again.

  • Yours win. A menu item you have already given a shortcut keeps it.

  • Clashes are not written. A key the app already uses for another item, or another kit sets, is skipped and named. Lint says the same.

  • Turning off removes exactly what was added. A shortcut you changed since is left as you set it. The app's earlier list is kept in the kit's state first.

  • The path is checked. The menu path is looked up in the running app; one that isn't there shows in red ("Menu item not found") after Check. Shortcuts are keyed by the item's title, so two items called the same in different menus can't both be set.

Keys are written as symbols (⌘E, ⇧⌘E, ⌃⌥⌘K) or words (cmd+shift+e).

Settings presets #

Choose a preset in the Kit tab, from a kit command, or with the Apply a kit's settings preset step.

  • Preview lists each setting with its value now and the one the preset sets, and any control steps. It says the app will quit while the settings are written.

  • Apply quits the app (it asks; it never forces), writes the defaults, opens the app again and runs the controls, which are Set a control steps for the settings preferences don't hold, in the app's Settings window.

  • Undo Preset puts the old values back and sets the controls to what they were. Applying a preset twice does not lose the first snapshot.

Electron apps #

Slack, VS Code, Notion and Discord are web pages in a window. A kit for an Electron app says "electron": true. Switchboard then starts the app itself with a private DevTools connection (a pipe, never a network port, so nothing else on your Mac can join it), asking first to quit and reopen it if it is running, and Launch at login through Switchboard starts it that way every time. The whole of Site Kits then works inside the app: styles, features, anchors that are CSS selectors, response hooks, and browser: "app:com.tinyspeck.slackmacgap" in any browser step. Pins, the drawer and readings keep working beside it.

The Kit tab's Electron section says Slack opened for this kit while the connection is up, with Open Slack for this kit. Some apps refuse the flag; the section then says Can't be opened up and why, and the kit keeps what it can do from outside the app. The trust sheet reads "Runs code inside Slack, which can read everything Slack shows": only install such a kit if you trust it. The design is PLANS/app-kits.md §7 in the Switchboard source.

What works inside an opened app, what doesn't, and the trust wording are in Kits inside Electron apps.

Permissions #

An App Kit asks the Mac for the least its parts need, and the import sheet lists them before you install (see Sharing):

NeedsFor
AccessibilityScreens, anchors, pins, the drawer, and anchor, menuBarExtra, dockBadge and windowTitle readings; preset controls
AutomationappleScript and jxa readings
Full Disk Accessfile readings under ~/Library
Screen Recordingocr readings

Fixing permissions covers granting them.

Turning a kit off #

Turn Off Kit for Mail is in the menu bar menu while Mail is in front. Everything the kit drew in the app goes at once, and its features stop. The Kit tab shows Turned off for Mail with Turn On, and so does Settings ▸ Browsers under Turned off on.

Sharing a kit #

Right-click the group and choose Share Kit…: the same sheets as a Site Kit. The sheet lists every file, the group's macros, what was left out (your settings values, everything the kit stored, saved pages (the screens you saved for checks) unless you tick Include saved pages) and what the other person will be asked to trust: Runs on Mail, Needs Accessibility and Automation, Runs code inside Slack, which can read everything Slack shows, and the rest of the Site Kit rows. A kit always installs turned off, and an update shows the permissions the new version adds. See Site Kits for the details.

What it can't do #

  • Code inside an app that isn't Electron. No injection, no plug-ins loaded into another app's process: macOS allows it only with System Integrity Protection off, and a sold app can't ask for that. Panels, readings, shortcuts and presets are what macOS lets Switchboard do.

  • Change an app's own windows or toolbars. Pins sit beside them, never inside.

  • Write to an app's databases. file readings open the file read-only. Changes go through the app's own doors: AppleScript, intents, the interface.

  • Open a remote debugging port. Electron apps are opened over a pipe only.

  • iPhone apps. The iPhone runs no macros.

When something doesn't work #

  • Nothing appears. Open the Kit tab. A paused kit says why in its header; a red anchor means the app changed, so use Repair with AI; a missing permission shows in Settings ▸ Permissions.

  • A reading is stale. The window it reads is closed, or the app isn't running. It keeps its last value.

  • A shortcut isn't in the menu. Check the Shortcuts section: if it says the app restarts, use Relaunch; if the path is red, fix the title.

  • The button looks a pixel off. Run Check Fit and read the differences.