matterbox

matterbox / Docs / Configuration

Configuration — config.yaml

matterbox writes its own configuration file on first run, with a comment over every key. This is that file, key by key.

Everything matterbox lets you change lives in one YAML file. It is written for you on first run with every key at its default and a comment block explaining each one, so the file itself is the quickest reference — this document is the long version: what each key does, what it defaults to, when a change takes effect, and which keys interact.

Nothing here is required. A config with a single server_url line is a complete config; the rest exists so you can change your mind about a default.

Where it lives

Path
Linux~/.config/matterbox/config.yaml (honours XDG_CONFIG_HOME)
macOS~/Library/Application Support/matterbox/config.yaml
Anywhere$MATTERBOX_CONFIG_DIR/config.yaml

MATTERBOX_CONFIG_DIR names the directory verbatim — nothing is appended — and relocates everything matterbox keeps on disk, not just the config. That makes it the way to run a second profile against another server:

$ MATTERBOX_CONFIG_DIR=~/.config/matterbox-work matterbox

The rest of the directory:

File What it is
config.yamlthis file — mode 0600, it holds tokens
config.schema.jsonJSON Schema for editor autocomplete, rewritten to match the running build
mm_token.jsonthe Mattermost session token (matterbox login writes it)
messages.dbthe local message cache + FTS5 index + embeddings — see the message database
templates.jsoncomposer templates (/tmpl)
channel_stats.json, picker_stats.jsonusage counts that order the channel switcher and pickers
tui.sockcontrol socket a running TUI listens on (matterbox open, the daemon’s “are you reading this?” check)

Your Mattermost credentials are not in config.yaml — the session token lives in mm_token.json. The config does hold the Jira, GitLab and Telegram secrets, which is why it is owner-only; matterbox tightens the permissions every time it reads the file, not only when it rewrites it.

First run, and when matterbox rewrites the file

A rewrite keeps your values but regenerates the file from scratch: the header comments come back, keys are re-ordered canonically, and any comments you added are lost. Four things trigger one:

  1. the file did not exist;
  2. a section or key from a newer build was missing;
  3. matterbox welcome saved your answers;
  4. you reordered the team tabs in the TUI with < / > (which persists team_order).

So treat the file as data rather than a place to keep notes, and if you want commentary that survives, keep it in a copy under version control.

Editor autocomplete

matterbox drops a JSON Schema next to the config and puts a modeline at the top of the file:

# yaml-language-server: $schema=config.schema.json

Any editor running the YAML language server (VS Code’s YAML extension, Neovim’s yamlls, Helix, …) then gives you key completion, hover docs, enum values, and a warning on unknown or mistyped keys — with no per-editor setup, because the schema path is relative to the config itself. The file is refreshed on every load, so it always describes the build you are running.

What takes effect when

Values that name a fixed set of choices (nav_modifier, vim_nav, emoji_images, image_thumbnails, code_theme) fall back to their default when you write something unrecognised — a typo costs you the setting, not the app. Keybinding overrides are the exception: an unknown action id, an unparseable chord, or a binding that collides with another action is a hard startup error, because a silently shadowed key is worse than a failure to launch.

Minimal config

server_url: https://mattermost.example.com

Then matterbox login. Everything below is optional.

Reference

The server

Key Default What it does
server_urlhttps://mattermost.example.comBase URL of your Mattermost instance.

The default is a placeholder: commands that need a real server (login) treat a server_url still equal to it as “not configured yet” and send you to matterbox welcome.

Reading and display

