Greek Developer
| #cli #dotnet #automation #homelab

notify-cli

Send one notification to every place you want it — gotify, Discord — in a single command.

Written against notify version 0.2.8.

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 typesgotify (via an application token) and discord (via a webhook URL).
  • Profiles — named, ordered groups of destinations. notify send "..." uses the profile called default; -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.
  • Inspectionnotify config path, notify config edit, notify targets list, notify profiles list.
  • Exit codes you can branch on0 everything delivered, 1 something failed, 2 the 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 edit opens the file and you edit it yourself.
  • --json structured output for send and the list commands.
  • Retries or backoff on a failed delivery. A failure is reported, not re-attempted.
  • A generic webhook destination 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. Only priority is 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> ok on 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 --priority on Discord or an unrecognized extras key.

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