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:
| Builder | Makes | Handler |
|---|---|---|
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 trigger | start(ctx, params, emit) calls emit(payload) to start macros. It may return a cleanup. stop is optional. |
paletteProvider(id, { search, run }) | Palette rows | search(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 message | Returns a prompt request (kind text, list or form), or { text, seconds? }. |
menuBarItem(id, { build, click }) | A section in the engine's menu bar menu | build(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 debugger | Returns a panel (rows, text, status) or null. |
sensor(id, { read, everyMs }) | A value that streams in | Usable 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 saved | Returns findings. |
intent(id, handler) | An action Shortcuts and URLs can run | switchboard://intent?..., switchboard ext intent and the API reach it. |
aiTool, mcpTool, modelProvider, importer, route, cli, shareTransform | A 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 rewrite | See each page in the SDK reference. |
settingsPane, templates, types | No code | Declared 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.
| Call | Notes |
|---|---|
ctx.log.info(...) | A log per extension. console.log goes there too. |
ctx.storage.get/set/delete | A 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/writeText | Needs permissions.clipboard.read or .write. |
ctx.notify({ title, body }) | Needs permissions.notifications. |
ctx.keyboard, ctx.screen, ctx.accessibility | Need the matching permission set to true. |
ctx.files.readText/writeText | Needs 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.signal | Aborted 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 writehost:port. Redirects are re-checked against the list.network.lan:
truealso allows devices on the user's own network (10.x, 172.16-31.x, 192.168.x,*.local), any port, andhttp://andws://. Use it for hosts the user types into settings, such as Home Assistant. Public hosts still needhosts. Loopback is never covered bylan.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.fetchfor HTTP.Standard JavaScript works: JSON, Promise, Map, Intl, Date, typed arrays. Timers (
setTimeout,setInterval) are there. Polling triggers usesetInterval.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.