Browse the docs

Make your first extension

A ten-minute walk-through from an empty folder to a working extension action in a macro, with hot reload, a setting, a permission, logs, tests and the gallery.

You'll build an extension with one step, Say Hello, and use it in a macro. Then you'll change it while it runs, add a setting and a permission, read its log and see how it reaches the gallery. It takes about ten minutes. For the full list of what an extension can add, see Writing an extension.

You need Node 22, the switchboard command line tool and Switchboard running.

1. Start from the template #

The quickest start is in the app: open Extensions (⌥⌘3), choose Develop, then New Extension… (also File > New Extension…). Name it, pick what it adds, and Switchboard saves a working example of that kind in your Develop folder and opens it in your editor (Visual Studio Code, Cursor, Zed, Sublime Text or Xcode if you have one). Then jump to step 3. The steps below do the same by hand.

The SDK ships a starter for every kind of extension. Make a folder, install the SDK and TypeScript, and copy the hello starter in.

mkdir -p ~/Switchboard-Extensions/hello && cd ~/Switchboard-Extensions/hello
npm init -y
npm i -D typescript @switchboard/sdk
cp -R node_modules/@switchboard/sdk/templates/hello/src .
cp node_modules/@switchboard/sdk/templates/hello/switchboard.json .

The starter's own tsconfig.json points at the SDK's source tree, so write one for your folder.

cat > tsconfig.json <<'EOF'
{
  "compilerOptions": {
    "target": "ES2022", "module": "CommonJS",
    "strict": true, "lib": ["ES2022"], "types": [], "skipLibCheck": true,
    "outDir": "dist", "rootDir": "src"
  },
  "include": ["src"]
}
EOF

Open switchboard.json and change id to a reverse-DNS name that is yours, such as com.yourname.hello. Delete the $schema line, or point it at ./node_modules/@switchboard/sdk/manifest.schema.json for completion in your editor.

sed -i '' 's/com.example.hello/com.yourname.hello/; /"\$schema"/d' switchboard.json

2. Read the two files #

switchboard.json says what the extension adds and what it may touch. This one adds a single action.

{
  "id": "com.yourname.hello",
  "name": "Hello",
  "version": "0.1.0",
  "engines": { "switchboard": "^1.0" },
  "entry": "dist/index.js",
  "adds": {
    "actions": [{
      "id": "hello", "title": "Say Hello", "dryRunSafe": true,
      "params": { "type": "object", "properties": { "name": { "type": "string", "title": "Name", "default": "world" } } },
      "output": { "type": "object", "properties": { "message": { "type": "string" } } }
    }]
  }
}

src/index.ts is the code. What you declare under adds and what you implement must match, or Switchboard refuses to load the extension.

import { defineExtension, action } from '@switchboard/sdk';

export default defineExtension({
  actions: [
    action('hello', async (p: { name?: string }, ctx) => {
      ctx.log.info('hello called');
      return { message: `Hello, ${p.name ?? 'world'}!` };
    }),
  ],
});

3. Run it live #

Build once, then let switchboard ext dev take over.

npx tsc -p .
switchboard ext dev .
[dev] watching /Users/you/Switchboard-Extensions/hello (Ctrl-C to stop)
[dev] built
[dev] reloaded com.yourname.hello (1 methods)

ext dev watches the folder. On every save it runs tsc, loads the new build into the running engine as a development copy and hot-reloads it: the old worker is unloaded, the next call uses the new code, triggers and sensors restart. Development copies need no signature, and Switchboard grants whatever the manifest asks for. The extension's log streams to this terminal.

If a build fails, the error is printed and the previous version keeps running. Leave this terminal open for the rest of the guide.

The same thing is in the app: open Extensions (⌘3), go to the Develop tab, press Choose… and pick the folder, then Watch and Reload. Reload Now rebuilds without waiting for a save.

4. Use it in a macro #

Your action is now a step named <extension id>.<action id>: com.yourname.hello.hello. In a macro, press ⌘⏎ under Do, type Say Hello in the picker and press ⏎. Its fields are made from the params schema in your manifest. Or add this macro with Add to Library; it shows the greeting as a message.

Greet
Do
  1. Com: yourname.hello.hello
  2. Show “{{ hello.message }}” for 2 s
Open in Switchboard to try it

into keeps the action's output in a variable, and {{ hello.message }} reads one field of it. See Variables and expressions.

Run it from the terminal:

switchboard run Greet --wait
f376dc10-8436-4a51-b5df-65655e236827
  ok          s1  2 ms
  ok          s2  90 ms
succeeded

Open the run in Activity (⌘2) to see what s1 received and returned. Debugging covers the rest.

5. Change it while it runs #

Edit src/index.ts and change Hello, to Hey, . Save. The ext dev terminal prints change detected, built and reloaded. Run the macro again: the message has changed, and you never reinstalled anything.

Break something on purpose, such as const x: number = 'a'; at the end of the file. The terminal prints the TypeScript error and says the previous version keeps running. Remove the line and it reloads.

6. Add a setting #

Anything the person using your extension should choose goes in a settings pane, not in code. Add a field to adds in switchboard.json:

