TownCrier

TownCrier
Login

TownCrier

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

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:

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