OmniNotch by Mactoro

Store · Make your own

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….

"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:

  1. value is 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.
  2. Say it in words, not colour. The state already speaks: failed says "failed", needsYou says "needs you". Don't put "ERROR" in the title.
  3. rising (amber) is only for time. A countdown, something due, something waiting on you. alert (red) is only for something broken. Most things are calm.
  4. needsYou means 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/:

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.

Share yours on GitHub