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" --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 --json # stream messages (Socket Mode)
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
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 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.- 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