matterbox

matterbox / Docs / The CLI

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, platform

There 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
sendPost a message to a channel, DM or group DM, with up to five attachments.
replyReply in an existing message's thread.
reactAdd one or more emoji reactions to a message.
readPrint recent messages from a channel, or a whole thread.
unreadPrint every unread message, grouped by channel.
mark-readClear the unread state of one or more channels.
searchSearch the local cache — keyword, or hybrid with --semantic.
digestList your own messages across every channel for a time range. Aliased activity.
channelsList every team and channel with its id and its address.
whoamiPrint the authenticated user's username, id and email.
openTell a running TUI on this machine to switch to a channel.
listenThe daemon: keeps the cache warm and runs your rules.
rulesInspect, dry-run and reset the daemon's rules and its ledger.
loginSign in and save the session token.
welcomeThe first-run setup wizard.
keysEvery rebindable keyboard action, its default keys, and your overrides.
embedBackfill semantic-search embeddings for cached messages.
decodeDecode the invisible payload matterbox smuggles through a post body.
completionEmit a completion script for bash, zsh, fish or powershell.
helpHelp about any command.

Addressing a channel

Every command that names a channel takes the same four forms.

Form Means
team/channelA 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.
@userThe direct message with that user, created if it does not exist yet.
@a,@bThe 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, --limitHow 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 / --untilBound by time; --until is exclusive. See Time windows.
-f, --from @userOnly 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, --waitAfter printing history, block until a new message arrives. See below.
--timeoutWith --wait, give up after this long. Requires --wait.
--json / -oJSON 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 7f3k9y2m4n6p8q0r2s4t6u8v0w

One 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.

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, --channelRestrict to one channel (team/channel or @user).
-f, --from @userRestrict to one author.
--since / --untilBound by time, as elsewhere.
-n, --limitMaximum matches (default 20). Unlike read, 0 is not “no cap” here — it falls back to 20. Page with --offset instead.
--offsetSkip this many top-ranked matches, for paging.
--semanticBlend semantic similarity with the keyword ranking.
--context NShow 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" --semantic

Writing

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.gif

reply

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 7f3k9y2m4n6p8q0r2s4t6u8v0w

react

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 rocket

mark-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 rule

JSON 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
idThe post id. This is what reply and react take.
channel_idThe 26-character channel id.
channelThe channel's addresseng/general or @alice — so it feeds straight back into read or send. DM channels would otherwise surface as a raw userid__userid.
user_idThe author's id.
usernameThe author's name, resolved up front. A webhook or bot override_username wins over the real account, matching what the text output shows.
messageThe markdown body, verbatim.
create_atCreation time in unix milliseconds, as Mattermost stores it.
timeThe same instant as RFC 3339 in your local zone — for humans and for jq.
root_idThe 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
idThe channel id.
addressWhat read/send accept. Empty for a group DM, which the CLI cannot address, and for a DM whose partner's username did not resolve.
nameThe channel's URL name. For a DM this is the generated userid__userid pair; for a group DM, a hash.
display_nameThe name as shown in the sidebar. Empty for DMs.
typepublic, private, direct or group. Note direct — the rules channel_type condition spells the same thing dm.
team, team_idThe team's URL slug and id. Omitted for DMs and group DMs, which belong to no team.
partnerFor 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
nowThe current instant.
today / yesterdayMidnight at the start of that day, in your local zone.
7d / 2wThat many days or weeks ago.
30m, 2h, 1h30mAny Go duration, read as “ago”.
2026-06-08Midnight at the start of that calendar day.
2026-06-08 15:04That local date and time. 2026-06-08T15:04 works too.
2026-06-08T09:30:00ZAn 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:

# 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
fi

Scripting 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" eyes

Who 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 -rn

A week of your own messages, as a spreadsheet

$ matterbox digest --since 7d --json |
    jq -r '[.time, .channel, .message] | @tsv' > week.tsv

Only 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'    # roots

Map 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.tsv

Your 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
done

On 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 today

If 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 -f

The 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 listWhat 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 testWhich rules a given message would fire, and for the rest, the first condition that failed. See Testing a rule.
rules statsHow 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 stateRead 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, anywhere

Setup 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
loginGitLab 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.
whoamiWho the saved token authenticates as.
keysEvery 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.
embedBackfill 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.
openTell 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.
decodeDecode 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.fish

matterbox 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"