"settingsPane": {
  "title": "Hello",
  "fields": [
    { "id": "greeting", "title": "Greeting", "type": "string", "default": "Hello", "description": "The word the action starts with." }
  ]
}

and read it in the action:

const greeting = (await ctx.settings.get('greeting')) ?? 'Hello';
const message = `${greeting}, ${p.name ?? 'world'}!`;

The field appears in Settings > Extensions and in your extension's detail in the Extensions window. A secret field is kept in the Keychain and read with ctx.secrets.get, never from settings. See settingsPane.

7. Ask for a permission #

An extension can touch only what its manifest asks for and the person approved. Make the action copy its greeting. In src/index.ts, before the return:

await ctx.clipboard.writeText(message);

Save, and run the macro. It fails, and the failure says exactly what is missing:

failed s1  com.yourname.hello: permission denied: this extension may not write the clipboard (permissions.clipboard.write)

Ask for it in switchboard.json:

"permissions": { "clipboard": { "write": true } }

Save and run again. It works. When someone installs the extension, they see this as a check row (Change the clipboard) before anything runs, so ask for the least you can: a narrow network.hosts, one folder, write without read. The whole list is in Writing an extension.

Because the action now changes something outside Switchboard (the clipboard), set "dryRunSafe": false so dry runs simulate it.

8. Read the log #

ctx.log.info(...) and console.log write to the extension's log. ext dev streams it. From any terminal:

switchboard ext logs com.yourname.hello
switchboard ext logs com.yourname.hello --follow
2026-10-25T08:11:53Z INFO hello called

It is kept at ~/Library/Application Support/Switchboard/Logs/Extensions/com.yourname.hello.log, rotated at 512 KB, and survives a crash or removal. Show Log File in the Extensions window opens it. See switchboard ext.

The gallery is a public repository, switchboard-app/switchboard-extensions, and an extension joins it by pull request. Fork it, then put your extension in extensions/hello/ (the folder name is the last part of the id):

extensions/hello/
  switchboard.json   the manifest, with author, "license": "MIT" and a settingsPane
  src/index.ts       the code
  tsconfig.json      { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"] }
  README.md          what it does, a "## Permissions" section explaining each one, a "## Setup" section
  CHANGELOG.md       a "## 0.1.0" entry for the manifest's version
  test/hello.test.mjs

Tests run your extension in the real sandbox with fake services, so a call to something you did not declare fails the test as it would for a user. This one stands in for the clipboard:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withExtension } from '../../../scripts/testkit.mjs';

test('Say Hello greets by name, with the greeting from settings, and copies it', async () => {
  const copied = [];
  const engine = (method, p) => { if (method === 'engine.clipboard.write') { copied.push(p); return null; } };
  await withExtension('hello', { settings: { greeting: 'Howdy' }, engine }, async (t) => {
    const r = await t.run('action.hello', { name: 'Kyle' });
    assert.equal(r.message, 'Howdy, Kyle!');
    assert.equal(JSON.stringify(copied).includes('Howdy, Kyle!'), true);
  });
});

Check it the way the gallery's CI does:

node scripts/check.mjs hello
ok   hello  (1 tests)

all 1 extension(s) pass (3.0 s)

check verifies the manifest, that every permission is used and none is missing, banned APIs, types and your tests. Open the pull request. A person reads it against the gallery's review checklist: least permissions, each one explained in your README, an honest dryRunSafe, no secrets in code. After the merge, CI builds a reproducible package and signs the gallery's index, and your extension appears in Extensions > Gallery, marked Verified when it was reviewed, is MIT licensed and was built from this source.

An update that adds a permission is reviewed as a new submission, and people are asked again.

Going native: a Swift extension #

Anything that needs a macOS framework, such as an event tap or Core Audio, is written in Swift instead. A native extension is a signed program the engine launches itself, under a sandbox built from its manifest, and it speaks the same protocol as a TypeScript one. The template is templates/swift-action, and the SwitchboardExtension package it builds on is in the SDK at swift/SwitchboardExtension.

import SwitchboardExtension

struct ShoutParams: Decodable { var text: String? }
struct Shouted: Encodable { var text: String }

let ext = Extension()
ext.action("shout") { (p: ShoutParams, ctx) -> Shouted in
    ctx.log.info("shout called")
    return Shouted(text: (p.text ?? "hello").uppercased() + "!")
}
ext.run()

Copy the template, point its Package.swift dependency at the package (.package(path: "…/swift/SwitchboardExtension")), and run the same command:

switchboard ext dev ~/Switchboard-Extensions/shout

On a folder with a Package.swift and a manifest.json, ext dev runs swift build, wraps the result in an ad hoc signed .sbext and relaunches it on every save. A real install checks the signature first. The Swift names match the TypeScript ones (ext.action, ext.trigger, ext.sensor and so on), and ctx.websocket is TypeScript-only for now. The SDK's README has the whole table and says what the sandbox does and does not enforce.

Where next #

  • Writing an extension lists every extension point and everything ctx can do.

  • actions, triggers, conditions and the other SDK pages are the reference for each point.

  • The gallery's extensions/ folder holds seven real extensions written exactly like yours: Linear, Jira, Home Assistant, OBS, Sonos, 1Password and Things.