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 react "#ops" 1712345678.123 white_check_mark
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>/cursors/<account>/<name>.json per-consumer last-consumed _seq
_seq is a strictly-increasing, never-reused integer. The spool auto-rotates
(keeping the most recent lines) once it grows past a cap; _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).
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