Make your own
A command can be a link, a script you already have, or a script that prints a little JSON. OmniNotch draws the search, the lists and the buttons. Any language, no SDK.
Three ways to make a command
1. A link
Anything with a web address or an app link becomes a command. {query} is what you type after it.
{ "v": 2, "id": "you.search-wiki", "name": "Search Wiki", "commands": [
{ "id": "search", "title": "Search Wiki", "mode": "run",
"url": "https://en.wikipedia.org/w/index.php?search={query}",
"arguments": [{ "name": "query", "placeholder": "Search…", "required": true, "percentEncoded": true }] } ] }
2. A script you already have
Scripts written for Raycast's script-command format run unchanged: put the file in a folder with a manifest that
says "format": "raycast-script". Over 800 from the open-source collection are already in the store.
3. A script that shows lists, details and forms
Your run script prints a screen as JSON (a list with actions, a detail, a form) and OmniNotch draws it.
The full format, with every field: FORMATS.md.
Share it
Open a pull request on GitHub. It's checked automatically, a person reads it, and it appears in the store with its own page and Install button.
Live statuses
An extension can also put something live in the notch: a build running, a countdown, a backup. The rest of this guide covers that mode.
Live statuses: a five-minute start
1. Put the omninotch command on your PATH (OmniNotch installs it the first time it runs):
mkdir -p ~/.local/bin && ln -sf "$HOME/Library/Application Support/OmniNotch/bin/omninotch" ~/.local/bin/omninotch
If ~/.local/bin isn't on your PATH yet, add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc.
2. Push your first activity:
omninotch push --id hello --title "Hello from my Mac" --value "hi" --icon sf:hand.wave
It appears in the closed notch and in the panel's Live tab. End it with omninotch end --id hello.
3. Make an extension:
omninotch new "Water"
This creates ~/Library/Application Support/OmniNotch/Extensions/local.water/ with a working extension: a
counter and an "Add one" button. OmniNotch notices the new folder at once and shows
"Water is waiting to run · Allow…" at the top of the Live tab. Press Allow.
4. Edit run. Change the title, the icon, what it counts. Save. Because the folder changed, OmniNotch asks
you to allow it again (that's the safety rule: you approve exactly the code that runs), then shows your change.
That's the whole loop.
How an extension works
local.water/
manifest.json what it is, how often it runs, its settings and buttons
run any executable: zsh, bash, Python, Node, Swift… (chmod +x)
icon.png optional, 64 px, monochrome
{
"v": 1,
"id": "local.water",
"name": "Water",
"description": "Glasses of water today.",
"author": "Your Name",
"run": "run",
"every": "60s",
"network": false,
"settings": [
{ "key": "goal", "title": "Daily goal", "type": "text", "default": "8" }
],
"actions": { "add": "Add one" }
}
OmniNotch runs run from the extension's folder every every (10 s at least; slower in power saving) and when
one of its buttons is pressed. It passes:
| Variable | What |
|---|---|
OMNINOTCH_SETTING_<KEY> |
Each setting's value, key in capitals (OMNINOTCH_SETTING_GOAL). Set them in Settings → Extensions |
OMNINOTCH_ACTION |
The pressed button's action name (add), or empty on a timed run |
OMNINOTCH_DATA |
A folder that's yours to keep state in. It survives updates and is never inside the extension folder, so writing to it doesn't trigger a new Allow |
OMNINOTCH_VERSION |
1 |
PATH |
The system paths plus /opt/homebrew/bin and /usr/local/bin |
The script prints JSON to stdout, either a list of activities:
[{ "v": 1, "id": "main", "title": "Water", "value": "3/8", "progress": 0.375, "icon": "sf:drop" }]
or a list plus when to run next (useful for a timer that should tick faster while it's running):
{ "activities": [ … ], "next": "15s" }
Print an empty list [] to show nothing. Each run replaces everything the extension showed before.
Limits: 10 seconds per run, 64 KB of output, 5 activities per extension. A run that fails, times out or prints bad JSON shows its error (and the first line of stderr) in Settings → Extensions, and its activities disappear until the next good run.
Buttons. An activity's actions can hold up to two buttons. {"title": "Add one", "run": "add"} runs your
script with OMNINOTCH_ACTION=add; the name must be listed in the manifest's actions. {"title": "Open",
"url": "https://…"} opens a web page. Add "confirm": true to ask before running. A button can never run an
arbitrary command, only your own script.
Trust. OmniNotch fingerprints the extension folder. Nothing runs until you allow it, and any change to the
folder (including your own edits) asks again. Declare "network": true if your script uses the internet; the
Allow dialog says so.
The activity
| Field | Required | Meaning | Limits |
|---|---|---|---|
v |
yes | Format version | 1 |
id |
yes | The same id updates the same activity | 64 chars, A–Z a–z 0–9 . _ - |
title |
yes | What it is | 40 chars |
subtitle |
no | One line of detail | 60 chars |
value |
no | The short thing worth a glance: a score, a price, time left | 8 chars |
progress |
no | 0 to 1, or "indeterminate" |
|
icon |
no | sf:<SF Symbol name> (browse them in Apple's SF Symbols app); app:<bundle id> for an installed app's own icon, like app:com.google.Chrome (find an id with osascript -e 'id of app "Zoom"'); or an image file in your extension folder |
|
tint |
no | calm (default), live, rising, alert |
|
state |
no | active (default), needsYou, done, failed |
|
priority |
no | low, normal (default), high |
|
actions |
no | Up to 2 buttons (see above) | 2 |
expires |
no | "30s", "10m", "2h", "1d", or an ISO date |
1 h by default; 10 s after done or failed |
End one early with {"v": 1, "id": "main", "end": true} (push) or by leaving it out of your next output
(extension).
Which one shows in the closed notch: anything that needsYou, then high priority, then the most recently
updated. Only one shows at a time, never rotating. The rest are in the Live tab.
When it pops up beside the camera: when an activity starts needing you, finishes (done), fails, or on any
update to a high priority one. Use high sparingly; it interrupts.
Grids and the filter menu
A grid is a list drawn as tiles, for things you pick by looking: emoji, icons, screenshots, colours. Use
"view": "grid" with the same sections, items and actions as a list, "columns" (3 to 8, default 6), and a
"tile" on each item: an emoji or symbol, sf:<symbol>, an https:// image, favicon:<site>, a file path
(/… or ~/…, shown as its Quick Look thumbnail) or a colour (#ff8800). "labels": false drops the titles
under the tiles and shows the selected one's name above the grid instead. ←→ move by tile, ↑↓ by row.
The filter menu sits at the right of the search field (⌘P), like Raycast's dropdown. Add to a list or grid:
"dropdown": { "title": "Show", "value": "all",
"options": [{ "title": "All", "value": "all" }, { "title": "Apps", "value": "apps" }] }
Picking an option runs your script again with OMNINOTCH_FILTER set to its value, and OmniNotch remembers the
choice for that command, so it's set the same way next time (and on every run after).
Long jobs in the notch
A command or action that takes a while (an upgrade, a download, a build) runs as a background job: no 10-second
limit (30 minutes), the bar can close, and the notch shows it as a Live activity with a Stop button, then done
or failed beside the camera. Use "mode": "background" on a command, or "background": true on an action.
While it runs, everything the script prints is shown as it goes: plain output lines as the status (so a tool's own
output works as it is), or {"progress": 0.4, "status": "Downloading"} for the ring. The last line can be
{"toast": "Upgraded 5 packages"}, which is what done says. Exit 0 is done; anything else is failed, with the
last line as the reason. The whole output is kept in a log (Show Log). OMNINOTCH_BACKGROUND is 1.
Previews and icons
A preview beside the list. Give list rows a detail and the bar splits like Raycast's: the list on the left,
the selected row's details on the right. Markdown (headings, bullets, code blocks, quotes, images on their own
line), an optional image (https), and label/value metadata:
{ "id": "42", "title": "Fix the crash", "actions": [ … ],
"detail": { "body": "## Fix the crash\n\nSteps to reproduce…", "image": "https://…/shot.png",
"metadata": [{ "label": "Status", "value": "Open" }, { "label": "Author", "value": "sam" }] } }
Icons. A row's icon can be sf:<symbol>, app:<bundle id> (the app's own icon), favicon:<url or site>
(the site's icon, fetched from the site itself and cached), or an https:// image. Quicklinks get their site's
icon without asking.
Accounts and API keys
Two ways for a command to use someone's account. Both are kept in the macOS Keychain, never in a file.
An API key or personal token: a setting with "type": "password". Mark it "required": true and OmniNotch
asks for it in a form the first time the command runs (typed hidden), then passes it as OMNINOTCH_SETTING_<KEY>.
People change it later from ⌘K →
"settings": [{ "key": "token", "title": "Personal token", "type": "password", "required": true,
"placeholder": "github.com/settings/tokens" }]
Sign in with the service (OAuth 2): an auth block. The first run shows Sign in with GitHub; the script then
gets OMNINOTCH_TOKEN, refreshed for it when it expires. ⌘K → Sign Out of GitHub forgets it.
"network": true,
"auth": { "provider": "GitHub", "flow": "device", "clientId": "Iv1.…",
"device": "https://github.com/login/device/code",
"token": "https://github.com/login/oauth/access_token", "scopes": "repo" }
flow is device (a code to enter on the service's page: GitHub, Google, Microsoft) or pkce (the browser opens
authorize and comes back on omninotch://oauth: Spotify, Linear and others; register that redirect with the
service). Use a public client id with no secret: anything in the manifest is public. To test with
tools/check-extension, pass --set TOKEN_KEY=… for a password setting or --arg TOKEN=… for a sign-in.
Design rules
OmniNotch draws everything, so your job is the words and the state. Four rules keep the notch calm:
valueis a glance. "3/8", "12d 4h", "+2.4%", "2–1". Not a sentence. If you have nothing short to say, leave it out; a progress ring or the title stands in.- Say it in words, not colour. The state already speaks:
failedsays "failed",needsYousays "needs you". Don't put "ERROR" in the title. rising(amber) is only for time. A countdown, something due, something waiting on you.alert(red) is only for something broken. Most things arecalm.needsYoumeans a person has to act. Approve, answer, take the break. It jumps to the front and pops up, so it's not for news.
Push from anything
Command line. Flags map one-to-one to the fields:
omninotch push --id deploy --title "Deploying web" --progress 0.4 --icon sf:hammer
omninotch push --id deploy --title "Deployed" --state done
omninotch end --id deploy
omninotch push --json '{"v":1,"id":"deploy","title":"Deploying web","value":"40%"}'
--source <name> groups your activities (default push); --button <title> --url <https://…> adds a link
button. Exit code 0 means OmniNotch took it; otherwise the reason is on stderr (69 means OmniNotch isn't running).
A link, from Shortcuts, Automator, Raycast or any app: omninotch://push?id=deploy&title=Deployed&state=done
and omninotch://end?id=deploy. Links can add url buttons but never run buttons, since any web page can open
a link.
A socket, from your own app: connect to ~/Library/Application Support/OmniNotch/agent.sock (private to your
user) and write one JSON line:
{"type": "activity", "source": "my-app", "activity": {"v": 1, "id": "sync", "title": "Syncing", "progress": 0.5}}
The reply is ok or error: <why>.
Recipes
Python extension (run, chmod +x run):
#!/usr/bin/env python3
import json, os, pathlib
data = pathlib.Path(os.environ["OMNINOTCH_DATA"])
goal = int(os.environ.get("OMNINOTCH_SETTING_GOAL", "8"))
f = data / "count"
n = int(f.read_text()) if f.exists() else 0
if os.environ.get("OMNINOTCH_ACTION") == "add":
n += 1
f.write_text(str(n))
print(json.dumps([{"v": 1, "id": "main", "title": "Water", "value": f"{n}/{goal}",
"progress": min(1, n / goal), "icon": "sf:drop",
"actions": [{"title": "Add one", "run": "add"}]}]))
Node extension:
#!/usr/bin/env node
fetch("https://api.github.com/repos/apple/swift").then(r => r.json()).then(r =>
console.log(JSON.stringify([{ v: 1, id: "stars", title: "swift", icon: "sf:star",
value: `${(r.stargazers_count / 1000).toFixed(1)}k` }])));
It uses the internet, so declare "network": true. Node 18 or later.
Wrap any long command with the push route. samples/extensions/notch-run/notch-run is a ready one:
notch-run make build # shows "running", then "done" or "failed · exit code 2"
Samples
In the OmniNotch repo under samples/extensions/:
- pomodoro/: a timer with Start and Stop buttons, state in
$OMNINOTCH_DATA, faster runs while it's going (next), and aneedsYounudge when the break is due. Copy the folder into your Extensions folder to try it. - notch-run/: the push wrapper above.
And the 30 that ship inside OmniNotch, in Resources/Extensions/: read any of them for a pattern. Good ones to
start with: Countdown (settings, rising), Memory Hog (a confirm button), Homebrew (a background job
with indeterminate progress), Is It Up (failed then done), and Reminders (JavaScript via osascript).
Check yours the way OmniNotch runs it: tools/check-extension <folder> [--action <name>] [--set KEY=value].
Testing and debugging
Run your script by hand with the same variables OmniNotch passes, and check the JSON:
cd ~/Library/Application\ Support/OmniNotch/Extensions/local.water
OMNINOTCH_DATA=/tmp/water OMNINOTCH_ACTION=add ./run | python3 -m json.tool
Then push it straight in to see how it looks, without waiting for the timer:
omninotch push --json "$(OMNINOTCH_DATA=/tmp/water ./run | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)[0]))')"
If it doesn't show: check Settings → Extensions for its error, that run is executable (chmod +x run), that
the extension is on and allowed, and that it prints nothing but the JSON to stdout (send logs to stderr).
Sharing
An extension is a folder, so share it as a zip or a git repo. Use a reverse-domain id you own
(com.yourname.thing); local. ids are for your own Mac. Whoever installs it allows it once on their Mac, and
again whenever it changes.
If your extension shows another company's data (Spotify, GitHub, a sports league), say in its description that it's unofficial, and make sure their API terms allow it.