Key Default What it does
reactions[+1, -1, heart, tada, eyes, rocket, laughing, thinking_face]Quick list in the reaction picker (R on a message), in the order you write it — listed after any emoji already placed on that message. The picker’s search box reaches every server emoji regardless, so an empty list is not a dead end.
team_order(empty)Left-to-right order of the team tabs, by team URL name (case-insensitive; a display name is accepted too). Teams you don’t list are appended alphabetically. Reordering with < / > in the TUI writes this key.
mark_read_delay_seconds5How long a channel must stay open before it is marked read on the server. A shorter peek leaves it unread. 0 marks read the moment you open it.
group_message_seconds120Consecutive messages from the same person, sent within this many seconds and with nobody else posting in between, render as bare continuation lines under one name+time header. 0 gives every message its own header.
collapse_long_messages12Fold a message whose body wraps to more than this many rows down to a preview plus a … N more lines footer, so a log dump doesn’t bury the conversation. z expands/re-folds the selected message. 0 disables folding.
collapse_preview_linestwo-thirds of collapse_long_messagesHow many leading rows a folded message keeps. Clamped to 1 … collapse_long_messages; ignored when folding is off.
custom_statustrueShow DM partners’ custom statuses (🌴 On vacation) in the header and as a sidebar hint glyph. false leaves only the presence dots.
date_separatorstrueDraw a labelled rule (Today, Yesterday, or a date) above the first message of each local calendar day.
feed_show_mutedfalseLet muted channels into the unread Feed and its tab badge. This is only the startup state — M on the Feed tab toggles it for the session.
mousetrueMouse support: wheel-scroll the transcript/thread/result lists, click a tab, channel or message to select it, drag to select text, hover to highlight. Set false to keep your terminal’s native click-drag selection, which capturing the mouse otherwise replaces (most terminals fall back to shift-drag).
sql_tabfalseAdd the read-only SQL tab — a query editor over the local message cache whose result rows render as chat messages. See the message database.
kaomoji_options(empty)Extra entries for the /kaomoji picker, listed after the built-in set.
code_thememonokaiChroma style used to highlight fenced code blocks: dracula, github-dark, gruvbox, nord, onedark, catppuccin-mocha, tokyonight-night, … plus the bundled everforest-dark. An unknown name falls back to the default; NO_COLOR disables code colour entirely.

Attachments, images and motion

Key Default What it does
download_dir~/DownloadsWhere s on a message saves attachments. A leading ~ is expanded; the directory is created on first download.
attach_on_droptrueAttach a file dragged onto the terminal. Terminals have no drag-and-drop protocol — the emulator delivers a drop by pasting the path — so this is a heuristic: a paste that is nothing but existing absolute file paths becomes an attachment. false pastes such paths as text.
emoji_imagesautoauto renders custom (server) emoji as real inline images on a Kitty/Ghostty-class truecolor terminal outside tmux; off keeps literal :name: text everywhere. Unicode emoji are unaffected — they are always font glyphs.
image_thumbnailsoffauto draws image attachments as inline thumbnails in the transcript, wherever emoji_images works (same terminal gate). off shows only the 🖼️ filename line. Space opens the full-size preview either way.

animations: groups the motion toggles, so movement you find distracting can go away one piece at a time:

Key Default What it does
animations.custom_emojitrueAnimate GIF custom emoji in place. No effect unless emoji_images renders them as images at all.
animations.image_previewtrueAnimate GIFs in the space-to-preview modal.
animations.inline_imagestrueAnimate GIF thumbnails in the transcript — only while they are on screen, so a channel full of GIFs costs nothing once scrolled away.
animations.native_animationfalseExperimental. Play all of the above through the Kitty graphics protocol’s native animation frames: every frame is uploaded once and the terminal times and loops it, instead of matterbox re-transmitting on a timer. In a binary built with the video tag it also unlocks video (mp4/webm/mov/animated-webp) — looping inline previews, and space streams the whole clip. Opt-in because it needs animation-frame support beyond what most Kitty-class terminals implement, and a terminal that only does the basics may show a frozen or blank image rather than falling back.

native_gif_protocol is the former name of native_animation and is still read, so an old config keeps working; the next rewrite drops it in favour of the new key.

keybindings

keybindings:
  nav_modifier: ctrl
  vim_nav: global
  bindings:
    compose: [i, a]
    delete_post: shift+d
    quit: []
Key Default What it does
nav_modifierctrlModifier for arrow-key team (/) and channel (/) navigation: ctrl, alt, shift, super (⌘ / Windows key; cmd also accepted), meta, hyper, or none to turn arrow-nav off and free ctrl+←/→ for the composer’s word-jump.
vim_navglobalWhen ctrl+h/j/k/l switch team/channel: global from any focus, even while typing; reading only outside text inputs, so ctrl+h / ctrl+k stay as the composer’s emacs editing keys; off never. Arrow-nav is unaffected.
bindings(empty)Per-action overrides: an action id mapped to one key or a list of keys.

