termnotify
Desktop notifications through your terminal, the way OpenCode does it. Zero dependencies, pure Python, no permission prompts, no code signing.
from termnotify import notify
notify("Build finished", title="CI")
$ termnotify "Build finished" -t CI
$ termnotify --detect
osc777
Why this exists
OpenCode does not use osascript, terminal-notifier or pyobjc for its
notifications. Its TUI writes an OSC escape sequence to the terminal and
the terminal (Ghostty, iTerm2, kitty, WezTerm, ...) turns it into a native
notification. Compared to the system-API route that the existing Python
libraries take, that means:
- no code signing or app bundle required (see the macOS notes in
desktop-notifier) - no notification/Automation permission prompts
- works over SSH: the notification appears on the machine running the terminal, not on the server
- nothing to install beyond Python itself
As far as we could tell there is no Python library that does this, so this
is one. It mirrors the detection heuristics and payload formats of the
OpenTUI terminal backend used by OpenCode:
packages/native/src/terminal.zig
(called from
packages/tui/src/feature-plugins/system/notifications.ts
in OpenCode).
Protocols
| Protocol | Sequence | Terminals |
|---|---|---|
| OSC 99 | ESC ] 99 ; ... with base64 title/body |
kitty, foot |
| OSC 777 | ESC ] 777 ; notify ; title ; body |
Ghostty, WezTerm, Warp, hterm/Blink, Contour, VTE (GNOME Terminal, Tilix, Terminator, ...), rxvt, Windows Terminal |
| OSC 9 | ESC ] 9 ; title: body |
iTerm2, Apple Terminal, ConEmu |
The protocol is detected from the environment (TERM, TERM_PROGRAM,
TERM_FEATURES, WT_SESSION, plus VTE_VERSION as a VTE-family hint);
when several match, the highest protocol priority (osc99 > osc777 > osc9)
wins. No interactive capability query is performed, so detection is
instant and never blocks or touches the input stream.
Force a protocol when detection fails:
$ TERMNOTIFY_PROTOCOL=osc9 termnotify "hello"
or per call: notify("hello", protocol="osc99"). Setting the variable to
0, false, off or none disables notifications entirely.
Multiplexers
- tmux: sequences are wrapped in DCS passthrough. tmux needs
set -g allow-passthrough on. - GNU Screen (
STYset): sequences are wrapped in a Screen DCS. - Zellij: detection is disabled (Zellij only forwards OSC 99 and needs
a query handshake), so pass
protocol="osc99"explicitly if you know the terminal forwards notifications.
API
notify(message, title=None, *, protocol=None, file=None, env=None) -> bool
Writes the notification sequence. Returns True if the bytes were written
to a terminal. That means the terminal was asked to notify, not that a
banner appeared: the window may be focused, notifications may be muted in
the terminal or the OS, etc. Returns False when no protocol was
detected, the message is empty, or no terminal is reachable.
Output goes to the controlling terminal (/dev/tty) rather than stdout, so
redirections and pipes don't swallow notifications and program output is
never polluted. Pass a binary file to override.
detect_protocol(env=None) -> str | None
Returns the protocol notify() would use, or None.
Installation
$ pip install -e .
# or simply copy src/termnotify into your project
Requires Python 3.9+.
Limitations
- Focus gating is up to the terminal. On macOS, Ghostty only presents a banner when its window is not focused; while focused, the notification is delivered silently to Notification Center. (OpenCode appears to work around this by only sending notifications when its TUI is blurred -- the terminal applies the same rule regardless.) To test, switch to another app right after triggering a notification.
- No sound. OpenCode plays sounds through its own audio engine; that is not part of the terminal notification protocols.
- Environment-based detection only. If your terminal is not recognized,
set
TERMNOTIFY_PROTOCOL. - Requires a terminal. For GUI apps without one, use a system API library
such as
desktop-notifier.
Development
$ python -m unittest discover -s tests -v
$ PYTHONPATH=src python -m termnotify --detect
Metadata
Release files for termnotify 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| termnotify-0.1.0.tar.gz | 10.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| termnotify-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 19.7 kB
Release files / termnotify-0.1.0.tar.gz
| Download URL | termnotify-0.1.0.tar.gz |
|---|---|
| Size | 10.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cbee93d13c6c6aadbe69403c8990edd41dd89b8956551bc39ba2213548761aa2
|
|
BLAKE2b-256 checksum How to use checksums |
d3c34e6f898ccfe43008ccf64a4977c18682a3133c10d388c1b34b4688b31f6c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / termnotify-0.1.0-py3-none-any.whl
| Download URL | termnotify-0.1.0-py3-none-any.whl |
|---|---|
| Size | 9.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
289e3b6c052fd892b1fdbd4b27241bcc2afa3a997063900e3f5d823892a349b1
|
|
BLAKE2b-256 checksum How to use checksums |
02f09510e18f403eb9270989cfbaa7b06556e5d97ff3111e72899ebabe4d11a8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|