Minimal multi-account channel mailbox CLI — Slack and Telegram — built for LLM agents and shell scripts.
The core idea: an agent shouldn't manage timestamps or connections. read is
a mailbox — it prints only what's new since that consumer's last read and
advances a cursor. send posts. Everything is a one-shot command; no daemon
required. read/listen emit JSONL by default (structured, reliable for
agents & scripts); add --text for human-readable output.
msgr send "#ops" "deploy finished"
echo "long report..." | msgr send standup # alias from config
msgr read "news:@daily_updates" # only new posts since last read
msgr read "#alerts" --as pnl-loop # independent cursor per agent
msgr read "#alerts" --last 50 --text # human-readable (ISO time)
msgr read "#alerts" --thread 1712345678.123 # one whole thread (root + replies)
msgr read "#alerts" --peek # look without consuming
msgr read "#alerts" --json # JSONL for scripts
msgr send "work:@alice" "lunch?" # direct message
msgr read "#alerts" "#ops" --as watcher --block --timeout 3600
# long-poll: block until any
# address has new messages,
# print them (exit 3: timeout)
msgr list # what the bot can see
msgr listen # ingest into the durable spool
msgr read "acct:*" --as reception --follow # durable, resumable live stream
msgr context "acct:*" --since 1d # readable digest of recent chat
msgr search "deploy" "failed" --since 6mo # grep all history incl. archives
msgr react "#ops" 1712345678.123 white_check_mark
msgr edit "#ops" 1712345678.123 "fixed wording" # amend our own message
msgr delete "#ops" 1712345678.123 # remove our own message
msgr login news # one-time Telegram session setupURI-like: the account is the scheme, the target is written in the
platform's own syntax (like mailto:foo@bar.com).
dl:#ops Slack channel in account "dl"
dl:@alice DM with a person
dl:@ the operator's own DM
tg:@some_channel Telegram channel/handle
tg:-100123456 Telegram chat by numeric ID
robot:foo@bar.com email recipient (email accounts, when supported)
robot:INBOX mail folder
#ops @alice @ no prefix = the default account
dl:* EVERY channel of an account (spool-backed read only)
standup any alias from the config
The default account is $MSGR_ACCOUNT, else default_account in the
config, else the only account configured.
First found of: $MSGR_CONFIG, ~/.config/msgr/config.toml,
/etc/msgr/config.toml.
default_account = "work"
[accounts.work]
platform = "slack"
bot_token = "xoxb-..."
app_token = "xapp-..." # only needed for `listen` (Socket Mode)
owner = "U0123456789" # optional: the operator's user ID — their
# messages get an authenticated "(owner)" mark
# in read/listen output, so agents can tell the
# operator apart from anyone merely claiming to be
[accounts.family]
platform = "slack"
bot_token = "xoxb-..."
[accounts.news]
platform = "telegram"
api_id = 12345
api_hash = "0123456789abcdef"
phone = "+15551234567"
session = "~/.local/state/msgr/news.session" # default: <env>.session
[aliases]
standup = "work:#standup"
alerts = "work:C0123456789" # private Slack channels: use the ID
daily = "news:@daily_updates"By default an account reads anything its account can see. allow_read
restricts it — for accounts with more access than the agent's business:
[accounts.personal]
platform = "telegram"
allow_read = ["@exchange_news", "@status_updates"] # nothing else readableAccounts are quiet by default: read/listen always work, but
send, react, and uploads are refused until the account is armed:
[accounts.work]
allow_post = true # whole account writable
[accounts.news]
# no allow_post -> pure notetaker: can read, can never post
[accounts.other]
allow_post = ["#ops", "@alice"] # only these addressesread polls an account's history on demand — great with zero setup, but a
consumer reading the raw real-time socket (listen) loses any message that
arrives during a restart/reconnect gap. The spool closes that gap:
- Run
msgr listen <account>as an always-on ingester. Instead of printing events, it appends every one to a durable, append-only per-account log (add--printto also echo to stdout for debugging). The sole-listener flock guarantees exactly one writer. read <addr…> --as <name>then auto-detects the ingester (via that same flock) and reads from the spool with its per-consumer cursor. A reader can restart and backfill from where it left off — nothing is silently lost. If no ingester is running,readfalls back to the on-demand history poll (unchanged), somsgr read --last 10on a laptop still works with no setup.msgr read "acct:*" --as receptionconsumes every channel of the account (a receptionist);acct:#chanfilters to one channel.--blocktails the spool live (returns after the first new batch; a fresh consumer starts "from now").--followis a continuous stream: it keeps tailing and prints each new event, advancing the cursor per event, until--timeout(0 = forever) — a durable, resumable drop-in forlisten. Like--block, a fresh cursor starts "from now" and an existing cursor resumes/ backfills the gap — a new follower never replays history. Add--from-startto opt into emitting the retained backlog first, then tailing.--last Nand--thread <ts>are always direct one-shot API calls, never spooled.
The journal doubles as ~a week of situational-awareness memory: it is
time-retained (SPOOL_RETENTION_DAYS, default 7 — tunable). Rotation drops
events older than the window (and caps size); _seq stays strictly increasing
across the prune (a persisted high-water mark survives even a prune-to-empty),
so cursors are never silently overtaken.
msgr context [address] [--since <spec>] renders the journal as a compact,
human/agent-readable digest (not JSONL) — load "what's going on in the chats"
in one shot. It reads the spool (offline, complete incl. thread replies).
msgr context "acct:*" # all channels, everything retained (~week)
msgr context "acct:#ops" --since 1d # one channel, last day
msgr context "acct:*" --since today
msgr context "acct:*" --thread 1712345678.123 # zoom into one thread
msgr context "acct:*" --json # filtered raw events (JSONL) for scripts- Address grammar mirrors
read:acct:*/acct:= all channels;acct:#chan= one channel; omitted = default account, all channels. --sinceaccepts7d/1d/2h/today/YYYY-MM-DD; default is everything retained.- Rendering groups by channel (readable
#namewhen cached, else the id), orders chronologically, nests thread replies under their parent, marks the(owner), and shows attachments as[attachment: <name>]— referenced, never inlined. If no journal exists yet, it prints a one-line note (runmsgr listen <account>to start one).
Layout (under the state dir — $MSGR_STATE_DIR, else
~/.local/state/msgr, created 0700):
<state>/spool/<account>.jsonl append-only log; each line is a normal
read entry + an internal "_seq"
<state>/spool/archive/<account>/<year>-W<week>.jsonl
weekly archive segments (plain JSONL,
kept indefinitely)
<state>/cursors/<account>/<name>.json per-consumer last-consumed _seq
_seq is a strictly-increasing, never-reused integer. The spool auto-rotates
once it grows past a cap or its oldest event ages out of the retention
window — rotation moves those events to the weekly archive segments (nothing
is deleted); _seq continues across rotation. If a consumer falls so far
behind that its cursor was rotated away, read prints a one-line warning to
stderr and resumes from the oldest retained event (never silently skips,
never errors).
msgr search TERM... [--addr <address>] [--since <spec>] [--limit N] [--json] greps the account's entire record — weekly archives plus the hot
spool — and renders hits in the context digest format. Deliberately just a
glorified grep over plain text files: no index, no database, nothing to
operate. Every TERM is a case-insensitive regex and ALL must match a
message's sender or text (AND semantics).
msgr search "deploy" "failed" # all history, default account
msgr search okx --addr "acct:#ops" # one channel
msgr search maintenance --since 6mo # bounded lookback
msgr search error --limit 50 --json # newest 50 raw events for scripts--sinceadditionally accepts2w/6mo/1yhere and incontext.--limitkeeps the newest N hits (default 200); the header says when truncation happened.
msgr edit <addr> <ts> <text> (chat.update) and msgr delete <addr> <ts>
(chat.delete) amend the bot's own Slack posts — same addressing and
arming rules as send; edit reads stdin on -. Slack itself enforces
authorship (only our own messages are editable); its error is surfaced
as-is.
The ingester journals other people's edits and deletions as typed entries:
an edit keeps the original message id (still the valid reply/thread
target) with the new text plus edit_of and prev_text; a deletion
has empty text plus delete_of. --text and context render them as
(edited) <new text> / (deleted). Bot edits, our own bot's amendments,
and Slack's unchanged-text housekeeping (link unfurls) are filtered at
ingest, so normal entries keep their exact shape and plain consumers are
unaffected.
read and listen emit one JSON object per message (same shape on every
platform):
| field | meaning |
|---|---|
id |
the message ID — pass it verbatim to --thread / react / replies |
time |
ISO8601 UTC timestamp |
account |
msgr account name |
channel |
channel / chat / folder id within the account |
addr |
canonical reply address (account:channel) |
thread |
parent/root message id, or null |
from |
sender display name |
user |
sender's platform id (raw), or null |
owner |
true if the sender is the configured operator (authenticated) |
text |
message body |
trust, reactions, files, files_note |
present when applicable |
Reformat time, never id (the id is the platform's message key). Add
--text for a human-readable rendering instead of JSON.
- Slack: the bot must be a member of channels it reads or posts to.
Public channel names resolve via API; private channels need an ID or alias
unless the app has the
groups:readscope.@personresolves by username, display name, orU…ID.listenuses Socket Mode (needs an app-level token withconnections:write); minimum bot scopes for the rest:chat:write,channels:read,channels:history,users:read(+groups:historyfor private channels,im:writefor DMs,reactions:writefor react). - Telegram: uses a user-account MTProto session (Telethon). Run
msgr login <account>once interactively; after that reads/sends are one-shot. The session file is as sensitive as being logged in — guard it. read --blockis the long-poll gate for agent loops: if nothing is new it blocks (cheap API polling, no LLM anywhere) until one of the addresses has messages, then prints them and advances cursors atomically — wake and data in one command. Exit 3 on--timeout. A fresh cursor starts "from now" (a blocking read never fires on old history). Add--peekto block without consuming (shell-gate pattern: a supervisor blocks, then spawns an agent that reads for itself). Slack thread replies are included by default;--no-threadsto exclude.read --thread <id>returns one whole thread — the root message plus every reply, reactions included — in the same JSON schema as a timeline read. It's a one-shot read (no cursor, no blocking):conversations.historyreturns only top-level timeline messages, so historical in-thread replies are invisible to a plainread; pass the id of the root (or any message in the thread) to pull the full exchange. Slack-only for now.- Attachments:
readdownloads files (≤20 MB) to~/.local/state/msgr/files/and appends[attachment: /path]to the message — point your agent's file-reading tool at the path to view images.--no-filesskips downloads. Slack needs thefiles:readscope; without it you get afiles_notenaming the undownloadable attachments. - Message fields: each message carries
ts(the platform message ID — pass it verbatim to--thread/react; Slack's is an epoch-like key, Telegram's is the integer id) andtime(ISO8601 UTC, human/LLM readable). Reformattime, neverts. - Cursors live in
~/.local/state/msgr/cursors/, one per(consumer, account, channel). First read of a channel returns only the last 20 messages rather than all history. - Not yet: Telegram in
listen/channels; email as a platform (the model maps cleanly: env = mailbox account,#folderchannels with IMAP UID cursors,@addresssends via SMTP, MIME attachments to the spool,allow_readscoping to folders).
pip install -e . # Slack send/read: stdlib only
pip install -e '.[telegram,listen]' # + Telethon, + Socket Mode listen