On macOS ctrl+arrows collide with Mission Control — shift is the most broadly compatible alternative, and super (⌘) works on terminals that speak the Kitty keyboard protocol (Ghostty, kitty, WezTerm) but not on Terminal.app or iTerm2. Some chords only arrive at all on a Kitty-protocol terminal: shift+enter, for instance, sends on a legacy terminal instead of inserting a newline — use alt+enter there.

bindings rules of the road. A value is a single chord (shift+d) or a list ([i, a]); an empty list or the string none unbinds the action. Modifiers are ctrl, alt, shift, super, meta, hyper. Rebinding a navigation action drops its modifier-arrow alias too. ctrl+c always quits, no matter what you do to quit. An unknown action id, an unparseable chord, or an override that makes two actions collide in layers active at the same time is reported at startup, with the full list of valid ids.

matterbox keys prints every action, its default keys, your effective keys, and marks the ones you overrode — the authoritative list. In the TUI, ? expands the footer into every key active right now and f1 › Keys opens the full scrollable cheatsheet. The action ids, by layer:

The jump-to actions (goto_team = alt+1…9, goto_dm = alt+d, goto_feed = alt+u) need alt to reach the app at all; on macOS that means macos-option-as-alt = true in Ghostty, or rebinding them here (goto_team: [super+1, super+2, …]).

Search, summaries and AI

All of it is optional and all of it talks to OpenAI-compatible endpoints, so a local llama.cpp server keeps your chat history on your machine. Leave the sections alone and the features simply stay unused; when a server is down, semantic search degrades to keyword and summaries fall back to raw text.

summary: — the > Summarize command (ctrl+k), and the chat model the listen daemon and agentic search reuse:

Key Default What it does
summary.endpointhttp://127.0.0.1:8321Base URL of the chat server; matterbox appends /v1/chat/completions (a trailing /v1 is accepted).
summary.api_key(empty)Optional Bearer token — unnecessary locally, required by hosted APIs.
summary.modelgemma-4-E4B-it-UD-Q4_K_XL.ggufModel id, sent verbatim. curl <endpoint>/v1/models shows what your server actually has loaded.
summary.prompt(a summarising system prompt)System prompt prepended to the transcript. Your @username is appended at request time so the model can flag where you were mentioned.

ai_search: — agentic search, triggered by ending a Search-tab query with ?. It reuses summary.endpoint and summary.model; these keys tune the agent:

Key Default What it does
ai_search.prompt(the shipped agent prompt)Frames the loop: how to route who/where/what, when to stop, never to answer from its own knowledge. Team names and the current scope are appended per request.
ai_search.max_steps32Cap on tool-call rounds before the model must answer with what it has — keeps a small model from looping.
ai_search.timeout_minutes4Bound on the whole run, all rounds together. Raise it for a slow server or a high max_steps.

embeddings: — semantic search. A separate server from summary, because an embedding model has to be loaded with --embeddings; see scripts/llama-embeddings.sh.

Key Default What it does
embeddings.endpointhttp://127.0.0.1:8322Base URL; matterbox appends /v1/embeddings. Note the different port.
embeddings.api_key(empty)Optional Bearer token.
embeddings.modelembeddinggemma-300m-qat-Q8_0.ggufEmbedding model id, sent verbatim.
embeddings.dim256Truncate each vector to its first n components and renormalise — a Matryoshka model stays meaningful smaller, and the on-disk vector shrinks to dim bytes. 0 keeps the model’s native dimensionality.
embeddings.auto_indextrueLet the TUI embed not-yet-indexed messages in the background (newest first, plus new arrivals). false reserves the GPU for the chat model and leaves indexing to matterbox embed.

Vectors are stored tagged with model@dim, so changing either key makes the existing ones “not ours”: every message is re-pended for embedding rather than compared against vectors from a different model. Expect a full re-index after such a change (matterbox embed, or let the background indexer catch up).

search: — ranking for both the Search tab and AI search:

Key Default What it does
search.recency_half_life_days90A match’s relevance weight halves per this many days of age, so recent discussion outranks stale chat unless an old message is much more relevant. Lower = stronger recency bias.

Composer helpers

