docs blog library
github

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
KindFieldsSecret (keyring)
desktopnonenone
ntfyurl (a topic URL)none
webhookurlsecret, optional: signs bodies
telegramchatId, optional apiBaseUrl, approveFromChatbotToken
discordoptional channelId (with a bot), apiBaseUrl, approveFromChatwebhookUrl, 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

EventWhen
waitingSomething 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
pausedThe daemon paused its own work at a daily cap
unattended-failedA workflow, goal cycle, loop run, wake-up turn, job batch, webhook or jazz run failed with nobody watching
spend-capA spend cap refused an unattended run; once per cap and window
reminderA reminder fired on a host where no desktop notification can be shown (a server, a system service)
workflow-resultA 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.

TerminalDetected fromSequence
kittyKITTY_WINDOW_ID, or TERM=xterm-kittyOSC 99
GhosttyTERM_PROGRAM=ghosttyOSC 777
WezTermTERM_PROGRAM=WezTermOSC 777
WarpTERM_PROGRAM=WarpTerminalOSC 777
iTerm2TERM_PROGRAM=iTerm.app, or LC_TERMINAL=iTerm2OSC 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:

ValueEffect
auto (default)Detect the terminal as in the table above.
osc99Always send kitty’s sequence.
osc777Always send the sequence Ghostty, WezTerm and Warp show.
osc9Always send iTerm2’s sequence.
offAlways use the system notifier.
jazz config set notifications.terminal osc777
  • Over SSH the remote shell usually has no TERM_PROGRAM, so auto finds nothing (iTerm2’s LC_TERMINAL is often forwarded, and is detected). Set notifications.terminal on 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 on in ~/.tmux.conf. tmux replaces TERM_PROGRAM with tmux, so inside tmux auto detects kitty and iTerm2 only; set notifications.terminal for 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.targets is unset), and jazz notify says why;
  • a desktop target 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.kindFields besides kind
waitingitem: key, kind, title, detail, since, and runId, goalId or loopId
pausedpause (kind, at, and for a daily cap limit and until), reason
unattended-failedsource, error, optional name, agentId, runId
spend-capsource, agentId, message, optional name
reminderagentId, text, fireAt (epoch ms)
workflow-resultworkflow, agentId, answer

Every delivery carries:

HeaderValue
X-Jazz-Eventthe event kind
X-Jazz-Deliverya delivery id; the same on a retry, so dedupe on it
X-Jazz-TimestampUnix seconds when it was sent
X-Jazz-Signature-256with 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_IDS or DISCORD_OPERATOR_IDS. With none listed, nobody can answer.
  • The bridge needs JAZZ_APPROVALS_HOME set 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 approve or jazz runs reject as 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>.

machine-readable: /docs/configure/notifications.md · /llms.txt · /llms-full.txt