Documentation
Build a Cirrulet plugin
Cirrulet is a macOS app that draws an island around the notch. Everything in that island beyond the system surfaces is a plugin, and the plugins are the product — the ones that ship with the app are demonstrations of what you can build, not a privileged tier you cannot reach.
This page is the whole path, from an empty directory to a signed package submitted for review. It takes about half an hour the first time. The plugin platform specification behind it is the authoritative contract; come back to it when you want to know exactly what the host will and will not do.
What you are building
A plugin is one self-contained JavaScript file. The app runs it in a sandboxed JavaScriptCore worker, one worker per plugin, and talks to it over newline-delimited JSON-RPC. That shape decides most of what follows:
There is no DOM and no Node. No
window, nofs, nochild_process, noimport()at run time. Your bundle is the whole program.You do not draw. You return a document describing what you want — a detail view, a list, a live activity beside the notch — and the host renders it with native AppKit. That is why a plugin cannot make the island lie about which app it belongs to.
You cannot open a socket. Outbound HTTPS goes through the host, only to origins you declared in your manifest, and the host holds any OAuth token so your code never sees one.
Everything you want, you declare. Capabilities live in your manifest with a human-readable reason, are shown to the user, and are enforced at the boundary rather than trusted.
Before you start
Node.js 22 or newer. The SDK and all of its tools are plain Node and run anywhere, including CI.
macOS 26 on Apple Silicon, if you want to run your plugin in the real app rather than only the headless simulator. You can write, build, test, and sign a plugin on any platform.
A publisher account at the publisher dashboard, when you reach the last step. You do not need one to build.
1. Start a package
mkdir hello-cirrulet && cd hello-cirrulet
npm init -y
npm install @cirrulet/sdkThe SDK installs five commands onto your PATH, all prefixed cirrulet-. Use them through npx inside the project.
2. Write the manifest
Your package.json is also the plugin manifest. The edged object carries your identity, what you are asking the host for, and what you contribute:
{
"name": "hello-cirrulet",
"version": "0.1.0",
"type": "module",
"description": "A greeting beside the notch",
"license": "MIT",
"engines": { "edged": ">=1.0.0 <2.0.0" },
"edged": {
"manifestVersion": 1,
"id": "dev.example.hello",
"displayName": "Hello Cirrulet",
"publisher": "example",
"minimumMacOS": "26.0.0",
"runtime": {
"target": "edged-js-v1",
"entrypoint": "dist/plugin.js",
"protocol": "^1.0.0",
"sdk": "^1.0.0"
},
"platforms": ["darwin-arm64"],
"capabilities": {
"required": [
{
"id": "live-status",
"name": "ui.island",
"reason": "Show a short greeting beside the notch",
"scope": {
"actionIDs": ["hello"],
"liveActivityIDs": ["hello"],
"maximumPriority": "low",
"maximumUpdatesPerMinute": 6,
"maximumLifetimeSeconds": 30,
"deterministicProgress": false
}
}
],
"optional": []
},
"contributes": {
"actions": [
{
"id": "hello",
"title": "Hello Cirrulet",
"description": "Render a native greeting",
"handler": "actions.hello",
"presentation": "view",
"uses": ["live-status"]
}
],
"providers": [],
"events": [],
"preferences": [],
"islandViews": []
}
}
}Two things about that shape are worth internalising now, because they are the two most common first mistakes:
runtime.entrypointnames the built bundle, not your source. It is the file the host loads, which is what your build produces.Every contribution lists the capability IDs it
uses. A handler is granted the intersection of what the plugin declared and what its own contribution named, so an action that forgets to name a capability is refused at run time even though the manifest declared it.
Check it before you write any code:
npx cirrulet-validate-manifest package.jsonIt checks two contracts at once — the runtime one the app enforces when it loads you, and the distribution one the marketplace enforces when it packages you. Exit 1 means the app would refuse it; exit 3 means it would run but could not be published. Fields that only make sense in source, like dependencies and scripts, are reported as a note, not a failure: the build leaves them behind.
3. Write the plugin
// src/extension.ts
import { defineExtension, island, ui } from "@cirrulet/sdk";
export default defineExtension({
actions: {
async hello(_context, _args, host) {
await island(host).live.publish({
activityID: "hello",
title: "Hello from Cirrulet",
value: "Ready",
state: "success",
ttlSeconds: 12,
priority: "low",
actionID: "hello",
});
return ui.document({
surfaceID: "hello",
root: ui.detail({
id: "hello-detail",
title: "Hello",
children: [ui.text({ id: "message", text: "Rendered by the host." })],
}),
});
},
},
});handler: "actions.hello" in the manifest is what binds that function to the action. The builders — island, ui, and the network, oauth, storage, calendar, and launcher clients — are documented in the SDK's own README on npm, and every bound they enforce is in the specification.
4. Build it
npx cirrulet-build-pluginThat validates both contracts, bundles your entrypoint and everything it imports — the SDK included — into one self-contained file, selects the distribution fields of your manifest into canonical JSON, and writes the sign-ready package to build/.
The bundle is byte-reproducible: the same sources produce the same bytes from your laptop, from a fresh install, and from the marketplace's build sandbox. Its SHA-256 is your plugin's identity, so that property is not a nicety — it is what lets the marketplace rebuild your submission and check that it got what you signed.
If you would rather own your build script, import the same function instead of spawning the command:
import { buildPluginPackage } from "@cirrulet/sdk/builder";
await buildPluginPackage({ pluginDirectory: "." });Spawning it would fail review regardless: the marketplace's static analysis rejects node:child_process anywhere in a submitted checkout.
5. Run it against a headless host
You do not need the app, or a Mac, to find out what the host would refuse:
npx cirrulet-simulate-plugin .It drives every handler you contributed against a simulation of the real host and prints each refusal with the reason the host would give:
dev.example.hello@0.1.0 (dist/plugin.js)
action hello: 1 refused, and the handler returned as if it had not been
host.island.live.publish.v1 unapproved-island-symbol
"timer" is not one of the 30 SF Symbols the host approves, so the host
rejects the whole activity — title, value, ring and terminal alert
included, not just the glyph.
1 refusal(s) the host would issueIt exits non-zero when anything would be refused, so it belongs in your build script. It drives your handlers with synthetic arguments, though, so it only reaches the code paths a handler takes when it is told nothing. For real assertions, drive createHostSimulator yourself:
// test/island.test.mjs
import assert from "node:assert/strict";
import test from "node:test";
import { createHostSimulator } from "@cirrulet/sdk/testing";
import manifest from "../package.json" with { type: "json" };
import extension from "../src/extension.ts";
test("the greeting is one the host accepts", async () => {
const host = createHostSimulator({ manifest, extension });
await host.invokeAction("hello", { name: "world" });
assert.deepEqual(host.refusals, []);
assert.deepEqual(host.island.liveActivityIDs(), ["greeting"]);
});Run it with node --test test/*.test.mjs. Node strips the types out of your TypeScript on the way in, so there is no build step between writing a test and running it — but it resolves imports literally, and ../src/extension.js names a file that does not exist. Import your own modules by the name they have, and tell TypeScript to accept that spelling:
{
"compilerOptions": {
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
}
}tsc, node --test and the bundler all agree on ./thing.ts. They do not all agree on ./thing.js, and they do not all agree on ./thing.
This is worth the ten minutes. A refused payload is the one failure with no symptom: the host writes no audit event for a payload it turns down, and a plugin that catches a failed publish — as it should — has nothing to tell it apart from success. The alternative to a test is installing the plugin and noticing a chip that never appears.
6. Enrol a signing key, once
Every package you publish is signed by an Ed25519 key the marketplace knows is yours.
npx cirrulet-keygen-publisher --out ~/.config/cirrulet/publisher.pem \
--key-id example-2026 --publisher exampleThe private key is written only to that path, mode 0600, and never printed. The command prints two things: the public half, which you enrol at the publisher dashboard, and a cirrulet-publisher.json to commit at the root of your repository.
Commit that file. It records the public half of the key you are allowed to sign with, and it is the only thing standing between you and the most expensive mistake this tooling allows. A signature carries a keyID string the signer chose freely: passing --key-id example-2026 writes that name into the envelope _whatever key you actually signed with_. Naming the right key and being signed by it are different things, and only one of them is checkable. The first team to use this tooling shipped two of four plugins signed by an unrelated SSH key, with envelopes that named the right one, and found out from an audit rather than from anything in the workflow.
With the file committed, signing refuses any key but the enrolled one before it writes anything. Without it, nothing can tell the difference. It holds public halves only — no private key is ever recorded in it — and it lives in the repository rather than in your home directory because the check has to give the same answer inside the marketplace's build sandbox, which has neither.
--publisher matters: the marketplace enrols a key _to a publisher_, so the name in the trust file has to be the same one your manifest publishes as, or a correctly signed package is rejected on submission for belonging to someone else.
On your second plugin you already have a key, and generating another is the wrong answer — it would need enrolling too, and now two keys can sign for you. Run the release from the new repository and it will tell you the trust file this key wants, public half filled in:
Cannot release the plugin: No key ID: pass --key-id <id>, set
EDGED_PUBLISHER_KEY_ID, or record it in cirrulet-publisher.json.
The key at ~/.config/cirrulet/publisher.pem has this public half, so the
trust file it wants is:
{
"schemaVersion": 1,
"keyId": "<the key ID you enrolled with the marketplace>",
"publicKey": "Dzq0gK5pxexyOOZJzIfWfpJQIRtqf1XE9Kjfj2JygRM=",
"privateKeyPath": "~/.config/cirrulet/publisher.pem",
"retiredKeys": []
}Fill in the key ID, add your publisher, commit it, and carry on.
7. Release
npx cirrulet-release-plugin .One command builds, signs, verifies the result the way the marketplace's packager will, and leaves the signature envelope where a rebuild will find it. It prints the public key that actually signed, every time, and the last line names the one file to commit:
git add edged-package-signature.jsonThat envelope has to be committed because the marketplace never sees your build/ directory. It clones the revision you submit, rebuilds it in its own sandbox, and packs its own output — which must already contain your signature. That works because the signed payload covers the canonical manifest and the content digest of every packaged file _except_ the envelope itself, and because the bundle is reproducible, so the sandbox re-derives exactly what you signed.
Add the check to your test script so a wrong key can never reach a submission:
"scripts": {
"test": "cirrulet-verify-signature"
}8. Submit
Create a publisher account and enrol the public key from step 6.
Register the plugin, which gives you a marketplace row ID.
Submit a source release: an archive of your checkout, the repository URL and the exact commit, your build command and output directory, and the capabilities your manifest declares.
The marketplace then rebuilds your source in a sandbox and gates the result. It checks the manifest against both contracts, statically analyses the checkout for forbidden imports and for host.call operations it cannot resolve, verifies your signature against your enrolled key, and confirms its own rebuild produced the bytes you signed. Everything it checks, you can check first with the commands above.
Where to go next
The plugin platform specification — the full contract: every capability, every host operation and its limits, the island vocabulary, the JSON-RPC protocol, the packaging and signing formats, and a section on exactly what ships today versus what is sketched.
The SDK reference —
@cirrulet/sdkon npm, whose README documents each client with worked examples: network, OAuth, calendar, launcher, storage, the island builders, and the headless test host.examples/plugins/hello-edgedin the SDK repository — the smallest manifest and entrypoint that actually build.