Notifications
Configure a notification target to receive results, reminders, failures, and approval requests while you are away.
notify.targets in config.json applies to daemon work, schedules, goals, loops, and
jazz run.
Add and test a target
Add one with jazz notify add, which asks for the secret on a terminal and stores it in the
keyring:
jazz notify add phone --kind telegram --chat-id 123456789 --approve-from-chat
jazz notify test phone
| Kind | Fields | Secret (keyring) |
|---|---|---|
desktop | none | none |
ntfy | url (a topic URL) | none |
webhook | url | secret, optional: signs bodies |
telegram | chatId, optional apiBaseUrl, approveFromChat | botToken |
discord | optional channelId (with a bot), apiBaseUrl, approveFromChat | webhookUrl, or botToken |
Every target has a name (lowercase letters, digits, - and _) and an optional events
list. Secrets never go in config.json: they are in the keyring under
notify.targets.<name>.<field> (jazz config set notify.targets.phone.botToken), or, on a host
with no keyring, in JAZZ_NOTIFY_<NAME>_<FIELD>, for example JAZZ_NOTIFY_PHONE_BOT_TOKEN or
JAZZ_NOTIFY_TEAM_WEBHOOK_URL.
Pick an ntfy topic name nobody can guess: anyone who knows it can read what you are sent. For
Telegram, the bot must be able to post in the chat: send it a message first, then read the chat
id from https://api.telegram.org/bot<token>/getUpdates.
What you hear about
| Event | When |
|---|---|
waiting | Something needs you: a parked run’s approval, question or file, a goal stopped for review or at its cycle cap, a loop that stopped. Sent once per item |
paused | The daemon paused its own work at a daily cap |
unattended-failed | A workflow, goal cycle, loop run, wake-up turn, job batch, webhook or jazz run failed with nobody watching |
spend-cap | A spend cap refused an unattended run; once per cap and window |
reminder | A reminder fired on a host where no desktop notification can be shown (a server, a system service) |
workflow-result | A workflow run finished. Only the targets its deliver: field names receive it |
A target gets every event except workflow results unless events narrows it. A workflow’s
deliver: targets also get its failures and approval requests.
Targets
{
"notify": {
"targets": [
{ "name": "desktop", "kind": "desktop", "events": ["waiting", "paused"] },
{ "name": "phone", "kind": "telegram", "chatId": "123456789", "approveFromChat": true },
{ "name": "push", "kind": "ntfy", "url": "https://ntfy.sh/my-private-jazz-topic" },
{ "name": "team", "kind": "discord", "events": ["unattended-failed", "spend-cap"] },
{ "name": "ops", "kind": "webhook", "url": "https://hooks.example.com/jazz" },
],
},
}
Unset, notify.targets is one desktop target. Once you set it, the list is exactly what you
get: include a desktop target to keep desktop notifications. notifications.enabled: false
drops desktop targets.
Desktop notifications
A desktop notification is shown one of two ways, never both.
In the terminal. When Jazz runs in a terminal that shows notifications itself, it writes the notification to that terminal as an escape sequence, and the terminal shows it. This works while the fullscreen interface is up.
| Terminal | Detected from | Sequence |
|---|---|---|
| kitty | KITTY_WINDOW_ID, or TERM=xterm-kitty | OSC 99 |
| Ghostty | TERM_PROGRAM=ghostty | OSC 777 |
| WezTerm | TERM_PROGRAM=WezTerm | OSC 777 |
| Warp | TERM_PROGRAM=WarpTerminal | OSC 777 |
| iTerm2 | TERM_PROGRAM=iTerm.app, or LC_TERMINAL=iTerm2 | OSC 9 |
The terminal sends nothing back, so a notification counts as delivered once it was written to
the terminal. Whether it appears then depends on the terminal and the OS allowing it (on macOS,
System Settings, Notifications, then your terminal app). Titles and messages are limited in
length and lose control characters; OSC 777 and OSC 9 also lose ;, which separates their
fields.
With the system notifier everywhere else: in any other terminal (Apple Terminal, GNOME
Terminal and other VTE terminals, VS Code), and in every process with no terminal, such as
jazz daemon, scheduled workflows and the chat bridges. On macOS that is terminal-notifier.
The release binary bundles it; a Jazz installed another way uses JAZZ_TERMINAL_NOTIFIER, then
one on PATH or in Homebrew (brew install terminal-notifier). On Linux it is notify-send
(from libnotify), and it needs a desktop session (DISPLAY, WAYLAND_DISPLAY or
DBUS_SESSION_BUS_ADDRESS).
notifications.terminal chooses the terminal sequence, and JAZZ_NOTIFICATIONS_TERMINAL wins
over it:
| Value | Effect |
|---|---|
auto (default) | Detect the terminal as in the table above. |
osc99 | Always send kitty’s sequence. |
osc777 | Always send the sequence Ghostty, WezTerm and Warp show. |
osc9 | Always send iTerm2’s sequence. |
off | Always use the system notifier. |
jazz config set notifications.terminal osc777
- Over SSH the remote shell usually has no
TERM_PROGRAM, soautofinds nothing (iTerm2’sLC_TERMINALis often forwarded, and is detected). Setnotifications.terminalon the remote machine: the sequence travels back over SSH and your laptop’s terminal shows it. - In tmux the sequence is wrapped so tmux forwards it, which tmux does only with
set -g allow-passthrough onin~/.tmux.conf. tmux replacesTERM_PROGRAMwithtmux, so inside tmuxautodetects kitty and iTerm2 only; setnotifications.terminalfor the others. - In GNU screen the system notifier is always used: screen does not forward these sequences.
notifications.enabled: false turns off both.
On a host that can show a desktop notification neither way, such as a server or a CI runner:
- nothing is queued for the default desktop target (the one used while
notify.targetsis unset), andjazz notifysays why; - a
desktoptarget you configured yourself still receives events; each one that cannot be shown is reported once on stderr with what to install, then dropped instead of retried; jazz notify test <name>reports the same reason;- a reminder goes to the notify targets that take reminders instead.
Delivery and retries
Every notification is written to an outbox under $JAZZ_HOME/notify/outbox before anything
sends it. The daemon delivers it on its next tick; a jazz run or scheduled workflow delivers
what it queued before it exits, so a host without a daemon still delivers.
A send that fails because the target is down, rate-limited (429) or erroring (5xx) is kept with its error and retried: one minute, then doubling up to an hour, five attempts in all. A refusal (a wrong token, a chat the bot cannot post in) stops retrying at once and stays visible. A desktop notification that cannot be shown is dropped (see Desktop notifications).
An event sent once per key (a waiting item, a spend cap per window) counts as sent on a target only once it is in that target’s outbox. A target whose outbox is full is tried again with the same event on the next tick; the targets that already have it do not get it twice.
jazz notify outbox # what is waiting, the last error, the next retry
jazz notify retry # re-arm the given-up ones and send everything now
Webhook bodies and signatures
A webhook target POSTs JSON: { id, type, title, body, event }, where id is the delivery
id, type is the event kind, title and body are the rendered text, and event is the event
itself, with kind naming it:
{
"id": "m1abc-x7k2q9",
"type": "waiting",
"title": "A run wants to use execute_command",
"body": "About to run execute_command\njazz runs approve run-a (or reject)",
"event": {
"kind": "waiting",
"item": {
"key": "run:run-a:call-1",
"kind": "approval",
"title": "A run wants to use execute_command",
"detail": "About to run execute_command",
"since": "2026-09-28T09:00:00.000Z",
"runId": "run-a",
"agentId": "agent-1",
},
},
}
event.kind | Fields besides kind |
|---|---|
waiting | item: key, kind, title, detail, since, and runId, goalId or loopId |
paused | pause (kind, at, and for a daily cap limit and until), reason |
unattended-failed | source, error, optional name, agentId, runId |
spend-cap | source, agentId, message, optional name |
reminder | agentId, text, fireAt (epoch ms) |
workflow-result | workflow, agentId, answer |
Every delivery carries:
| Header | Value |
|---|---|
X-Jazz-Event | the event kind |
X-Jazz-Delivery | a delivery id; the same on a retry, so dedupe on it |
X-Jazz-Timestamp | Unix seconds when it was sent |
X-Jazz-Signature-256 | with a secret: sha256= hex HMAC-SHA256 of <timestamp>.<raw body> |
With a secret, recompute the HMAC, compare in constant time, and reject timestamps more than a few minutes from your clock. Without one, anyone who learns the URL can post look-alike events, so treat unsigned bodies as unverified.
Approving from chat
When a Telegram or Discord target points at a chat that a running Jazz bridge serves, set
approveFromChat: true. Approval requests then end with /approve <runId> and
/deny <runId> [why], and the bridge answers them:
- Only the bridge’s operators are heard: user ids in
TELEGRAM_OPERATOR_IDSorDISCORD_OPERATOR_IDS. With none listed, nobody can answer. - The bridge needs
JAZZ_APPROVALS_HOMEset to the Jazz home whose parked runs it answers (usually the operator’s~/.jazz). Unset, the commands answer that approving from chat is off. - The answer runs
jazz runs approveorjazz runs rejectas the bridge’s own process, outside any conversation sandbox. That command refuses when an agent’s tool started it, so an agent cannot approve its own run.
A question (rather than a tool approval) is answered with jazz runs answer <runId> --response.
A secret the run asked for is never answered from chat: type it in a terminal with
jazz runs secret <runId>.
Related
- Budgets: per-run caps and daily and monthly caps
- Daemon: what waits on you, pausing
- Deferred work: reminders, wake triggers and at-least-once delivery
- Approvals: parking and answering runs
- Workflow frontmatter: the
deliver:field