Send a notification from your phone, run a task on your machine.
triggerd is a small, long-running daemon that listens for events from
configurable event sources and triggers allowlisted actions through
pluggable executors. The first supported source is
ntfy; the first supported executor triggers a task your
operating system already manages — a systemd unit on Linux, a launchd job on
macOS, a Task Scheduler task on Windows.
… and triggerd runs the "update-resume" entry from its allowlist.
- Why it exists
- Architecture
- Supported platforms
- Installation
- Configuration
- ntfy setup
- Creating the OS task
- Running triggerd
- Sending a test notification
- Security
- Concurrency, timeouts and reliability
- Development
- Running the tests
- Adding a new event source
- Adding a new executor
- Platform-specific behaviour
- Roadmap
Triggering a job on a machine you own usually means one of: exposing SSH, running a webhook server with its own TLS and auth, or wiring up a heavyweight automation platform. None of that is proportionate to "regenerate my resume when I tap a button on my phone".
triggerd takes the smallest useful shape:
- the machine makes an outbound connection to a pub/sub service, so no inbound port, firewall hole or public hostname is needed;
- the daemon can only run tasks that are named in its configuration file, so a hostile message is worth nothing beyond what you already allowed;
- the actual work lives in a normal OS service or scheduled task, so logs, restart policy, permissions and dependencies stay where your platform already handles them.
Everything else in the project exists to keep those three properties true while the number of sources and executors grows.
flowchart LR
Phone["Phone / laptop / CI"] -->|publish| Ntfy["ntfy topic"]
Ntfy -->|streaming HTTP| Source["EventSource\n(internal/sources/ntfy)"]
Source -->|domain.Event| Dispatcher["Dispatcher\n(worker pool, timeouts,\nconcurrency policy)"]
Dispatcher --> Decoder["EventDecoder\n(JSON protocol)"]
Decoder -->|ActionRequest| Registry["ActionRegistry\n(allowlist)"]
Registry -->|Action| Executor["ActionExecutor\n(os-task)"]
Executor --> Planner{"Platform planner"}
Planner -->|linux| Systemd["systemctl start x.service"]
Planner -->|darwin| Launchd["launchctl kickstart gui/501/x"]
Planner -->|windows| Schtasks["schtasks /Run /TN x"]
The core pipeline is fixed:
Event → decode → resolve → execute
What varies sits behind interfaces at the two ends:
| Interface | Defined in | Varies because |
|---|---|---|
domain.EventSource | internal/domain | ntfy today; webhook, MQTT, stdin, cron later |
domain.EventDecoder | internal/domain | the wire protocol is not a source's business |
domain.ActionRegistry | internal/domain | the allowlist is configuration, not code |
domain.ActionExecutor | internal/domain | os-task today; process, HTTP, Docker later |
ostask.Planner | .../ostask | systemd vs launchd vs Task Scheduler |
cmdrunner.Runner | internal/cmdrunner | tests must not need a real service manager |
Dependencies point inwards. internal/domain imports nothing but the standard
library — no ntfy, no systemd, no YAML, no HTTP.
cmd/triggerd/ process entry point (10 lines)
internal/
domain/ Event, ActionRequest, Action, Execution, ports, errors
decoder/ JSON payload -> ActionRequest
registry/ action allowlist and executor lookup
dispatcher/ worker pool, per-action locking, timeouts, shutdown
sources/ntfy/ ntfy subscription: streaming, auth, reconnect, backoff
executors/ostask/ cross-platform OS task executor and its planners
executors/dryrun/ decorator that plans but never runs
cmdrunner/ process execution behind an interface (+ a fake)
config/ YAML schema, env expansion, validation
logging/ slog setup
app/ wiring and lifecycle
cli/ flags, subcommands, signal handling
version/ build metadata
testsupport/ shared test doubles
configs/example.yaml a working, commented configuration
docs/ architecture, configuration, platforms, security, testing
deploy/ ready-to-edit unit files, plists and PowerShell
More detail: docs/architecture.md.
| Platform | Executor mechanism | Tool | Elevation |
|---|---|---|---|
| Linux | systemd unit | systemctl | none for --user units; root/polkit for system units |
| macOS | launchd job | launchctl | none for gui/user domains; root for system |
| Windows | Task Scheduler task | schtasks | none for tasks the current user owns |
Any other GOOS builds and runs, but os-task actions fail with
unsupported platform instead of guessing.
go install github.com/itsyourap/triggerd/cmd/triggerd@latestgit clone https://github.com/itsyourap/triggerd
cd triggerd
go build -o triggerd ./cmd/triggerd # add .exe on WindowsEvery tagged release ships binaries for linux/amd64, linux/arm64,
darwin/amd64, darwin/arm64, windows/amd64 and windows/arm64, plus a
SHA256SUMS file:
tag=v1.0.0
curl -fsSLO "https://github.com/itsyourap/triggerd/releases/download/$tag/triggerd_${tag}_linux_amd64"
curl -fsSLO "https://github.com/itsyourap/triggerd/releases/download/$tag/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS
install -m 0755 "triggerd_${tag}_linux_amd64" /usr/local/bin/triggerdThe same binaries are attached to every CI run as workflow artifacts if you need an untagged build.
Push a v* tag. Once the tests, the linter and all six cross-compiles pass,
CI creates the GitHub release, generates its notes from the commit history and
uploads the binaries and checksums:
git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0A tag containing a hyphen (v1.0.0-rc.1) is published as a pre-release and
does not become "Latest". The tag name is also compiled into the binary, so
triggerd version reports it.
Start from configs/example.yaml. The short version:
log:
level: info # debug | info | warn | errorformat: text # text | jsondispatcher:
workers: 4# maximum actions running at oncequeue_size: 64# requests that may wait for a workerdefault_timeout: 5mshutdown_grace: 30ssources:
- name: phone # appears on every event this source producestype: ntfyntfy:
server: "https://ntfy.sh"topic: "run-service"token_env: NTFY_TOKEN # optional; reads the token from the environmentactions:
update-resume:
executor: os-tasktarget: "update-resume"# systemd unit / launchd label / task nametimeout: 5mallow_concurrent: falseRules worth knowing:
- Unknown keys are errors. A typo fails at startup instead of silently doing nothing.
- Durations are strings:
30s,5m,1h30m. ${VAR}and${VAR:-default}are expanded everywhere in the file (including comments); an unset variable without a default is a startup error.$${is a literal${. For secrets prefertoken_env:/password_env:, which read the value directly and never touch the YAML.- Every problem is reported at once, so one edit can fix them all.
Check a file without starting anything:
triggerd validate-config --config configs/example.yamlconfiguration configs/example.yaml is valid
log: level=info format=text
dispatcher: workers=4 queue_size=64 default_timeout=5m0s shutdown_grace=30s
sources:
- phone (ntfy, enabled) server=https://ntfy.sh topic=run-service auth=none
actions:
- update-resume: executor=os-task target=update-resume timeout=5m0s allow_concurrent=false
planned commands on this host:
- update-resume: schtasks /Run /TN update-resume
Full reference: docs/configuration.md.
Pick a topic name that is long and unguessable — on the public
ntfy.shserver, a topic name is the only thing protecting it:run-service-8Fj2kQn7cZSubscribe to the same topic in the ntfy app on your phone (optional, but handy for publishing).
For anything beyond personal use, run your own ntfy server or enable access control and give triggerd a read-only token:
ntfy: server: "https://ntfy.example.com"topic: "run-service"token_env: NTFY_TOKEN
NTFY_TOKEN=tk_… triggerd --config /etc/triggerd/config.yaml
Tokens and passwords are never written to logs, and validate-config reports
only whether credentials are present.
Ready-to-edit files live in deploy/. Summary:
Linux (systemd, user scope) — ~/.config/systemd/user/update-resume.service:
[Unit]Description=Update resume
[Service]Type=oneshot
ExecStart=%h/bin/update-resume.shsystemctl --user daemon-reload
systemctl --user start update-resume.service # verify it worksThen set options: { systemd.scope: user } on the action.
macOS (launchd) — ~/Library/LaunchAgents/com.example.update-resume.plist
with RunAtLoad=false, loaded via launchctl bootstrap gui/$UID …. Use the
label as the action target.
Windows (Task Scheduler):
schtasks /Create /TN "update-resume"/TR "powershell -File C:\scripts\update-resume.ps1"/SC ONCE /ST 00:00/FFull instructions, including how to run triggerd itself as a service on each
platform: docs/platforms.md.
triggerd # uses ./triggerd.yaml or $TRIGGERD_CONFIG
triggerd --config /etc/triggerd/config.yaml
triggerd --config config.yaml --dry-run --log-level debug
triggerd validate-config --config config.yaml
triggerd version
triggerd --helpDry-run mode runs the entire pipeline — subscribe, receive, decode, resolve, plan — and logs the command it would run without executing it:
level=INFO msg="dry run: action not executed" action.action=update-resume
would_execute="schtasks /Run /TN update-resume"
It is the fastest way to develop against a real topic from a laptop.
Exit codes: 0 clean shutdown, 1 runtime failure, 2 usage error,
3 configuration error (restarting will not help).
curl -d '{"service":"update-resume"}' https://ntfy.sh/run-service-8Fj2kQn7cZWith a token:
curl -H "Authorization: Bearer $NTFY_TOKEN" \
-d '{"service":"update-resume"}' \
https://ntfy.example.com/run-service-8Fj2kQn7cZFrom the ntfy Android/iOS app, send the same JSON as the message body.
The daemon logs each stage:
level=INFO msg="action started" action.action=update-resume status=started
level=INFO msg="action completed" action.action=update-resume status=completed duration=1.2s
triggerd is a remote execution system. It is built accordingly:
- Allowlist only. An event selects a name; the name must already exist in
actions:. Unknown names are logged and dropped. - No commands on the wire. The protocol has no field for a command,
target, executor or timeout.
{"command":"rm -rf /"}is rejected as an unknown field, and{"service":"rm -rf /"}fails action-name validation before any lookup happens. - No shell, ever. Executors build an argument list and use
exec.CommandContext. There is nosh -canywhere in the codebase. - No argument injection. Targets come only from configuration, and are
still rejected if they start with
-or/, contain quotes, control characters or (where the platform requires it) whitespace. - Params never reach the command line. The optional
paramsfield is carried to the executor, and theos-taskexecutor ignores it by design. - Secrets stay out of logs.
domain.Eventanddomain.ActionRequestimplementslog.LogValuerand deliberately omit payload bytes and param values; auth headers are set in exactly one function and never logged. - Bounded everything. Payload size, param count, name length, queue depth, worker count, captured command output and action runtime are all capped.
Details and threat model: docs/security.md.
- Bounded worker pool.
dispatcher.workersactions run at once;dispatcher.queue_sizemay wait. When the queue is full, new events are dropped and logged rather than buffered without limit — memory stays bounded and the operator sees it. - No overlapping runs by default. A second request for an action that is
already running is rejected (
allow_concurrent: trueopts out). This is a dispatcher policy, not executor behaviour, so it works for every executor. - Timeouts. Every execution gets
context.WithTimeoutfrom the action'stimeoutordispatcher.default_timeout. - Retries where they are safe. The ntfy source reconnects with exponential backoff and jitter, forever. Failed actions are never retried automatically: most real tasks are not idempotent.
- Graceful shutdown. SIGINT/SIGTERM cancels the root context; sources
stop; queued-but-unstarted requests are discarded; running actions get
shutdown_graceto finish before their context is cancelled; then the process exits. - Unrecoverable source failures stop the daemon (bad credentials, wrong URL) instead of leaving a process that is running but deaf. A service manager can restart it once the configuration is fixed.
- Duplicate suppression. The last 256 ntfy message ids are remembered, so a reconnect that replays messages cannot run an action twice.
The project is developed on Windows and tested on all three platforms; nothing
in the test suite assumes /bin/sh, systemctl, Unix paths or Unix signals.
go build ./...
go vet ./...
go test ./...
go test -race ./...
gofmt -l .One Windows-specific gotcha: PowerShell splits an unquoted
-coverprofile=coverage.out into two arguments, and go test then treats
.out as a package name. Quote the flag (go test "-coverprofile=coverage.out" ./...) or use bash. CI pins every matrix job to bash for the same reason.
Handy loop while developing:
go run ./cmd/triggerd validate-config --config configs/example.yaml
go run ./cmd/triggerd --config configs/example.yaml --dry-run --log-level debugA Makefile wraps the same commands (make test, make lint, make build,
make dist).
| Command | What it covers |
|---|---|
go test ./... | everything below except the integration tag |
go test -race ./... | the same, with the race detector |
go test -tags=integration ./... | additionally talks to the real service manager and a real ntfy server |
The suite is split deliberately:
Platform-independent unit tests — decoding, validation, registries, routing, executor selection, dispatcher concurrency, timeouts, cancellation, shutdown, configuration parsing and error handling. No OS service required.
Platform adapter tests — the systemd, launchd and Task Scheduler planners are ordinary functions compiled on every platform, so all three are tested from any host. They assert exact argument lists:
systemctl--userstartupdate-resume.servicelaunchctlkickstart-kgui/501/com.example.update-resumeschtasks/Run/TNupdate-resumeThe executor itself is driven through a fake cmdrunner.Runner, which covers
success, non-zero exit, missing tool, unknown target, cancellation, timeout
and stderr handling without touching the machine. A separate test asserts that
the planner selected by the build constraints matches runtime.GOOS, which
ties those tests back to real behaviour on each OS.
ntfy tests run against httptest servers that speak the real
newline-delimited JSON stream: subscription, keepalives, malformed frames,
reconnect, HTTP 500 retry, HTTP 401 giving up, auth headers, duplicate
suppression, resume-on-reconnect and the read-idle watchdog.
Integration tests (-tags=integration) are opt-in and documented in
docs/integration-tests.md.
CI runs build, vet, tests and -race on ubuntu-latest, macos-latest and
windows-latest, lints on Linux, and cross-compiles six targets.
Say you want an HTTP webhook. You touch two files plus the new package; the dispatcher, decoder, registries and executors are not modified.
Implement the interface:
// internal/sources/webhook/source.gotypeSourcestruct{ /* … */ } func (s*Source) Name() string { returns.name } func (s*Source) Run(ctx context.Context, outchan<- domain.Event) error { // serve until ctx is done; for each request:select { caseout<- domain.Event{ ID: requestID, Source: s.name, Type: domain.EventTypeMessage, Payload: body, // not interpreted hereMetadata: map[string]string{"http.remote": addr}, // source-specific, generic shape }: case<-ctx.Done(): returnctx.Err() } returnnil }
Add the schema: a
SourceTypeWebhookconstant, aWebhook *WebhookConfigblock onconfig.SourceConfig, and a case invalidateSources.Add a case to
buildSourcesininternal/app/app.go.
That is the whole extension point. Payload format, action names and execution
are unaffected, because a source's only job is to produce domain.Event.
Say you want to run a process directly.
Implement the interface:
// internal/executors/process/executor.goconstName="process"func (e*Executor) Name() string { returnName } func (e*Executor) Execute(ctx context.Context, ex domain.Execution) error { // use cmdrunner.Runner so the executor stays testable_, err:=e.runner.Run(ctx, ex.Action.Target, e.argsFrom(ex.Action.Options)...) returnerr }
Optionally implement
domain.ExecutionDescriber(gives you--dry-runandvalidate-configoutput for free) anddomain.ActionValidator(gives you startup validation for free).Register it in
buildExecutorsininternal/app/app.go.
Then executor: process works in configuration. ntfy, the decoder, the
dispatcher and the systemd/launchd/schtasks code are untouched.
| Behaviour | Linux | macOS | Windows |
|---|---|---|---|
| Command | systemctl [--user] <verb> <unit> | launchctl kickstart [-k] <domain>/<uid>/<label> or launchctl start <label> | schtasks /Run|/End /TN <name> |
| Target suffix | .service appended unless the target already names a unit type | none | none; \folder\task is supported |
| Options | systemd.scope, systemd.verb | launchd.domain, launchd.verb, launchd.restart | schtasks.verb |
| Blocking | systemctl start waits for a oneshot unit to finish | kickstart returns once the job is launched | /Run returns once the task is started |
| "Not found" detection | exit code 5 or a "not found" message | exit code 113 or "Could not find service" | "cannot find the file specified" (localised) |
Because launchd and Task Scheduler return as soon as the job starts, a
triggerd action succeeding means "the task was started", not "the task
finished". On Linux with Type=oneshot, it means the task finished. That
difference is inherent to the platforms, and is why the executor reports the
exit status of the trigger, not of your script.
Options that belong to another platform are ignored, so one configuration file can be deployed to Linux, macOS and Windows hosts unchanged.
Not implemented, but the architecture is shaped for it:
- Sources: HTTP webhook, Unix socket / named pipe, stdin, MQTT, cron, filesystem watch.
- Executors: direct process, HTTP request, Docker container.
- Policies: per-action rate limiting, an optional "queue instead of reject" concurrency policy, opt-in retries for actions declared idempotent.
- Observability: Prometheus metrics from the dispatcher counters that already exist, and a health endpoint.
- Protocol: signed payloads for use on shared topics.
MIT.