TownCrier is a Windows desktop application that lives in the system tray and displays Windows 11 toast notifications based on configurable rules. It has no main window — only a tray icon and toast notifications.
Notifications can be triggered by cron timers, process output, file contents, HTTP
responses, or combinations thereof. Text from triggers is parsed with JSONPath, XPath,
and/or regex to extract variables, which feed into Scriban templates for the toast title,
body, and button labels. The tray app lives in TownCrier.exe, while the console CLI lives
in tc.exe so command-line usage works cleanly without opening a GUI.
Requirements
- Windows 10 (build 19041) or later
- .NET 10 SDK
Quick start
Build everything first:
dotnet build
Start the tray application (TownCrier.exe) with the example configuration:
dotnet run --project src/TownCrier -- --config example.toml
This starts TownCrier.exe. A tray icon appears in the notification area with no console
window. Right-click the tray icon for options (reload config, list rules, exit).
In a separate terminal, start the console CLI (tc.exe) like this:
dotnet run --project src/TownCrier.Cli -- --help
Or run an actual command against the tray app:
dotnet run --project src/TownCrier.Cli -- list
dotnet run --project src/TownCrier.Cli -- reload
CLI usage
When TownCrier.exe is running, you can control it from another terminal with tc.exe:
tc list # list active rules and their status
tc notify --title "Hi" --body "Hello world" # show a toast
tc reload # reload the TOML config
tc disable <rule-id> # disable a rule
tc enable <rule-id> # re-enable a rule
tc stop # shut down the running instance
In development, the equivalent dotnet run commands are:
dotnet run --project src/TownCrier.Cli -- list
dotnet run --project src/TownCrier.Cli -- notify --title "Hi" --body "Hello world"
dotnet run --project src/TownCrier.Cli -- stop
All tc commands communicate with the running TownCrier.exe instance over a named pipe.
If tc list cannot reach a running instance, it falls back to reading the configured
TOML file directly and shows rule status from config only. Other commands, such as
tc reload and tc stop, return an error if TownCrier.exe is not running.
Options
| Option | Description |
|---|---|
--config <path> |
Path to TOML config file (default: towncrier.toml) |
--help |
Show help |
--version |
Show version information |
Configuration
TownCrier is configured with a single TOML file. The file contains global settings and one or more rules.
Settings
[settings]
config_dir = "." # base directory for relative paths
db_path = "towncrier.db" # SQLite database for rule state
log_level = "Information" # Trace, Debug, Information, Warning, Error, Critical
Rules
Each rule has an id, one or more triggers, optional variable extraction, an optional
condition, and a toast definition.
[[rule]]
id = "check-api-health"
enabled = true
Triggers
A rule can have multiple triggers. The engine evaluates all triggers on each tick (every 15 seconds).
Timer
Fires when the current time passes the next cron occurrence. Uses standard 5-field cron expressions (minute, hour, day-of-month, month, day-of-week). Cron schedules are interpreted in the local system time zone and automatically follow daylight saving time transitions.
[[rule.trigger]]
type = "timer"
cron = "*/5 * * * *" # every 5 minutes
Process
Runs a command and captures its stdout, stderr, and exit code.
[[rule.trigger]]
type = "process"
command = "powershell"
args = ["-Command", "Get-Counter '\\LogicalDisk(C:)\\% Free Space'"]
Output fields: process.stdout, process.stderr, process.exitcode
File
Reads the full content of a file.
[[rule.trigger]]
type = "file"
path = "C:/logs/app.log"
Output fields: file.text
HTTP
Makes an HTTP request and captures the response.
[[rule.trigger]]
type = "http"
url = "https://api.example.com/health"
method = "GET" # GET, POST, PUT, DELETE, etc.
headers = { Authorization = "Bearer {{ env.API_TOKEN }}" }
timeout = 10 # seconds
Output fields: http.body, http.status, http.headers.*
Variable extraction
Variables are extracted from trigger output using JSONPath, XPath, or regex.
[[rule.variable]]
name = "status"
from = "http.body" # which trigger output field to read
jsonpath = "$.status" # JSONPath expression
[[rule.variable]]
name = "free_gb"
from = "process.stdout"
regex = 'free=(\d+)' # regex with capture group
capture = 1 # which capture group (default: 1)
default = "unknown" # fallback if extraction fails
[[rule.variable]]
name = "server"
from = "http.body"
xpath = "//server/@name" # XPath expression
Note: use TOML literal strings (single quotes) for regex patterns containing
backslashes, so that \d, \s, etc. are passed through as-is.
Conditions
An optional Scriban expression that determines whether the toast should be shown. The expression is evaluated as a template — if it renders to a truthy value, the toast fires.
[rule.condition]
expression = '{{ status != "healthy" }}'
Toast
The toast definition uses Scriban templates for all text fields.
[rule.toast]
title = "API {{ status }}"
body = "Server {{ server }} reports status: {{ status }}"
scenario = "default" # default, reminder, alarm, incoming_call
tag = "api-health" # for toast replacement (max 16 chars)
group = "monitoring" # for toast grouping (max 16 chars)
You can also attach an action to clicking the toast body itself:
[rule.toast.action]
action = "open_url"
url = "https://dashboard.example.com/{{ rule.id }}"
foreground = true # default: false; set true for foreground activation
[rule.toast.action] supports the same action fields as buttons except label. Use it
when clicking the notification body should perform an action without requiring a custom
button. By default the action is invoked with background activation.
Buttons
Each toast can have up to 5 buttons. Each button has a label and an action.
[[rule.toast.button]]
label = "Open dashboard"
action = "open_url"
url = "https://dashboard.example.com/{{ rule.id }}"
foreground = true
[[rule.toast.button]]
label = "Disable this rule"
action = "disable_rule"
[[rule.toast.button]]
label = "Acknowledge"
action = "http"
url = "https://api.example.com/ack"
method = "POST"
headers = { Content-Type = "application/json" }
body = '{"rule": "{{ rule.id }}", "status": "{{ status }}"}'
[[rule.toast.button]]
label = "Show details"
action = "popup"
message = "Status: {{ status }}\nUptime: {{ uptime }}s"
[[rule.toast.button]]
label = "Dismiss"
action = "dismiss"
[[rule.toast.button]]
label = "Snooze"
action = "snooze"
Use action = "snooze" with scenario = "reminder" or scenario = "alarm" when you
want Windows to show its native snooze behaviour. The current implementation uses the
minimal system snooze button with the default Windows snooze interval.
| Action | Behaviour |
|---|---|
disable_rule |
Disables the current rule in the state database |
exit_app |
Gracefully shuts down TownCrier |
popup |
Shows a MessageBox with templated text |
exec |
Runs a command via the shell |
open_url |
Opens a templated URL in the default browser |
http |
Makes an HTTP request with templated URL/headers/body |
dismiss |
System dismiss (closes the notification) |
snooze |
Native Windows snooze button for reminder/alarm toasts |
Toast body activation uses the same action types. Windows does not expose whether the
activation came from the popup itself or from Notification Center, so TownCrier treats
both clicks the same way. Buttons and toast.action both default to background
activation; set foreground = true when you explicitly want foreground activation.
Cooldown and deduplication
TownCrier automatically deduplicates consecutive identical toasts for the same rule (based on a content hash of title + body + scenario). Rule state — including enabled status, last trigger time, and content hashes — is persisted in a SQLite database so it survives restarts.
Full example
See example.toml for a working configuration with three rule types
(HTTP, process, file).
For developers
Project structure
TownCrier/
TownCrier.sln
Directory.Build.props # shared build settings
AGENTS.md # conventions for AI agents
TODO.md # implementation plan
example.toml # example configuration
src/
TownCrier/ # WPF tray executable (TownCrier.exe)
Program.cs # tray startup, DI, single-instance mutex, WPF startup
App.xaml / App.xaml.cs # tray events, toast activation routing
Services/
TrayIconService.cs # H.NotifyIcon system tray
HostedEngineService.cs # rule evaluation loop (BackgroundService)
HostedIpcService.cs # named-pipe IPC listener (BackgroundService)
PopupService.cs # WPF MessageBox wrapper
TownCrier.Cli/ # console executable (tc.exe)
Program.cs # command parsing, IPC client, offline list fallback
TownCrier.Core/ # class library (no WPF dependency)
Configuration/ # TOML config models + loader
Extraction/ # JSONPath, XPath, regex extractors
Templating/ # template engine wrapper
Triggers/ # timer, process, file, HTTP trigger adapters
Engine/ # rule evaluator, state, variable scope
Actions/ # button action executors
Notifications/ # toast builder, activation handler
Persistence/ # SQLite state store
Ipc/ # named-pipe protocol, server, client
tests/
TownCrier.Core.Tests/ # xUnit tests
Technology stack
| Layer | Package | Version |
|---|---|---|
| Runtime | .NET | 10.0 (TFM net10.0-windows10.0.19041.0) |
| UI framework | WPF | (no main window, tray-only) |
| System tray | H.NotifyIcon.Wpf | 2.4.1 |
| Toast notifications | Microsoft.Toolkit.Uwp.Notifications | 7.1.3 |
| Configuration | Tomlyn | 2.3.0 |
| Templating | Scriban | 7.0.6 |
| JSON extraction | Newtonsoft.Json | 13.0.4 |
| Process execution | CliWrap | 3.10.1 |
| Scheduling | Cronos | 0.11.1 |
| Hosting / DI | Microsoft.Extensions.Hosting | 10.0.0 |
| CLI parsing | System.CommandLine | 2.0.5 |
| Persistence | Microsoft.Data.Sqlite | 10.0.0 |
Building and testing
dotnet build TownCrier.sln
dotnet test TownCrier.sln
dotnet run --project src/TownCrier -- --config example.toml
dotnet run --project src/TownCrier.Cli -- --help
dotnet run --project src/TownCrier.Cli -- list
Avoid running dotnet build and dotnet test in parallel — PDB file locks can cause
build failures. Use --no-build on the test command after a successful build:
dotnet build TownCrier.sln && dotnet test TownCrier.sln --no-build
Architecture
tc.exe
Program.Main
parse command line
IpcClient --> send command over named pipe --> print response --> exit
offline list fallback --> read config directly --> print rule summary --> exit
TownCrier.exe
Program.Main (STA thread)
single-instance mutex --> start Generic Host
HostedEngineService (15s tick loop: evaluate rules, show toasts)
HostedIpcService (named-pipe listener: route CLI commands)
TrayIconService (H.NotifyIcon system tray with context menu)
ToastService (build + show Windows toast notifications)
StateStore (SQLite: rule state, cooldowns, dedup hashes)
RuleEvaluator
TriggerAdapters (timer / process / file / http)
ExtractionPipeline (JSONPath / XPath / regex)
TemplateEngine (title / body / button labels)
ActionExecutors (disable / exit / popup / exec / open_url / http / dismiss)
The WPF App.Run() owns the message loop on the main STA thread in TownCrier.exe.
The Generic Host runs background services on thread-pool threads. The IPC server
accepts one connection at a time and dispatches commands to the engine, toast service,
or state store. tc.exe remains a pure console client so shells wait for output and
return to the prompt only after the command completes.
Version control
This project uses Fossil, not Git.
fossil status # working directory status
fossil diff # show diffs
fossil addremove # stage new/deleted files
fossil commit -m "msg" # commit
fossil timeline # recent check-ins
AI agents must tag their commits:
fossil commit -m "message" --tag ai-model=<model-name> --no-warnings
Commit messages must be either:
- Short: a single concise line, or
- Long: a short summary line, then a blank line, then the detailed description.
The Fossil timeline is configured with timeline-truncate-at-blank and
timeline-hard-newlines, so the blank line separating the summary from the body
controls what appears in the timeline overview. Keep the first line short and
meaningful — the rest is only shown when viewing the full check-in.
Key conventions
- Config uses
rule.trigger(notrule.source). - Config reload is automatic (FileSystemWatcher) and can also be triggered
manually via the CLI
reloadcommand or the tray context menu. - The app is unpackaged (no MSIX). Toast registration uses the toolkit's built-in unpackaged desktop flow.
- LSP diagnostics in this project are frequently false positives. Always verify with
dotnet buildbefore acting on red squiggles. fossil addremovesometimes picks up spurious files (check-types.ps1,nul). Always review withfossil changesandfossil rmany unwanted files before committing.