Browse the docs

Writing an extension

Build a TypeScript extension that adds actions, conditions, triggers, functions or palette rows, and asks only for the permissions it needs.

An extension adds things to Switchboard: actions you can put under Do, conditions for Only if, triggers for When, functions for expressions, palette rows and more. You write it in TypeScript with @switchboard/sdk. It runs in its own sandboxed worker and can touch only what its manifest asks for and you approved.

You need Node 22 and the switchboard command line tool.

Start from the template #

Make your first extension walks through this from an empty folder. The steps are: install typescript and @switchboard/sdk, copy templates/hello from the SDK, and change the id in switchboard.json to a reverse-DNS name that is yours, like com.yourname.hello.

The manifest #

switchboard.json says what the extension adds and what it may touch. Required keys are id, name, version, engines, entry and adds.

{
  "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" } } },
      "output": { "type": "object", "properties": { "message": { "type": "string" } } }
    }]
  }
}

Every extension point has a list under adds: triggers, conditions, actions, types, functions, prompts, huds, menuBarItems, paletteProviders, inspectors, debuggerViews, intents, modelProviders, linters, settingsPane, templates, aiTools, importers, sensors, mcpTools, routes, cli and shareTransforms.

Add "$schema": "./node_modules/@switchboard/sdk/manifest.schema.json" for completion in your editor. The schema is the full reference.

dryRunSafe on an action means running it cannot change anything outside Switchboard. Dry runs and the debugger may then run it for real.

Write the code #

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

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

What you declare in adds and what you implement must match. Switchboard refuses an extension that declares something it does not implement, or implements something it does not declare.

The builders:

BuilderMakesHandler
action(id, handler)A step(params, ctx) returns the step's output.
condition(id, handler)An Only if condition(params, ctx) returns true or false.
fn(id, handler)A function for expressions(params, ctx) returns the value.
trigger(id, { start, stop })A When triggerstart(ctx, params, emit) calls emit(payload) to start macros. It may return a cleanup. stop is optional.
paletteProvider(id, { search, run })Palette rowssearch(query, ctx) returns rows with id, title, and optional subtitle, icon, data. run(item, ctx) is optional.
prompt(id, handler), hud(id, handler)A step that asks, or shows a messageReturns a prompt request (kind text, list or form), or { text, seconds? }.
menuBarItem(id, { build, click })A section in the engine's menu bar menubuild(ctx) returns { title, icon?, items? }; click(entryId, ctx) runs on a choice.
inspector(id, handler), debuggerView(id, handler)A section in the editor's inspector, or the debuggerReturns a panel (rows, text, status) or null.
sensor(id, { read, everyMs })A value that streams inUsable in expressions, as a condition and as a trigger. Or { start(ctx, emit) } to push values.
linter(id, handler)Warnings on a macro when it is savedReturns findings.
intent(id, handler)An action Shortcuts and URLs can runswitchboard://intent?..., switchboard ext intent and the API reach it.
aiTool, mcpTool, modelProvider, importer, route, cli, shareTransformA tool for the in-app AI, a tool on the MCP server, a model, a file importer, a web route, a CLI command, a share rewriteSee each page in the SDK reference.
settingsPane, templates, typesNo codeDeclared in the manifest only.

Once the extension is installed, an action appears in the editor as <extension id>.<action id>, for example com.yourname.hello.hello.

What ctx gives you #

ctx is how your code reaches anything outside its sandbox.

CallNotes
ctx.log.info(...)A log per extension. console.log goes there too.
ctx.storage.get/set/deleteA key-value store kept by the engine. JSON values. No permission needed.
ctx.secrets.get(name)Values you stored for this extension in the Keychain. No permission needed.
ctx.settings.get(id)The user's value for a settingsPane field. No permission needed.
ctx.fetch(url, init)HTTPS through the host. Needs permissions.network.
ctx.websocket(url)send, receive(timeoutMs) and close. Same host rules as fetch.
ctx.clipboard.readText/writeTextNeeds permissions.clipboard.read or .write.
ctx.notify({ title, body })Needs permissions.notifications.
ctx.keyboard, ctx.screen, ctx.accessibilityNeed the matching permission set to true.
ctx.files.readText/writeTextNeeds permissions.files.paths, and write: true to write.
ctx.exec(cmd, args, { stdin })Runs an absolute path listed in permissions.exec.commands. No shell. Switchboard asks the user the first time.
ctx.openURL(url)Needs permissions.openURL.schemes to contain the URL's scheme.
ctx.signalAborted when the engine cancels the call.

Ask for permissions, and only those #

"permissions": {
  "network": { "hosts": ["api.linear.app", "*.example.com"] },
  "clipboard": { "write": true }
}
  • network.hosts: exact names or *.domain. A wildcard covers subdomains, not the bare domain. HTTPS only, default port only unless you write host:port. Redirects are re-checked against the list.

  • network.lan: true also allows devices on the user's own network (10.x, 172.16-31.x, 192.168.x, *.local), any port, and http:// and ws://. Use it for hosts the user types into settings, such as Home Assistant. Public hosts still need hosts. Loopback is never covered by lan.

  • clipboard: { "read": true, "write": true }, each on its own.

  • files: { "paths": ["~/Documents/Notes"], "write": false }. Folders only, never / or ~ alone.

  • exec: { "commands": ["/opt/homebrew/bin/op"] }. Absolute paths only.

  • openURL: { "schemes": ["things"] }.

  • keyboard, screen, notifications, accessibility: true.

The user sees these as check rows before installing. A call without its permission fails with permission denied (error code -32010) and never reaches the engine.

What the sandbox means for your code #

Extension code runs with no fs, net, http, child_process, process, Buffer, fetch, URL or TextEncoder, and no import() or eval. require loads your own relative files and @switchboard/sdk only.

  • Bundle your dependencies into dist/ (esbuild with --platform=neutral --format=cjs) and avoid libraries that need Node.

  • Use ctx.fetch for HTTP.

  • Standard JavaScript works: JSON, Promise, Map, Intl, Date, typed arrays. Timers (setTimeout, setInterval) are there. Polling triggers use setInterval.

  • A call that takes more than 5 seconds without answering is killed and the extension restarts. Memory is capped.

  • At most 8 WebSockets are open at once, 1 MB per message. They close when the extension unloads.

Build and test #

npx tsc -p .

compiles src/ to dist/. In the SDK folder, npm test builds everything, runs the manifest tests and type-checks the examples. examples/word-count (an action and a function, no permissions) and examples/http-json (one network host) are worth reading.

validateManifest in manifest.mjs checks a manifest against the schema.

switchboard ext dev <folder> loads the folder into the running engine, rebuilds it on every save, hot-reloads it and streams its log. It also works on a Swift package (Package.swift and manifest.json), which it builds into an ad hoc signed .sbext. switchboard ext logs <id> [--follow] prints the log later. Unsigned extensions install only in Develop mode (switchboard ext install <folder> --dev).

Native extensions in Swift use the SwitchboardExtension package and have the same shape as the TypeScript SDK; see Make your first extension.