giphy: — turn a pasted Giphy link into an inline ![alt](url) image. The expansion happens instantly and offline from the link’s id; with a key, the line is then upgraded in place with the GIF’s real title.

Key Default What it does
giphy.api_key(empty)Key from developers.giphy.com. GIPHY_API_KEY overrides it.
giphy.renditionfixed_heightWhich size to post: fixed_height (200px tall, what the Mattermost picker posts), fixed_height_small (100px), fixed_width, downsized / downsized_medium (full dimensions, size-capped — need api_key), or original (full quality, can be several MB). An unrecognised name posts the full-size original rather than reverting to the default, and the API upgrade for it fails — so check the spelling here.

language_tool: — grammar and spell check in the composer. Off by default; when on, your draft is checked as you type and mistakes are underlined in place, with alt+g opening the suggestions for the one under the cursor.

Key Default What it does
language_tool.enabledfalseTurn it on. Everything else has a working default, so enabled: true is enough.
language_tool.server_urlhttp://localhost:8010/v2The API /v2 root; the check endpoint is this + /check.
language_tool.languageautoLanguage code (en-US, en-GB, nl, …) or auto to let the server detect it per message.
language_tool.pickyfalseLanguageTool’s “picky” level — stricter style, typography and grammar rules.

The listen daemon

matterbox listen holds a WebSocket open, keeps the local cache warm, and bridges mentions and DMs to Telegram. telegram: is the delivery channel:

