notify-cli
Send one notification to every place you want it — gotify, Discord — in a single command.
Written against notify version 0.2.3.
What it is
notify is a command-line tool that pushes one message out to several destinations at once —
today that means a gotify server and a Discord webhook. You hand it a
message, it delivers that message to every destination in a named group, prints one line per
destination, and exits with a code your script can branch on. It's for the moments when
something finishes or breaks while you're not watching: the end of a backup job, a deploy, an
overnight cron, a build that takes twenty minutes.
It's deliberately single-purpose and strictly non-interactive — it never prompts, never opens a window, never asks a question. One invocation delivers one message and gets out of the way.
Status
These all work today:
- Two destination types —
gotify(via an application token) anddiscord(via a webhook URL). - Profiles — named, ordered groups of destinations.
notify send "..."uses the profile calleddefault;-P <name>picks another;-P '*'hits every configured destination. - Ad-hoc destinations —
-T <name>sends to exactly the destinations you name and ignores profiles entirely. - Message shaping — a title (
-t), a gotify priority (-p), and a list of links (-u) appended to the body as markdown. - stdin — pass
-as the message and the body is read from the pipe. - Inspection —
notify config path,notify config edit,notify targets list,notify profiles list. - Exit codes you can branch on —
0everything delivered,1something failed,2the config or the destination you asked for is wrong.
[not-yet-implemented] — the following are designed but not built. Until they are, the
config file is hand-edited and the output is plain text:
- Commands to change config from the CLI — adding or removing targets, setting profiles. For
now,
notify config editopens the file and you edit it yourself. --jsonstructured output forsendand thelistcommands.- Retries or backoff on a failed delivery. A failure is reported, not re-attempted.
- A generic
webhookdestination type (your own method, URL, headers and body template). - Per-destination HTTP timeout tuning — every request currently uses the same fixed 30-second timeout.
- Arbitrary nested gotify
extras. Onlypriorityis honoured.
Setup
1. Install it. notify ships as a .NET global tool, so you'll need the .NET 10 SDK on the
machine, then:
dotnet tool install --global grdev.notify-cli
The package is grdev.notify-cli; the command it installs is notify. Check it landed on your
PATH with notify --help, and notify version will print the exact build, commit and build
time you're running.
2. Find your config file. Everything notify knows about lives in one JSON file:
| Platform | Location |
|---|---|
| Windows | %USERPROFILE%\.grdev.notify-cli\config.json |
| Linux / macOS | ~/.grdev.notify-cli/config.json |
You never have to remember that — ask the tool:
notify config path
C:\Users\you\.grdev.notify-cli\config.json
Any command that reads config also takes -c/--config <path>, if you'd rather keep the file
somewhere else or want a second one for a different set of destinations.
3. Create it. notify config edit opens the file in your editor, writing a
fill-in-the-blanks skeleton first if it doesn't exist yet:
notify config edit
The editor it launches is $VISUAL, then $EDITOR, falling back to notepad on Windows and
vi everywhere else. One catch: that variable has to be a bare executable path with no
arguments. code works, /usr/bin/vi works, notepad works — code --wait does not,
because the whole string is treated as the name of the program.
What it writes is a template, not a working config — one blank target and an empty default
profile. It won't send anything until you fill it in.
4. Fill it in. The file has exactly two top-level keys. targets is the list of places a
message can go; profiles maps a name to an ordered list of those targets. Here's a complete
file with one gotify target, one Discord target, and two profiles:
{
"targets": [
{
"name": "homelab-gotify",
"type": "gotify",
"url": "https://gotify.example",
"token": "AbCdEf0123456789",
"defaultTitle": "Workflow Notification",
"extras": {
"priority": "5"
}
},
{
"name": "team-discord",
"type": "discord",
"url": "https://discord.com/api/webhooks/123456789/AbCdEf-token",
"extras": {
"username": "notify"
}
}
],
"profiles": {
"default": ["homelab-gotify"],
"alerts": ["homelab-gotify", "team-discord"]
}
}
The target fields:
| Field | Required | Meaning |
|---|---|---|
name |
yes | Unique name, used by profiles and by -T. It cannot be *. |
type |
yes | gotify or discord. |
url |
yes | For gotify, the base server URL. For Discord, the full webhook URL. Must be an absolute http(s) URL. |
token |
gotify only | The gotify application token. Discord doesn't use it — its auth is already in the webhook URL. |
defaultTitle |
no | Title used when you don't pass --title. If neither is set, no title is sent. |
extras |
no | Flat map of strings for provider-specific knobs. Unknown keys are ignored with a warning. |
What extras accepts:
| Type | Key | Meaning |
|---|---|---|
| gotify | priority |
Default priority, an integer 0–10 written as a string. Resolved as --priority, then extras.priority, then 5. |
| discord | username |
Overrides the webhook's display name. |
| discord | avatar_url |
Overrides the webhook's avatar. |
A few rules worth knowing before you save. The profile named default is what a bare
notify send "msg" uses — without it, a bare send stops with exit code 2 and tells you so.
The profile * is special and never written to the file: it always means "every configured
target" and is there automatically. Naming a target or a profile * yourself is an error. So
is a profile that lists a target which doesn't exist, or one that lists nothing at all. notify
validates the whole file at once and reports every problem it finds in one go, rather than
stopping at the first.
5. Check your work. Both listing commands are read-only, so they're safe to run any time:
notify targets list
homelab-gotify gotify https://gotify.example
team-discord discord https://discord.com/api/webhooks/123456789/AbCdEf-token
notify profiles list
default: homelab-gotify
alerts: homelab-gotify, team-discord
If either one prints errors and exits 2 instead, your config isn't valid yet — fix what it
names and run it again.
Using it
Send to the default profile
The shortest useful command. No flags, no ceremony — the message goes to every target listed in
your default profile:
notify send "Backup finished"
You get one line per target on stdout, in the order the profile lists them. The shape is
<target> then two spaces then ok, so a one-target default profile prints a single line —
illustrative, since a successful send needs your own server:
homelab-gotify ok
Send to a named profile, or to everything
notify send "Deploy to production finished" -P alerts
-P/--profile picks any profile from your config. The built-in * profile means every target
you've configured, whatever your profiles say:
notify send "Heads up, everyone" -P '*'
Quote the * — otherwise your shell may try to expand it into filenames before notify ever
sees it.
Send to specific targets, ignoring profiles
When you want one particular destination and don't want to add a profile for it:
notify send "quick ping" -T team-discord
-T/--target is repeatable, so -T homelab-gotify -T team-discord sends to both. A target
named twice is only sent to once. -P and -T are mutually exclusive — passing both is an
error:
Pass either --profile or --target, not both.
An unknown name gets the same treatment (Unknown profile 'nope'.), and both exit 2, so a
typo fails loudly instead of quietly sending nowhere.
Add a title and a priority
notify send "Nightly backup finished" -t "Backup" -p 8 -P alerts
-t/--title overrides the target's defaultTitle; with neither set, no title is sent. On
gotify the title is a real title field; Discord has no such thing, so the title is prepended to
the message as a bold first line.
-p/--priority is a gotify concept — an integer from 0 to 10. Discord has no priority, so a
Discord target warns once and carries on. A non-integer priority stops the command:
homelab-gotify: priority 'high' is not an integer.
Attach links
-u/--urls appends links to the message. The body becomes your text, a ---- rule, then each
URL as a markdown link, and the payload is marked as markdown:
notify send "Build failed" --urls https://ci.example/run/42 -P alerts
It takes several URLs per token and is repeatable, so this works too:
notify send "Build failed" -u https://ci.example/run/42 https://ci.example/log/42
Put the message before --urls. Both --urls and --target swallow every bare word that
follows them, so a message written after --urls is read as another URL and the command fails
with "Required argument missing". If you really want to lead with --urls, put every other
option before it and terminate the list with --:
notify send -P alerts --urls https://ci.example/run/42 -- "Build failed"
Pipe the body in
Pass - as the message and notify reads the body from stdin. This is the one and only thing
it ever reads from a pipe:
journalctl -u backup.service | tail -20 | notify send - -t "Backup log" -P alerts
On Windows:
Get-Content .\build.log -Tail 20 | notify send - -t "Build log" -P alerts
Keep the noise down
--quiet suppresses the per-target ok lines. Failures and warnings still print — that's the
point:
notify send "Backup finished" --quiet
What it prints, and what it returns
Targets are attempted concurrently, but output is always printed in resolved order, so a log is reproducible. Three kinds of line:
<target> okon stdout, per delivered target (hidden by--quiet).<target> FAILED: <reason>on stderr, per failure — always shown.<target> warning: <text>on stderr, for things like an ignored--priorityon Discord or an unrecognisedextraskey.
A failure that can't reach the server looks like this:
homelab-gotify FAILED: No such host is known. (gotify.example:443)
And the exit codes, which are the part your scripts should care about:
| Code | Meaning |
|---|---|
0 |
Every target accepted the message. |
1 |
At least one target failed, or --priority wasn't an integer for a gotify target. |
2 |
Config missing or invalid; no default profile for a bare send; unknown profile or target; -P and -T passed together. |
Use cases
The end of a backup script
The classic. Your backup already knows whether it worked — notify just tells you, so you find
out tonight instead of next Tuesday when you need the restore.
restic backup /srv && notify send "Backup OK" || notify send "Backup FAILED" -p 9 -P alerts
A CI step that pings the team
CI already emails somebody who stopped reading the emails months ago. One line at the end of a job puts the result in the channel where people actually are, with the run linked:
notify send "Deploy to production finished" -t "$CI_PROJECT" --urls "$CI_PIPELINE_URL" -P alerts
A homelab cron job or watchdog
Anything on a timer that nobody is watching — certificate renewal, a disk check, a sync — is a good fit, especially with the body piped in so the notification carries the actual output:
df -h / | notify send - -t "Disk check" -p 7
A long-running local task
Same idea, closer to home: put notify at the end of a long build, a big test run, or an agent
workflow, and go do something else until your phone buzzes.
dotnet test; notify send "Test run finished with exit $?" -T homelab-gotify
Links
- NuGet: grdev.notify-cli
- Source: github.com/greek-developer/notify-cli