The CLI
Run matterbox with no arguments and you get the
terminal UI. Everything else on this page is for scripts.
The subcommands are a thin, non-interactive layer over the same client and
the same local cache the TUI uses. Most of the
reading verbs never touch the network at all — search and
digest query the cache — so they are fast enough to put in a
prompt, a cron line, or a pipe.
The shape of a command
$ matterbox # no subcommand: the TUI
$ matterbox <command> [args] # everything else
$ matterbox help <command> # the authority for any of them
$ matterbox --version # build, optional features, platformThere is no version subcommand — --version prints
the whole build block a bug report wants. --pprof localhost:6060
serves net/http/pprof, and applies to the TUI rather than to the
subcommands.
Every command
| Command | What it does |
|---|---|
send | Post a message to a channel, DM or group DM, with up to five attachments. |
reply | Reply in an existing message's thread. |
react | Add one or more emoji reactions to a message. |
read | Print recent messages from a channel, or a whole thread. |
unread | Print every unread message, grouped by channel. |
mark-read | Clear the unread state of one or more channels. |
search | Search the local cache — keyword, or hybrid with --semantic. |
digest | List your own messages across every channel for a time range. Aliased activity. |
channels | List every team and channel with its id and its address. |
whoami | Print the authenticated user's username, id and email. |
open | Tell a running TUI on this machine to switch to a channel. |
listen | The daemon: keeps the cache warm and runs your rules. |
rules | Inspect, dry-run and reset the daemon's rules and its ledger. |
login | Sign in and save the session token. |
welcome | The first-run setup wizard. |
keys | Every rebindable keyboard action, its default keys, and your overrides. |
embed | Backfill semantic-search embeddings for cached messages. |
decode | Decode the invisible payload matterbox smuggles through a post body. |
completion | Emit a completion script for bash, zsh, fish or powershell. |
help | Help about any command. |
Addressing a channel
Every command that names a channel takes the same four forms.
| Form | Means |
|---|---|
team/channel | A channel, by the team's URL slug and the channel's URL name — eng/general, the same pair in the web app's address bar. |
@user | The direct message with that user, created if it does not exist yet. |
@a,@b | The group DM you share with all of them, created if it does not exist. A group DM holds you plus 2–7 others. |
<channel-id> | A raw 26-character id, passed straight through. mark-read and open take one, which is how a listen exec rule can hand $MATTERBOX_CHANNEL_ID along without resolving it. |
matterbox channels is how you discover them, and how you map an
id back to a name:
$ matterbox channels
# eng
q7w8e9r0t1y2u3i4o5p6a7s8d9 public eng/general General
z1x2c3v4b5n6m7q8w9e0r1t2y3 private eng/releases Releases
# DMs
a1s2d3f4g5h6j7k8l9z0x1c2v3 direct @alice Text output is id, type, address, display name — the id first so it is
awk-friendly. Group DMs have no CLI address, so that column is
blank for them.
Exit status
There is one failure code. Every command exits 0 on success and 1 on any error, printing a single line to stderr:
matterbox: unrecognized time "lastweek" (try: now, today, yesterday, 7d, 2h, or 2006-01-02)So set -e and if matterbox … ; then both work, but
nothing distinguishes “no such channel” from “the server is down” by number —
read the message if you need to tell them apart. Note that a
--wait that times out is an error too, which
is usually what you want in a script.
Reading
Four verbs, and the choice between them is mostly about scope: one channel
(read), everything unread (unread), everything
you said (digest), or everything matching a query
(search). All four take --json.
read
The most recent messages in one channel, oldest first. System messages — joins, leaves, header changes — are left out.
| Flag | Meaning |
|---|---|
-n, --limit | How many recent messages (default 30). 0 means no cap, but only alongside --since or --until — on a plain recent read there is no window to bound it, so 0 falls back to 30. |
--since / --until | Bound by time; --until is exclusive. See Time windows. |
-f, --from @user | Only that author's messages. The --limit then counts their messages, not the channel's. |
--thread <root-id> | Print a whole thread — root and every reply, in order — instead of the channel tail. The channel argument becomes optional, and the windowing flags do not apply. |
-w, --wait | After printing history, block until a new message arrives. See below. |
--timeout | With --wait, give up after this long. Requires --wait. |
--json / -o | JSON Lines instead of text. See JSON output. |
$ matterbox read eng/general
$ matterbox read @alice --limit 50
$ matterbox read eng/general --since yesterday
$ matterbox read eng/general --since 2026-06-08 --until 2026-06-09
$ matterbox read eng/general --from @alice --since 7d
$ matterbox read --thread 7f3k9y2m4n6p8q0r2s4t6u8v0wOne asymmetry worth knowing: with --since the whole
window is printed, so a busy week is not silently cut to 30 lines —
pass --limit if you want it capped. Without --since,
--until just filters the most recent --limit
messages.
unread
Every unread message, grouped by channel, oldest first within a group.
Channels with mentions sort first, then by most recent activity. Each group
header is the channel's address, so it feeds straight back into
read or send.
$ matterbox unread
$ matterbox unread --limit 10 # per channel; older ones collapse
$ matterbox unread --muted # muted are excluded by default
$ matterbox unread --wait --timeout 15m--limit is per channel and defaults to
0, meaning all of them. Muted channels stay out unless you ask,
matching what the TUI's feed shows.
digest
What you posted, across every channel, in a time window — “what did I work on”. Grouped by channel, most-recently-active first. It is a single scan of the local cache rather than one history fetch per channel, so it is fast, and it only sees what matterbox has cached.
$ matterbox digest # since the start of today
$ matterbox digest --since yesterday
$ matterbox digest --since 7d
$ matterbox digest --since 2026-06-01 --until 2026-06-08--since defaults to the start of today and --until
to now. activity is an alias for the same command.
search
Searches the cached corpus — the same store the TUI's Search tab uses — best
ranked first. Apart from --semantic it makes no Mattermost API
calls beyond resolving labels.
By default it is a keyword search over
FTS5: every word must appear, and
words are prefix-matched, so tweak finds tweakwise.
--semantic embeds the query and blends that with the keyword
ranking, so a conceptual match surfaces without a shared word — that needs the
embeddings server up and vectors built with
matterbox embed.
| Flag | Meaning |
|---|---|
-c, --channel | Restrict to one channel (team/channel or @user). |
-f, --from @user | Restrict to one author. |
--since / --until | Bound by time, as elsewhere. |
-n, --limit | Maximum matches (default 20). Unlike read, 0 is not “no cap” here — it falls back to 20. Page with --offset instead. |
--offset | Skip this many top-ranked matches, for paging. |
--semantic | Blend semantic similarity with the keyword ranking. |
--context N | Show N surrounding messages around each match. Text output only. |
$ matterbox search tweakwise
$ matterbox search "release plan" --channel eng/general --since 7d
$ matterbox search deadline --from @alice --json
$ matterbox search "how do we deploy" --semanticWriting
send
The message is the remaining arguments joined by spaces. With no message it is read from standard input, which is what makes it a pipe target:
$ matterbox send eng/general "deploy is done"
$ echo "deploy is done" | matterbox send eng/general
$ matterbox send @alice "ping"
$ matterbox send @alice,@bob "standup in 5"Attach files with --file, repeatable up to five.
With an attachment the message is optional, so a caption-less upload works:
$ matterbox send @alice --file diagram.png "see attached"
$ matterbox send eng/general --file a.gifreply
Takes a post id — the id field of
read --json, or the $MATTERBOX_POST_ID a
listen exec rule exports. The
channel comes from the message, so you do not name it.
Mattermost threads are one level deep, so the reply is rooted at that message's thread: reply to a root and it goes under it; reply to a reply and it joins the same thread. When the target is itself a reply, matterbox records which message you answered — invisibly, so other clients see the ordinary flat reply they always saw, while matterbox draws it nested.
$ matterbox reply 7f3k9y2m4n6p8q0r2s4t6u8v0w "on it, thanks"
$ echo "on it" | matterbox reply 7f3k9y2m4n6p8q0r2s4t6u8v0wreact
Shortcodes, with or without the surrounding colons — tada and
:tada: are the same. Several at once are added left to right, and
the command stops at the first one the server rejects:
$ matterbox react 7f3k9y2m4n6p8q0r2s4t6u8v0w tada
$ matterbox react 7f3k9y2m4n6p8q0r2s4t6u8v0w :+1:
$ matterbox react 7f3k9y2m4n6p8q0r2s4t6u8v0w eyes rocketmark-read
Clears a channel's unread count and its mentions on the server — the same effect opening it in the UI has. Useful for scripting a catch-up, or for dismissing a noisy channel without reading it. Several at once are marked left to right, stopping at the first that fails to resolve:
$ matterbox mark-read eng/general
$ matterbox mark-read eng/general eng/random @bob
$ matterbox mark-read "$MATTERBOX_CHANNEL_ID" # a raw id from a ruleJSON output
read, unread, search,
digest and channels all take --json
(a shorthand for --output json; -o text is the
default and anything else is an error rather than a silent fallback).
The format is JSON Lines: one object per line, newline
terminated, ready for jq -c or a while read loop.
HTML escaping is off, so &, < and
> in message text read back verbatim.
A message
read, unread, search and
digest emit the same object, so one script can consume
any of them:
{
"id": "7f3k9y2m4n6p8q0r2s4t6u8v0w",
"channel_id": "q7w8e9r0t1y2u3i4o5p6a7s8d9",
"channel": "eng/general",
"user_id": "z1x2c3v4b5n6m7q8w9e0r1t2y3",
"username": "alice",
"message": "deploy is done",
"create_at": 1780000000000,
"time": "2026-06-08T09:30:00+02:00",
"root_id": "a1s2d3f4g5h6j7k8l9z0x1c2v3"
}| Field | Meaning |
|---|---|
id | The post id. This is what reply and react take. |
channel_id | The 26-character channel id. |
channel | The channel's address — eng/general or @alice — so it feeds straight back into read or send. DM channels would otherwise surface as a raw userid__userid. |
user_id | The author's id. |
username | The author's name, resolved up front. A webhook or bot override_username wins over the real account, matching what the text output shows. |
message | The markdown body, verbatim. |
create_at | Creation time in unix milliseconds, as Mattermost stores it. |
time | The same instant as RFC 3339 in your local zone — for humans and for jq. |
root_id | The thread's root post id. Absent on a root post, so has("root_id") is the test for “is this a reply”. |
Field order on the wire is the order above, and fields are only ever added — never renamed or removed — so a script can depend on them.
A channel
channels --json has its own shape:
{
"id": "q7w8e9r0t1y2u3i4o5p6a7s8d9",
"address": "@alice",
"name": "z1x2c3v4b5n6m7q8w9e0r1t2y3__a1s2d3f4g5h6j7k8l9z0x1c2v3",
"display_name": "",
"type": "direct",
"partner": "alice"
}| Field | Meaning |
|---|---|
id | The channel id. |
address | What read/send accept. Empty for a group DM, which the CLI cannot address, and for a DM whose partner's username did not resolve. |
name | The channel's URL name. For a DM this is the generated userid__userid pair; for a group DM, a hash. |
display_name | The name as shown in the sidebar. Empty for DMs. |
type | public, private, direct or group. Note direct — the rules channel_type condition spells the same thing dm. |
team, team_id | The team's URL slug and id. Omitted for DMs and group DMs, which belong to no team. |
partner | For a DM, the other person's username. Omitted otherwise, and when it could not be resolved. |
whoami is not JSON but is column-aligned to single-token values
for exactly this reason:
$ matterbox whoami
username alice
id z1x2c3v4b5n6m7q8w9e0r1t2y3
email alice@example.com
$ matterbox whoami | awk '$1=="id"{print $2}'Time windows
read, search and digest share one
parser, so --since and --until accept the same
vocabulary everywhere. Keywords are case-insensitive.
| Form | Means |
|---|---|
now | The current instant. |
today / yesterday | Midnight at the start of that day, in your local zone. |
7d / 2w | That many days or weeks ago. |
30m, 2h, 1h30m | Any Go duration, read as “ago”. |
2026-06-08 | Midnight at the start of that calendar day. |
2026-06-08 15:04 | That local date and time. 2026-06-08T15:04 works too. |
2026-06-08T09:30:00Z | An RFC 3339 instant, exactly. |
--until is exclusive, so one whole day is
--since 2026-06-08 --until 2026-06-09. A window whose start is
not strictly before its end is rejected, so an inverted range fails loudly
rather than printing nothing.
Waiting for the next message
read and unread take -w, --wait: after
printing what it has, the command opens a WebSocket and blocks until a new
message arrives, prints it, and exits. So it never returns empty-handed —
which is the difference between a poll loop and a blocking read.
$ matterbox read eng/general --wait --timeout 5m
$ matterbox unread --wait --timeout 15m
$ matterbox read eng/general --from @alice --wait # block on her next one--timeout requires --wait (passing it alone is an
error) and 0 waits forever. Two things to plan for:
- A timeout is a failure. It exits 1 with
timed out after 5m waiting for a new message, so aset -escript stops there. Guard it if a quiet period is normal. - “New” is relative to what was just printed — the newest post in the history it showed, or now if the channel was empty — so an existing backlog cannot make it return instantly.
# Block for a reply, but treat a quiet hour as normal rather than as an error.
if reply=$(matterbox read @alice --wait --timeout 1h --json); then
echo "$reply" | jq -r '.message'
else
echo "nothing yet" >&2
fiScripting patterns
Everything below is --json into jq, or stdin into
send. Those two directions cover most of it.
Post the output of something
$ df -h / | matterbox send eng/ops
$ git log --oneline -5 | matterbox send eng/releases
$ systemctl --user is-failed --quiet matterbox-listen &&
matterbox send eng/ops "the listen daemon is down"React to the newest message from someone
read prints oldest first, so the last line is the newest —
tail -1 rather than head:
$ id=$(matterbox read eng/general --from @alice --json |
tail -1 | jq -r .id)
$ matterbox react "$id" eyesWho is talking, and where
# who talks in one channel
$ matterbox read eng/general --since 7d --json |
jq -r .username | sort | uniq -c | sort -rn
# and where you have been active, across all of them
$ matterbox digest --since 30d --json |
jq -r .channel | sort | uniq -c | sort -rnA week of your own messages, as a spreadsheet
$ matterbox digest --since 7d --json |
jq -r '[.time, .channel, .message] | @tsv' > week.tsvOnly the replies
root_id is absent on a root post, so its presence is the
test:
$ matterbox read eng/general --json |
jq -r 'select(has("root_id")) | .message' # replies
$ matterbox read eng/general --json |
jq -r 'select(has("root_id") | not) | .message' # rootsMap a channel id back to an address
The message database stores ids and no names, so this is the lookup table for a query's output:
$ matterbox channels --json |
jq -r 'select(.address != "") | "\(.id)\t\(.address)"' > channels.tsvYour own user id, without hand-rolling an API call
$ me=$(matterbox whoami | awk '$1=="id"{print $2}')
$ matterbox read eng/general --json |
jq -r --arg me "$me" 'select(.user_id != $me) | .message'Block until someone answers, then act
A blocking read is the whole loop — no polling interval to pick, and it cannot spin:
while msg=$(matterbox read @alice --wait --json 2>/dev/null); do
case $(echo "$msg" | jq -r .message) in
"!deploy") ./deploy.sh && matterbox send @alice "deployed" ;;
"!stop") break ;;
esac
doneOn a timer
Nothing here needs the TUI or the daemon running, so a
cron line or a systemd timer is enough. Both need
PATH to reach the binary — make install puts it in
~/.local/bin, which a cron environment usually does not have:
# crontab -e — the standup prompt, weekdays at 08:45
45 8 * * 1-5 PATH=$HOME/.local/bin:$PATH matterbox send eng/standup "Standup in 15 ⏰"
# and yesterday's own activity, mailed to you by cron every morning
0 9 * * * PATH=$HOME/.local/bin:$PATH matterbox digest --since yesterday --until todayIf the daemon is already running, a
scheduled rule is usually the better home
for this: it lives with the rest of your config, it says when it will next
fire, and matterbox rules stats tells you whether it did.
The daemon
matterbox listen is the long-lived half: one WebSocket, every
incoming message written into the cache — so the TUI reopens warm and
search/digest stay fresh without launching the UI —
and whatever automation you have configured. It is safe to run alongside the
TUI; both write the same idempotent rows into the WAL-mode store.
What it does with a message is the rules: block, which
has a reference page of its own. With no rules
configured it applies one default: forward your mentions and DMs to
Telegram.
$ matterbox listen # in the foreground, while you iterate
$ matterbox listen --notify-self # also fire on your own posts
# under a supervisor — `make install` drops a disabled unit for your OS
$ systemctl --user enable --now matterbox-listen.service
$ journalctl --user -u matterbox-listen -fThe four rules verbs read the same config the daemon reads and
run the same matcher, so an answer from them is the answer the daemon would
give. None of them executes anything:
| Command | What it tells you |
|---|---|
rules list | What is configured, in evaluation order, and when each scheduled rule fires next. A rule that cannot compile is reported exactly as the daemon would report it at startup, so this doubles as a config check. |
rules test | Which rules a given message would fire, and for the rest, the first condition that failed. See Testing a rule. |
rules stats | How often each rule has fired and when it last did. Counters persist across restarts and count firings, not matches — so name your rules; an unnamed one is keyed by position and inserting a rule above it restarts its count. |
rules state | Read and edit the ledger — the one piece of rule state with no other window onto it. |
$ matterbox rules list
$ matterbox rules test -m "sev-1 in prod" --channel "Ops Alerts"
$ matterbox rules stats
$ matterbox rules state
$ systemctl --user reload matterbox-listen.service
$ pkill -HUP -f 'matterbox listen' # same thing, anywhereSetup and housekeeping
matterbox welcome is the first-run wizard — it writes
~/.config/matterbox/config.yaml and signs you in. After that:
| Command | What it does |
|---|---|
login | GitLab SSO by default: it opens your browser and the server hands the token back over an mmauth:// link. --user/--password signs in with a username or email instead, prompting for whatever you leave out — the password is read without echo and is never taken as a flag. --no-browser just prints the URL, --show prints the saved token's path and fingerprint, --clear deletes it. |
whoami | Who the saved token authenticates as. |
keys | Every rebindable action, the keys it answers to, and whether you have overridden it. The action id in the first column is exactly what goes on the left of a keybindings.bindings: override; an overridden row is marked * and shows the default it replaced. |
embed | Backfill semantic-search vectors for cached messages. Needs the embeddings server up. Safe to re-run or interrupt — each batch commits as it completes. A full backfill runs the GPU flat out for hours, so --gpu-share 50 paces it and leaves your desktop usable. |
open | Tell a running TUI to switch to a channel, over a local control socket. Same machine only, and an error if no TUI is running — it is what makes a desktop notification's click jump to the conversation. |
decode | Decode the invisible payload matterbox smuggles through a post body — a nested reply's target, text effects, or a game world. Body on stdin, as an argument, or --post <id> to fetch it from the server, which is the reliable route if the client you copied from ate the invisible runes. |
On Linux, login registers matterbox as the
mmauth:// handler so the SSO token is captured without a
copy-paste. That is what the hidden register-handler and
url-handler commands are for — install plumbing, run by
make install and by login itself, not daily
verbs.
Shell completion
make install sets this up already. By hand, for the current
shell only:
$ source <(matterbox completion bash)
$ matterbox completion zsh > "${fpath[1]}/_matterbox"
$ matterbox completion fish > ~/.config/fish/completions/matterbox.fishmatterbox completion <shell> --help has the permanent
install for each; powershell is there too.
A second profile
MATTERBOX_CONFIG_DIR names the directory holding
everything matterbox keeps on disk — config.yaml, the
saved token, the message cache, the stats files and the TUI's control socket.
It is taken verbatim, with nothing appended, which makes a second account or a
throwaway sandbox a one-line change:
$ export MATTERBOX_CONFIG_DIR=~/.config/matterbox-work
$ matterbox login
$ matterbox send eng/general "from the other account"