Key Default What it does
telegram.bot_token(empty)Token from @BotFather. Empty disables delivery entirely — the daemon still warms the cache.
telegram.chat_id(empty)Destination: a numeric chat id (message the bot, then read it from https://api.telegram.org/bot<token>/getUpdates) or an @channelusername. Also the only sender the bot obeys for two-way mode.

listen: is the behaviour:

Key Default What it does
listen.notify_on_mentiontrueForward direct @mentions (and DMs, if notify_dms). false runs the daemon as a pure cache-warmer.
listen.summarizetrueSend an LLM summary of the surrounding conversation instead of the raw message, using summary.endpoint/model. Falls back to raw text automatically when the chat server is down.
listen.notify_prompt(a one-or-two-sentence prompt)System prompt for that summary. Your @username and the message source are appended per request.
listen.respect_mutestrueSkip channels you muted in Mattermost.
listen.respect_dndtrueSkip notifications while your Mattermost status is Do Not Disturb. urgent notify actions bypass this either way.
listen.quiet_hours(empty)Suppress pushes during a daily window, "HH:MM-HH:MM" local, may wrap midnight ("22:00-08:00"). Messages are still cached — catch up with the bot’s /unread.
listen.two_waytrueAccept input from Telegram: reply to a notification to post back, tap the 👍 / ✓ buttons, run /search, /unread, /digest, /ask. Needs telegram.chat_id.
listen.notify_dmsfalseAlso forward direct messages. Off by default so a DM you are actively reading doesn’t ping your phone.
listen.notify_delay_seconds60Wait this long before sending, then re-check the server’s read state: if any client (TUI, mobile, web, on any machine) marked the channel read during the window, the notification is dropped. 0 delivers immediately with no read-check.

These options are not only about the built-in behaviour: every notify action passes the same gate, including ones you write in rules:. So notify_dms: false silences a rule’s notify on a direct message too; summarize is the default a rule’s own summarize: overrides; and respect_mutes / respect_dnd / quiet_hours are skipped only for an action marked urgent: true. notify_on_mention is the exception — it decides whether the built-in rule exists at all, and has no effect once you write your own rules.

Independent of all of the above, the daemon stays quiet about what you are looking at right now: before notifying it asks the TUI on this machine what is on screen and skips the push if that channel is open in a focused window. Rules gate on the same signal with viewing: false.

rules

With no rules: block the daemon behaves exactly as the listen: options describe — that default is a rule. Add a rules: list to take over: match on team, channel, author, message text, mention, bot, channel type, time of day or thread status, and run actions (notify, exec, webhook, send, react, mark_read, log, and the persistent-ledger state_* actions). Rules can fire on new messages, edits, deletions, reactions, or on the clock (cron: "0 9 * * 1-5").

It is the largest thing in the config by far and has its own reference: the rules engine. matterbox rules test says which rules a message would fire and why the rest wouldn’t, and matterbox rules list / stats / state show what loaded, what has fired, and what the ledger remembers.

Rules are compiled when they load, so a bad glob, regexp or action type is a startup error rather than a rule that silently never fires — and a SIGHUP reload that fails to compile changes nothing: the daemon logs the error and keeps the ruleset it already had, so a half-written edit can’t disarm a working daemon.

Integrations: Jira and GitLab

Both are opt-in and both hang off one key: v on a message that names a Jira issue or links a GitLab merge request opens it in a side panel — read-only, with inline editing when the token allows it.

jira:
  base_url: https://your-instance.atlassian.net
  email: you@example.com
  api_token: …
  projects: [ABC, PROJ]
gitlab:
  base_url: https://git.example.com
  token: glpat-…
Key Default What it does
jira.base_url(empty)Instance root. Also how matterbox recognises /browse/KEY links pointing at your instance.
jira.email(empty)Atlassian account email — the username half of the Cloud Basic-auth pair.
jira.api_token(empty)API token from id.atlassian.com → Security → API tokens. JIRA_API_TOKEN overrides it, which keeps the secret out of the file.
jira.projects(empty)Project keys whose bare ids (ABC-123) open the panel. Empty means only full /browse/KEY links are detected — so look-alikes like UTF-8 never trigger.
jira.story_points_field(empty)Pin the story-points custom field (customfield_10016). Empty auto-detects it from the instance’s field metadata; set this only if auto-detection picks the wrong field.
gitlab.base_url(empty)Instance root, also used to recognise /-/merge_requests/N links for this host.
gitlab.token(empty)Personal or project access token. Empty falls back to GITLAB_TOKEN, then to the token glab auth login stored for this host in ~/.config/glab-cli/config.yml — so a working glab setup needs no secret here.

Jira targets Cloud (/rest/api/3); Server/Data Center instances won’t work as-is. What a token needs:

Environment variables

Variable Effect
MATTERBOX_CONFIG_DIRUse this directory for config, token, cache and stats. Named verbatim.
JIRA_API_TOKENOverrides jira.api_token.
GITLAB_TOKENUsed when gitlab.token is empty (before the glab fallback).
GIPHY_API_KEYOverrides giphy.api_key.
NO_COLORDisables code-block colour regardless of code_theme.

Recipes

A calm terminal. No motion, no mouse capture, every message in full:

mouse: false
collapse_long_messages: 0
animations:
  custom_emoji: false
  image_preview: false
  inline_images: false

macOS-friendly navigation, avoiding the Mission Control collision and alt-key trouble:

keybindings:
  nav_modifier: shift
  vim_nav: reading
  bindings:
    goto_team: [super+1, super+2, super+3, super+4, super+5]
    goto_dm: super+d
    goto_feed: super+u

A quiet daemon — keep the cache warm and search fresh, notify nothing:

listen:
  notify_on_mention: false

Local AI, all of it — chat model on :8321, embeddings on :8322, background indexing on:

summary:
  endpoint: http://127.0.0.1:8321
  model: your-chat-model.gguf
embeddings:
  endpoint: http://127.0.0.1:8322
  model: embeddinggemma-300m-qat-Q8_0.gguf
  auto_index: true

Then matterbox embed to backfill, and matterbox search --semantic or the Search tab to use it.

Troubleshooting

“parse config …: yaml: line N” — a syntax error; matterbox refuses to start rather than guess. Indentation is the usual culprit. Your editor flags most of these before you save if the schema modeline is in place.

A setting does nothing. Check the spelling: unknown keys are ignored at load time. matterbox keys shows the effective keybindings, and the schema in your editor marks unknown keys as errors — an unrecognised value for an enum-ish key (nav_modifier, code_theme, …) silently reverts to the default.

It won’t start after a keybinding change. That is by design: unknown action ids, bad chords, and collisions fail loudly, and the error lists the valid action ids. matterbox keys prints the same list.

My comments vanished. matterbox rewrote the file — see First run, and when matterbox rewrites the file.

Start over. Move the file aside and let matterbox write a fresh one:

$ mv ~/.config/matterbox/config.yaml{,.bak}
$ matterbox welcome

The message cache, token and stats are separate files, so a fresh config costs you nothing but your settings.