Rules — event automation for matterbox listen
The listen daemon holds a persistent WebSocket
connection to your Mattermost server and reacts to what happens on it. Rules
make that reaction programmable.
When something matches a set of conditions, the daemon runs one or more actions — forward it to Telegram, run a local command, POST a webhook, post a message back, add a reaction, mark the channel read, or just log it.
A rule reacts to a new message by default, but on:
widens that to edits, deletions and reactions — and to the clock, so “post the
standup prompt at 09:00 on weekdays” is a rule rather than a separate cron job
with its own copy of your config.
Rules also have memory: a frequency
window fires only on a burst (“three sev-1s in ten minutes”), and a
persistent ledger lets rules count and remember across
messages.
This is the kind of automation a server-side Mattermost plugin cannot give you: it is per-user, it runs on your machine, and it can run your commands. Think procmail or Sieve, but for chat.
Creating a rule, step by step
Rules live under a top-level rules: key in your config file:
~/.config/matterbox/config.yamlOn first run matterbox writes that file with documented defaults. If a
rules: key is not there yet, just add one.
The shape of a rule
A rule is a match (when it fires) plus an ordered list of
actions (what it does). The list under rules: is
evaluated top to bottom for every incoming message:
rules:
- name: my-first-rule # optional label, shown in the daemon log
match: # all conditions here must hold (AND)
channel: "Engineering"
message: "(?i)deploy"
actions: # run in order when the match passes
- type: log
text: "deploy mentioned"name is optional — it only labels log lines.
match and actions are the substance. An empty
match: matches every message: handy with a narrow action,
dangerous with notify.
Your first rule
Start with a log action — it is free, synchronous, and cannot
spam anyone, so it is the safest way to confirm a rule matches what you think
it does. This one logs whenever a teammate posts in a channel whose name
starts with “Ops”:
rules:
- name: watch-ops
match:
channel: "Ops*"
actions:
- type: log
text: "ops activity"Loading it, and checking what loaded
Rules are compiled and validated when they load, so tell the running daemon to re-read them after every edit. A reload swaps the ruleset in place, without dropping the connection or re-running catch-up:
# Linux (systemd --user)
$ systemctl --user reload matterbox-listen.service
$ journalctl --user -u matterbox-listen -f
# anywhere, by signal
$ pkill -HUP -f "matterbox listen"
# macOS (launchd)
$ launchctl kill -HUP gui/$(id -u)/com.matterbox.listen
$ tail -f ~/Library/Logs/matterbox-listen.log
# or just run it in the foreground while you iterate
$ matterbox listenA reload that fails to compile changes nothing: the daemon logs the error and
keeps running the rules it already had, so a typo cannot disarm a working
daemon. To see what is configured without touching the daemon at all, run
matterbox rules list.
The startup — and reload — line reports how many rules loaded:
matterbox listen: cache=… two_way=true … rules=1 configuredIf a glob, regexp or action type is wrong, the daemon refuses to start and tells you which rule and why, so a typo fails loud rather than silently never firing:
rules: rule "watch-ops": bad message regexp "(": error parsing regexp: …Watch it fire, then swap in a real action
You do not have to wait for someone to say the magic word — ask which rules a message would fire:
$ matterbox rules test -m "sev-1 in prod" --channel "Ops Alerts" --author bobNothing runs: it reports, per rule, whether it matches and — when it does not — which condition stopped it. See Testing a rule.
With the daemon log open, post a test message that should match. You will see
your log line appear. Once the match is doing what
you want, replace log with the action you actually need —
notify, exec, webhook,
react or mark_read (see
Actions):
rules:
- name: watch-ops
match:
channel: "Ops*"
message: "(?i)sev-1|pagerduty"
actions:
- type: exec
command: ["/home/me/bin/page-me.sh"]
- type: notify
summarize: false
stop: trueTesting exec and webhook safely
exec and webhook receive the message as a JSON
envelope (and exec also as MATTERBOX_* environment
variables). To see exactly what your script receives, point it at a throwaway
command first:
actions:
- type: exec
command: ["sh", "-c", "cat >> /tmp/matterbox-rule.log"]Post a matching message, then inspect /tmp/matterbox-rule.log to
see the envelope; the exec / webhook payload documents
every field. You can also exercise a rule against your own messages without
involving anyone else: run the daemon with --notify-self and post
in your self-DM.
Authoring tips
- Order matters. Rules fire top to bottom; put specific
rules first and use
stop: trueon a rule that should be the last word for a message. - Conditions are ANDed. Add fields to narrow, remove fields
to widen. Different fields are ANDed; within
channelorauthora list is ORed (channel: [ops, "eng-*"]matches either). For “everything except…”, nest anot:block. channelmatches the display name, notteam/channel— use the name as it appears in the sidebar (globbing with*and?), or paste an exact channel id. It accepts a single value or a list.teammatches the team's URL slug (thecorein/core/channels/…, the same slugmatterbox sendtakes), not the team's display name. Use it alone to match every channel in a team, or alongsidechannelto disambiguate a channel name shared across teams.- Use
(?i)at the start of amessageregexp for case-insensitive matching. - Iterate with
log, promote to real actions once the match is right. matterbox rules testbeats guessing. It runs the same matcher the daemon runs and names the first condition that failed.- A rule only reacts to what it lists. With no
on:, that is new messages — so a rule about a reaction or an edit needson:.
Working on rules: matterbox rules
Four verbs, all reading the same config the daemon reads and running the same matcher — so an answer here is the answer the daemon would give.
Testing a rule
matterbox rules test reports, for every rule, whether it
matches — and when it does not, the first condition that stopped it. Nothing
runs: no action is executed, nothing is posted, and the ledger and the rate
gates are only read.
$ matterbox rules test -m "sev-1 in prod" --channel "Ops Alerts" --author bob
probe: message at Tue 18 Aug 21:32 in "Ops Alerts" from @bob — "sev-1 in prod"
✓ pager → exec → notify [stop: later rules are skipped]
✗ desktop mention doesn't match
– nightly-digest reacts to schedule✓ fires, ✗ does not (with the condition that
failed), – is not listening for this kind of event, and
⏸ matches but is held back by a cooldown
or frequency gate.
Describe a hypothetical message with flags, or pass a real post's id — or the permalink the UI copies — to test against a message that actually exists, attachments and props included:
$ matterbox rules test -m "help" --dm --author alice
$ matterbox rules test 8x4k9y… # a real post
$ matterbox rules test 8x4k9y… --on reaction --emoji eyes --reactor bob
$ matterbox rules test -m "deploy" --at 03:00 # test a time window
$ matterbox rules test --on schedule # the timer rules--at moves the clock, which is how a time: window or
a weekday condition is checked without waiting for Tuesday.
Reloading
systemctl --user reload matterbox-listen.service — or
pkill -HUP -f "matterbox listen" — makes the daemon re-read the
config and swap its rules in place: no dropped connection, no re-run catch-up.
A config that fails to compile is reported and ignored; the
daemon keeps the rules it already had.
Is it firing at all?
$ matterbox rules stats
RULE FIRES LAST
pager 12 2026-08-18 09:12
desktop – neverCounters persist across restarts and count firings, not matches — a
rule held back by its cooldown or frequency window does not count. A dash next
to a rule you expect to be busy is the fastest sign its match is wrong;
rules test then says which condition.
The ledger
$ matterbox rules state # every key
$ matterbox rules state get zork:active
$ matterbox rules state set greeted today
$ matterbox rules state del zork:active # unwedge a stuck ruleThe ledger is the one piece of rule state with no other window onto it.
The notification bridge is just a rule
The daemon's original behaviour — “on a direct @mention or DM, summarise
the surrounding conversation and forward it to Telegram” — is no longer
special. When you have no rules: block
configured, the daemon synthesises one default rule from the
listen.* options that reproduces it exactly:
# The built-in default, written out as a rule. You don't need this in your
# config — it's what you get when `rules:` is empty — but it shows the shape.
rules:
- name: notify-mentions-and-dms
match:
mention: true # you were directly @named in a channel …
actions:
- type: notify # … so summarise + push to Telegram
- name: notify-dms
match:
dm: true # any direct message
actions:
- type: notifyThe moment you add your own rules:, the default is not
applied — your rules are the whole policy. So if you write custom rules and
still want mention notifications, include a notify rule yourself.
The notify action always honours the daemon's do-not-disturb
settings — respect_mutes, quiet_hours and
notify_dms — and the per-channel read check
(notify_delay_seconds), exactly as the built-in bridge does. A rule
that must page through quiet hours and mutes can opt out with
urgent: true; the self and DM gates still apply.
Catch-up after a reconnect
When the daemon (re)connects it sends a single “📥 While you were away” digest
of the mentions and DMs that arrived while it was offline. That digest is
filtered through your rules: a message is only included if a rule with a
notify action matches it, so narrowing or routing your
notifications narrows the catch-up too.
Two deliberate bounds. The catch-up only considers unread mentions and
DMs — the set the server lets the daemon query cheaply on reconnect,
not every unread post. And it only drives notify:
exec, webhook, react and the
state_* ledger actions are live-only and never
re-fire for historical messages, so a reconnect cannot replay side effects or
double-count a counter. The frequency
window is likewise live-only. The digest always goes to the default
telegram.chat_id; a per-rule chat_id applies to live
messages.
How rules are evaluated
For each event — system messages are always skipped — the daemon walks the
rules: list top to bottom, considering only the rules that react
to that kind of event. Every rule whose match passes runs
its actions. A matching rule with stop: true ends
evaluation: no later rule runs for that event.
Triggers (on)
A rule reacts to whatever its on: field lists. With no
on:, that is message — a new post — which is what
every rule did before this field existed.
on: |
Fires when |
|---|---|
message | A new post arrives. The default. |
edit | An existing post is edited. |
delete | A post is deleted. The post is the tombstone, so message still matches the text it had. |
reaction | Someone adds an emoji reaction to a post. |
reaction_removed | Someone removes one. |
schedule | The rule's own timer comes due. Needs a schedule: block; cannot be combined with the other kinds. |
A rule can list several: on: [message, edit] reacts to a post and
to later edits of it.
rules:
- name: someone-noticed
on: reaction # a single value or a list
match:
from_me: true # …reacted to *my* post
emoji: [eyes, white_check_mark]
actions:
- type: mark_readOn a reaction trigger the post-centric conditions keep their usual meaning:
author, from_me, channel and
message describe the post, and who reacted is
reactor. That way “somebody
acknowledged my message” is from_me: true — which is what you
actually want to match.
Scheduled rules
on: schedule is the trigger nothing in Mattermost causes. Set
exactly one of cron: or every::
rules:
- name: standup-prompt
on: schedule
schedule:
cron: "0 9 * * 1-5" # 09:00, Monday to Friday, local time
actions:
- type: send
channel: eng/standup # required: a timer has no channel of its own
text: "Standup in 15 minutes ⏰"
- name: hourly-sweep
on: schedule
schedule:
every: 1h # first fires an hour after the daemon starts
actions:
- type: exec
command: ["/home/me/bin/sweep.sh"]cron: is the ordinary five-field crontab grammar — minute, hour,
day of month, month, day of week — with *, lists
(1,15), ranges (1-5), steps (*/10,
9-17/2) and names (mon, jan). As in
crontab, when day-of-month and day-of-week are both restricted the rule
fires when either matches. Times are local.
every: measures from the last firing, which is remembered across
restarts — so a daemon that restarts twice an hour still runs a
24h rule once a day. The minimum is 1m.
Three things to know:
- A restart catches up nothing. A firing missed because the
daemon was down is skipped, never replayed at startup — a standup prompt four
hours late is worse than one that never came. A running daemon does
cover a late tick, though: if the machine suspends and the clock jumps (Go's
timers freeze with it), the tick after the resume still fires the minute it
slept through, as long as that was within the last five minutes. Only the most
recent match fires — waking from an hour's sleep with a
*/10rule owes you one recap, not six. - There is no post, so
notify,reactandmark_readhave nothing to act on and are rejected at compile time, andsendmust name achannel:. matchstill applies.stateconditions in particular let a scheduled digest hold its tongue until there is something to report.
matterbox rules list shows when each scheduled rule fires next.
Conditions (match)
Different fields are ANDed; an empty match matches every
message.
| Field | Meaning |
|---|---|
channel | Case-insensitive glob (*, ?) over the channel's display name, or an exact channel id. A single value or a list (matches any). |
team | Case-insensitive glob over the team's URL name (the slug in the channel URL, e.g. core), or an exact team id. A single value or a list. A DM carries no team, so a team condition never matches a direct message. |
author | Username (no leading @), matched case-insensitively. A single value or a list. |
message | RE2 regexp over the body plus any attachment text (see below). Prefix (?i) for case-insensitive. Its capture groups are available to templates — see Captures. |
mention | true requires you were directly @named (the same test the bridge uses). |
dm | true = only direct messages; false = only channels; unset = either. |
from_me | true = only your own posts; false = only others'; unset = either. from_me: false is how you keep a rule from firing on the messages you send — exec, webhook and react have no built-in self-skip the way notify and send do. |
has_file | true requires at least one attachment. |
from_bot | true = only posts from a bot or incoming webhook; false = only posts from people; unset = either. |
channel_type | public, private, dm, or group (a multi-person DM). A single value or a list. |
emoji | Reaction shortcode (no colons), as a case-insensitive glob or exact name. A single value or a list. Needs on: reaction (or reaction_removed). |
reactor | Username (no @) of whoever reacted. A single value or a list. Needs a reaction trigger. |
time | A window of the local clock and/or certain weekdays: after, before, days. See Office hours. |
is_thread | true = only thread replies; false = only root posts; unset = either. |
viewing | true = only when you are looking at the post's channel; false = only when you are not; unset = either. See Not while you're reading it. |
not | A nested match block that inverts: the rule fires only when the post does not satisfy it. Recursive. |
frequency | A rolling-window threshold: even when the fields match, fire only on a burst. See below. |
cooldown | A minimum interval: even when the fields match, fire at most once per period (every: 48h), then stay quiet. Persisted across restarts. The general form of “once a day/week”. See below. |
state | Match on the persistent ledger — e.g. failure_count is at least 3. |
channel and author as a list is an OR; combine with
not to subtract. For example, “anything in the ops channels except
from the bots”:
match:
channel: ["Ops*", "Incidents"]
not:
author: [deploybot, alertmanager]Messages that hide in attachments
Integrations — Jira, GitLab, alertmanager, most incoming webhooks — routinely
post with an empty body and put everything in a Slack-style
attachment. A message condition therefore matches the body
and the flattened attachment text (pretext, title, text, fields,
footer), and such a post reaches the rules even though its body is empty:
rules:
- name: failed-pipelines
match:
from_bot: true
message: "(?i)pipeline failed" # matches attachment text too
actions:
- type: notify
urgent: trueThe raw attachment text is also in the
exec/webhook payload as attachment_text,
separate from message.
Captures from message
The capture groups of a message regexp are available to every
template in the rule, so a command-style message can
carry its own arguments:
rules:
- name: deploy-command
match:
message: '^!deploy (?P<env>\w+)$'
actions:
- type: exec
command: ["/home/me/bin/deploy.sh", "{{ .match.env }}"]- A named group
(?P<env>…)reads as{{ .match.env }}. - A numbered group reads as
{{ index .match "1" }}— Go templates have no syntax for a numeric field name."0"is the whole match. execalso gets them asMATTERBOX_MATCH_ENV,MATTERBOX_MATCH_1, …- Each rule sees only its own captures; a rule with no
messagecondition sees an empty map.
Office hours with time
frequency and cooldown say how often a rule
may fire; time says when it may fire at all. Every field
set is ANDed:
match:
time:
after: "09:00" # inclusive, local time
before: "17:30" # exclusive
days: [mon, tue, wed, thu, fri]A before earlier than after wraps midnight, so
after: "22:00", before: "06:00" is the night. Either bound can
stand alone (after: "18:00" runs to the end of the day).
days accepts mon..sun, full names, or
0-7.
The window is tested against the moment the trigger fired, not the daemon's uptime — so a reaction to an old post is judged by when the reaction happened.
Not while you're reading it
The daemon has its own WebSocket connection and knows nothing about your TUI,
so without help it will happily pop a desktop notification for the reply to the
message you are, at that moment, watching someone type. viewing is
the fix:
rules:
- name: desktop-dms
match:
dm: true
from_me: false
viewing: false # … but not for the chat that's on my screen
actions:
- type: exec
command: [notify-send, "{{ .author }}", "{{ .message }}"]viewing: true holds when a matterbox TUI on this
machine has the post's channel open and its terminal has
focus. Both halves matter: a conversation sitting in a buried window is not
being read, and it still notifies.
The daemon asks the TUI over its control socket
(~/.config/matterbox/tui.sock, the same one
matterbox open writes to) and caches the answer for a second.
Anything that is not a clear “yes” — no TUI running, the socket gone stale, a
daemon on another host, a channel hidden behind the Feed/Search/SQL tab — counts
as not viewing, so a viewing: false rule keeps
firing exactly as it did before. A missed notification is the expensive failure;
a redundant one is not.
notify actions get this for free: the Telegram bridge never pushes
a message for the channel you are focused on, in the same breath as it skips
muted channels and quiet hours. You only need viewing for
exec and webhook rules — the ones that drive your own
desktop notifications.
Terminals report focus via DECSET 1004 (Ghostty, kitty, WezTerm, Alacritty,
xterm; inside tmux, set -g focus-events on). On a terminal that
never reports it, the TUI falls back to “was there input in the last minute” —
coarser, but it still keeps quiet while you are typing.
Rate limiting with frequency
The fields above answer “does this one message match?” — they are
stateless. frequency adds memory: it counts how often the rest of
the match has held recently, and fires the rule's actions only once
that count crosses a threshold within a rolling window. The classic use is
“don't page me for one sev-1, but do if there are three in ten
minutes”:
rules:
- name: sev1-storm
match:
channel: "Ops Alerts"
message: "(?i)sev-1"
frequency:
count: 3 # fire once this many matches …
within: 10m # … land inside this rolling window
by: author # optional: count separately per author (or channel)
actions:
- type: notify
urgent: true| Field | Meaning |
|---|---|
count | How many matches within the window trigger the rule (≥ 1). |
within | The window length, as a Go duration: 10m, 1h30m, 45s. |
by | Count separately per author, per channel, or together (global, the default). |
Semantics. Every matching message records a hit in a sliding
window; hits older than within fall out. The rule fires on the
message that fills the window to count, and the window then
resets — so it re-arms only after another full burst, rather
than firing again on the 4th, 5th, … message. With by: author, each
author has an independent window (one noisy author cannot trip a threshold meant
for the room as a whole); by: channel does the same per channel.
The window is in-memory and live-only: it starts empty on every (re)start, and the reconnect catch-up never feeds historical messages through it. A frequency gate is therefore best for “happening right now” bursts, not for thresholds that must survive a restart — for that, count into the persistent ledger instead.
Periodic firing with cooldown
frequency answers “has this happened enough lately?”;
cooldown is the inverse — “has it been long enough since the
last time?”. With a cooldown the rule fires on the next matching message,
then goes quiet for every, then fires on the next match after that.
It is how you say “do this at most once a day / week / every two
days” without pinning it to a wall-clock time: the trigger is still a
message, but the rule throttles itself to one firing per interval.
rules:
- name: weekly-standup-nudge
match:
channel: "Engineering"
cooldown:
every: 168h # 7 days — fire at most once a week
by: channel # optional: a separate interval per author / channel / team
actions:
- type: send
text: "📅 Reminder: standup notes due!"| Field | Meaning |
|---|---|
every | The minimum time between firings, as a Go duration: 24h (daily), 48h (every two days), 168h (weekly), 30m. |
by | Keep a separate interval per author, channel, or team, or one for the whole rule (global, the default). |
Semantics. The rule fires on a matching message only if it
has not fired within the last every (for that by
group); firing records the time and re-arms the interval. Unlike
frequency, the last-fire time is persisted, so the
interval is honoured across a daemon restart — a
cooldown: { every: 168h } that fired yesterday will not fire again
after a restart today. It is still live-only for
firing (the catch-up never trips it), and it
composes with the field conditions and frequency — all must
pass.
The interval is rolling: measured from the last firing, not
aligned to midnight. For a reset-at-local-midnight “once per calendar day”
instead, build a per-day ledger key from
{{ today }}.
Actions
type |
Fields | What it does |
|---|---|---|
notify | summarize, urgent, chat_id (all optional) | Summarise and deliver to Telegram. summarize overrides listen.summarize; urgent: true delivers even during quiet hours or for muted channels; chat_id routes to a different Telegram chat. |
exec | command (argv list) | Run a local command. Each argv element is a template over the post, so an argument can carry {{ .author }}, {{ .message }}, … The message is also piped to its stdin as JSON and exported as MATTERBOX_* environment variables. 30 s timeout. |
webhook | url, headers (optional map) | HTTP POST the message envelope as a JSON body. headers adds request headers; values are expanded from the daemon's environment ($TOKEN / ${TOKEN}). 15 s timeout. |
react | emoji (shortcode) | Add an emoji reaction to the message. |
mark_read | — | Mark the message's channel read. |
send | text, channel (optional), thread (optional) | Post a message. text is a template, so it can carry {{ .author }}, {{ today }}, … With no channel it posts into the channel the trigger arrived in; channel (team/channel or @user) routes it elsewhere. thread: true replies in the trigger's thread (ignored when channel is set). Skips your own posts (unless --notify-self) so an ungated rule cannot loop on its own output. |
log | text (optional prefix) | Write a line to the daemon log. |
state_set | key, value | Write value into the persistent ledger under key. Both are templates. |
state_incr | key, by (optional, default 1) | Add by to the integer stored at key (a missing or non-numeric value counts as 0). Negative decrements. |
state_del | key | Remove key from the ledger. |
urgent bypasses only the do-not-disturb suppression
(quiet_hours, respect_mutes); the self and
notify_dms gates still apply. A rule that routes to a non-default
chat_id is delivery-only — the two-way reply buttons work just for
the configured telegram.chat_id.
The exec / webhook payload
Both exec (on stdin) and webhook (as the POST body)
receive the same flat JSON envelope. Fields are only ever added,
never renamed or removed, so a script can depend on them:
{
"post_id": "abc123",
"channel_id": "xyz789",
"channel": "Engineering",
"team_id": "t1",
"team": "core",
"author": "bob",
"message": "deploying now",
"is_dm": false,
"is_thread": false,
"root_id": "",
"mentioned": true,
"files": ["diagram.png"],
"create_at": 1700000000000,
"permalink": "https://mm.example.com/core/pl/abc123",
"event": "message",
"emoji": "eyes",
"reactor": "alice",
"attachment_text": "Pipeline failed on main",
"rule": "",
"match": { "0": "deploying now", "1": "now" },
"state": { "failure_count": "3", "last_failure_time": "1700000000000" }
}team/team_id, root_id and
permalink are omitted when empty; mentioned is whether
you were @named; files lists attachment names (present only
when the post carries file metadata); state is a snapshot of the
persistent ledger (omitted when empty).
event is the trigger kind, so one script
can serve several rules. emoji and reactor are set only
for a reaction trigger, rule only for a scheduled one,
attachment_text only when the post carries
attachment text, and match holds the
message regexp's captures.
exec additionally exports each scalar field as an environment
variable so a quick script need not parse JSON:
MATTERBOX_POST_ID MATTERBOX_CHANNEL_ID MATTERBOX_CHANNEL
MATTERBOX_TEAM_ID MATTERBOX_TEAM MATTERBOX_AUTHOR
MATTERBOX_MESSAGE MATTERBOX_IS_DM MATTERBOX_IS_THREAD
MATTERBOX_ROOT_ID MATTERBOX_MENTIONED MATTERBOX_FILES
MATTERBOX_PERMALINK MATTERBOX_CREATE_AT MATTERBOX_EVENT
MATTERBOX_EMOJI MATTERBOX_REACTOR MATTERBOX_ATTACHMENT_TEXT
MATTERBOX_RULEMATTERBOX_FILES is comma-separated. Regexp captures are exported
as MATTERBOX_MATCH_<NAME> / MATTERBOX_MATCH_1.
The ledger is exported as MATTERBOX_STATE (the whole map as JSON)
plus one MATTERBOX_STATE_<KEY> per entry — the key
upper-cased, non-alphanumerics collapsed to _ — e.g.
MATTERBOX_STATE_FAILURE_COUNT=3.
Persistent state (the ledger)
The match conditions and most actions are stateless — each
message is judged on its own. The ledger breaks that: a small
key/value store, persisted in the
message database (the rule_state
table) and therefore surviving restarts, that rules read and write. It is how a
rule carries context from one message to the next — a running failure count, the
timestamp of the last deploy, a flag that a later rule checks.
Three actions write it:
rules:
- name: track-deploy-failures
match:
author: deploybot
message: "(?i)failed"
actions:
- type: state_set
key: last_failure_time
value: "{{ .create_at }}"
- type: state_incr
key: failure_count # 0 → 1 → 2 … (created on first use)
- name: clear-on-success
match:
author: deploybot
message: "(?i)succeeded"
actions:
- type: state_del
key: failure_count # reset the counter on a green deployTemplating keys and values
The key of state_set/state_incr/state_del,
the value of state_set, the send
text, and each element of an exec
command, are
Go
text/template strings expanded against the message. The data is
exactly the exec/webhook envelope above — so
{{ .author }}, {{ .channel }},
{{ .create_at }}, {{ .message }},
{{ .is_dm }}, … all work — plus the current ledger
under .state, so a template can read a value another action just
wrote:
actions:
- type: state_incr
key: deploy_failures
# state_* actions run in order, so this sees the value the incr just stored.
- type: state_set
key: last_failure_count
value: "deploy #{{ .state.deploy_failures }} failed"A per-message key can vary too —
key: "failures:{{ .author }}" keeps an independent counter per
author. Note that .state.NAME only works for keys that are valid
template identifiers; to read a key built from a template (e.g.
failures:bob) use the index function:
{{ index .state "failures:bob" }}. A field or .state
key that does not exist renders as empty, not an error.
Two date helpers are available on top of the post fields, in every template —
state keys and values, state-match keys, the send body, and
exec command arguments:
| Function | Renders |
|---|---|
{{ today }} | The current local date as 2006-01-02 (e.g. 2026-06-22). |
{{ now }} | The current local time as a Go time.Time; format it yourself, e.g. {{ now.Format "15:04" }}. |
today is the building block for once-per-day
behaviour: put it in a ledger key and gate on the key's absence.
key: "greeted:{{ today }}" with exists: false matches
only the first time that key is seen each calendar day; setting it afterwards
closes the gate until tomorrow, when the date — and so the key — changes. The
per-day keys are tiny and simply accumulate; the date is the daemon host's local
timezone.
How state flows to other actions
- Order is guaranteed. Within one rule,
state_*actions run synchronously in the order written, before theexec/webhookactions that follow them are dispatched — so a script invoked later in the same rule sees the values this rule just wrote. execandwebhookreceive the whole ledger: in the JSON envelope understate, andexecalso asMATTERBOX_STATE/MATTERBOX_STATE_<KEY>environment variables.state_incris atomic, so two messages updating the same counter at once cannot lose a write.
Matching on the ledger
Writing the ledger is only half of it — a match.state condition
reads it, so one rule can react to what another counted. Each condition names a
key and one or more operators, all ANDed:
| Operator | Meaning |
|---|---|
exists | true = key is present; false = key is absent. |
eq / ne | Value equals / does not equal this (string compare). |
gt / gte / lt / lte | Value, as a number, compared to this (a range is gte + lt). |
state takes a single condition or a list (all ANDed). The
key is a template over the post, just like the
action keys — so a condition can read a per-channel or per-author key, e.g.
key: "hot:{{ .channel_id }}". The canonical pairing: count failures
in one rule, page when the count crosses a threshold in another.
rules:
- name: count-failures
match: { author: deploybot, message: "(?i)failed" }
actions:
- type: state_incr
key: failures
- name: page-on-streak
match:
state:
key: failures
gte: 3 # ← reads what count-failures just wrote
actions:
- type: notify
urgent: true
- type: state_del
key: failures # reset so the next streak starts clean
- name: clear-on-success
match: { author: deploybot, message: "(?i)succeeded" }
actions:
- type: state_del
key: failuresSame-message visibility. Within one incoming message the
ledger is re-read after any rule that wrote it, so page-on-streak
sees the failures value count-failures just
incremented — the page fires on the third failure, not the fourth. An
absent key satisfies exists: false but no value comparison (there is
nothing to compare); a non-numeric value never satisfies a numeric operator.
This overlaps with frequency but trades
off differently: frequency is in-memory and self-pruning (great for
“N in the last T minutes”), while a ledger counter is exact, persistent across
restarts, and yours to reset — better when the threshold must survive a restart,
or the count is cleared by an explicit event (a green deploy) rather than by
time.
Correlating across messages
Because a state condition's key is templated per post, the ledger
can remember context about one conversation and react to it several
messages later — a correlation no single-message match (term
and mention in the same message) or frequency
window (the same rule's own hits) can express. The pattern is a per-channel
countdown: a trigger term arms a window, every message ticks it
down, and a mention while the window is open escalates. “Was a sev-1 term posted
in the last ~5 messages, and then I got @-mentioned?”
rules:
# 1) ARM: a trigger term opens a ~5-message window for this channel.
- name: arm-hot
match:
message: &terms "(?i)urgent|sev-?1|p1|outage|prod(uction)? down"
actions:
- type: state_set
key: "hot:{{ .channel_id }}"
value: "5"
# 2) ESCALATE: I'm mentioned while this channel is still "hot".
- name: hot-mention
match:
mention: true
state: { key: "hot:{{ .channel_id }}", gte: 1 }
actions:
- type: notify
urgent: true
# 3) TICK: each NON-term message in a hot channel counts the window down.
- name: tick-hot
match:
state: { key: "hot:{{ .channel_id }}", gte: 1 }
not: { message: *terms }
actions:
- type: state_incr
key: "hot:{{ .channel_id }}"
by: -1
# 4) NORMAL: an ordinary mention (channel not hot) → regular notification.
- name: mention
match:
mention: true
not: { state: { key: "hot:{{ .channel_id }}", gte: 1 } }
actions:
- type: notifyThe &terms / *terms is a YAML anchor, so the term
regexp is written once. Why this order:
- ARM first so a message that is both a term and an @-mention (“sev-1, @corne!”) arms the window and then escalates in the same pass — putting it later would let that high-signal message fall through both notify rules.
- TICK skips term messages
(
not: { message: *terms }) and runs after the escalate check, so the arming message does not consume its own window and a mention on the last message of the window still escalates. - Rules 2 and 4 are mutually exclusive (the
not:in rule 4), so a mention fires either the urgent path or the normal one, never both — and an ordinary mention in a channel that never saw a term still notifies (an absenthot:key failsgte: 1, sonot:lets rule 4 through). - The window is per channel — a term in
#opscannot escalate a mention in#random.
Examples
Page yourself for sev-1 alerts and stop there:
rules:
- name: pager
match:
channel: "Ops Alerts"
message: "(?i)sev-1|pagerduty"
actions:
- type: exec
command: ["/home/me/bin/page-me.sh"]
- type: notify
summarize: false
stop: trueA desktop notification for direct mentions, on Linux:
rules:
- name: desktop
match: { mention: true }
actions:
- type: exec
command: ["notify-send", "{{ .author }} mentioned you", "{{ .message }}"]Mark a DM read as soon as you acknowledge it from your phone with 👀:
rules:
- name: ack-clears-unread
on: reaction
match:
dm: true
reactor: me-on-mobile # your own username
emoji: eyes
actions:
- type: mark_readPost the standup prompt on weekday mornings:
rules:
- name: standup-prompt
on: schedule
schedule:
cron: "0 9 * * 1-5"
actions:
- type: send
channel: eng/standup
text: "Standup in 15 minutes ⏰"Page for a failed pipeline — from an integration that posts an empty body and puts everything in an attachment — but only during office hours:
rules:
- name: pipeline-failures
match:
from_bot: true
channel_type: [public, private]
message: "(?i)pipeline failed"
time:
after: "08:00"
before: "18:00"
days: [mon, tue, wed, thu, fri]
actions:
- type: notify
summarize: falseRun a deploy script with the environment the message named:
rules:
- name: deploy-command
match:
from_me: false
message: '^!deploy (?P<env>staging|prod)$'
actions:
- type: exec
command: ["/home/me/bin/deploy.sh", "{{ .match.env }}"]
- type: react
emoji: rocket
stop: trueAuto-react with 👀 when a bot drops a deploy notice, and mark it read:
rules:
- name: ack-deploys
match:
author: deploybot
message: "(?i)deployed"
actions:
- type: react
emoji: eyes
- type: mark_readPage through quiet hours when on-call is summoned, and route it to a separate chat:
rules:
- name: oncall
match:
channel: ["Ops*", "Incidents"]
message: "(?i)@oncall|sev-1"
not:
author: [statusbot]
actions:
- type: notify
urgent: true # ignore quiet_hours + mutes
chat_id: "-1001234567" # a dedicated on-call chat
summarize: false
stop: trueMirror everything in a channel to an authenticated external system — the token comes from the daemon's environment, not the config file:
rules:
- name: archive-incidents
match: { channel: "Incidents" }
actions:
- type: webhook
url: https://hooks.example.com/incidents
headers:
Authorization: "Bearer ${INCIDENTS_TOKEN}"Escalate only on a deploy-failure streak — count failures in the ledger, and when the count reaches three, run a script with the count in its environment and reset it:
rules:
- name: count-failures
match: { author: deploybot, message: "(?i)failed" }
actions:
- type: state_incr
key: deploy_failures
- name: clear-failures
match: { author: deploybot, message: "(?i)succeeded" }
actions:
- type: state_del
key: deploy_failures
- name: escalate
match:
author: deploybot
message: "(?i)failed"
frequency: { count: 3, within: 30m } # three failures in half an hour
actions:
- type: exec
command: ["/home/me/bin/escalate.sh"] # reads $MATTERBOX_STATE_DEPLOY_FAILURES
- type: state_del
key: deploy_failuresSay good morning the first time anyone posts in your team, and only once a day.
The send action posts the greeting and a
cooldown throttles it to one firing per
interval:
rules:
- name: good-morning
match:
team: core # any channel in the "core" team …
cooldown:
every: 24h # … but at most once a day (48h for every two days, 168h weekly)
actions:
- type: send
text: "Good morning! ☀️" # posts into the channel that triggered it
stop: trueThe rule fires on the first matching message, then the cooldown suppresses it
until every has elapsed, when the next message re-arms it. Change
every to 48h, 168h, or any
duration for a
different cadence; add by: channel (or team) to greet
each room independently. Drop the team line to greet on the first
message anywhere, or add a channel: condition to pin
the trigger to one room. To greet in a fixed channel instead of wherever
the trigger landed, add channel: core/general to the
send action itself.
The cooldown interval is rolling — measured from the last
greeting. If you would rather it reset at local midnight, so the
first message at 08:00 always greets even if yesterday's was at 09:00, gate on a
per-day ledger key built from {{ today }} instead of a cooldown:
- name: good-morning-calendar
match:
team: core
state: { key: "greeted:{{ today }}", exists: false } # only if we haven't greeted today
actions:
- type: state_set
key: "greeted:{{ today }}" # close the gate until tomorrow
value: "1"
- type: send
text: "Good morning! ☀️"
stop: trueReminders: !remind me in 2 hours …
Two rules and a small helper make a !remind me … command: one
reacts to the message that asks for the reminder, the other is a schedule rule
that delivers the ones that have come due.
The first hands every !remind message you post to
scripts/matterbox-remind,
which parses the delay, stores the reminder in
~/.config/matterbox/reminders.db, and replies with a confirmation.
The second runs the same helper with --tick once a minute; the tick
delivers every reminder that is due, so a missed minute is late, never
lost. Everything posts back with matterbox reply, so the confirmation
and the delivered reminder thread under your original message.
rules:
- name: reminders
match:
from_me: true # only your own commands schedule reminders
message: "(?i)^\\s*!remind"
actions:
- type: exec
command: ["/home/me/.config/matterbox/matterbox-remind"]
- name: reminder-tick
on: schedule
schedule:
every: 1m
actions:
- type: exec
command: ["/home/me/.config/matterbox/matterbox-remind", "--tick"]What you can type — the helper dispatches on the message:
| Command | Effect |
|---|---|
!remind me in <when> <text> | Schedule for a relative delay and confirm. |
!remind me at <date> [time] <text> | Schedule for a clock date/time and confirm. |
!reminders (or !remind list) | List this channel's pending reminders. |
!remind cancel <id> | Cancel a pending reminder. |
in <when> is flexible: 2 hours,
2 days, 30m, 1h30m,
1 day 6 hours, 90 minutes, 2 weeks —
weeks, days, hours, minutes and seconds, spelled out or abbreviated and
combinable. A leading in/after and a connector (“… to
call dad”, “… that the build is done”) are ignored.
at <date> [time] schedules for an absolute moment. The date
can be 2026-07-01, 1 July/jul 1,
03/07 (day-first — 3 July), a weekday
(friday), or today/tomorrow; the optional
time can be 14:30, 2:30pm, 9am,
14h30, or noon. A date with no time defaults to
09:00 (override with MATTERBOX_REMIND_HOUR), and a
bare time (!remind me at 18:00 …) means today. A spec that has
already passed but omits the year or date — a weekday, a bare time, a no-year
date — rolls forward to its next occurrence; a fully-specified past moment is
rejected. (on and @ work as synonyms for
at, and a bare absolute date with no keyword is accepted too.)
Install the helper:
$ cp scripts/matterbox-remind ~/.config/matterbox/
$ chmod +x ~/.config/matterbox/matterbox-remind
$ systemctl --user reload matterbox-listen # pick up the two rulesThe reminders persist in SQLite, so they survive a daemon — or host — restart.
Delivery only runs while the daemon does, which is also the only time a reminder
can be created; if you want them to go out even with the daemon stopped,
the tick rule can be a systemd timer instead —
scripts/matterbox-remind.timer
and its service unit still do exactly that (Persistent=true catches
up a tick missed while the box was off).
Safety
A rule that reacts to a reaction and then adds one is the obvious way
to build a loop: the server echoes every reaction back over the WebSocket. The
daemon breaks that specific circle — the echo of a reaction its own
react action just added never triggers a rule — while a reaction
you make, from your phone say, still does. Nothing stops a rule
from looping through a slower path, though (a send whose text matches
the rule's own condition, for instance), so give any rule that writes back a
condition that its own output cannot satisfy — from_me: false is
usually it.
exec runs commands from your own config, as you, on your own
machine — the same trust level as a shell alias. Each run is bounded by a timeout
and runs off the ingest path, so a slow or hung command cannot block message
caching or take the daemon down.
A bad glob, regexp, template, frequency/cooldown
duration, cron expression, time window, or
state condition (missing key, no operator), an unknown
action type or on: kind, and any combination that could
never work — an emoji condition without a reaction trigger, a
schedule rule whose send has no channel — are all reported when the
rules load, so a typo fails loud rather than silently never firing. A failed
reload leaves the running rules in place. state_*
writes touch only matterbox's own database (the rule_state table),
never your Mattermost server.