README
¶
telegram-agent-cli
Safe access to your own Telegram account for AI agents. A single Go binary that talks MTProto as a logged-in device on your account, prints JSON, and is read-only unless you switch writes on.
$ telegram-agent-cli messages search "invoice" --limit 5
{"schema_version":"1","ok":true,"data":{"query":"invoice","messages":[…]}}

Recorded against a fictional demo account (-tags demo), not a real one.
Why
Agents (Claude Code, Codex, scripts) are useful for "what did people write me today", "find that message about the invoice", or "summarize this group". The Telegram Bot API can't do that: a bot only sees chats it was added to, never your private conversations. Reading your chats needs a user session (MTProto), and a user session can do everything you can: send, delete, leave groups, change your profile. This tool gives agents the reading part and makes everything else deliberate:
- Read-only by default, enforced on every request. A gotd middleware checks each MTProto call before it leaves the process and refuses anything that is not on an exact allowlist: the 28 read calls the tool and its MTProto library make (dialogs, history, search, resolve, media download, config) plus pings. Allowed calls are still refused when they carry a write-like argument (spending Stars, sharing location, counting a view, a referral). Read-named calls that act, such as pressing a bot button or accepting a gift offer, are not on the list.
- Every account-changing command is refused in read-only mode by the binary itself (exit 3,
before it touches anything):
send, everyauthcommand exceptstatus(logins, logout, session import) andhook install|uninstall(except--dry-run). - Reading leaves no trace. It never marks messages read, never sets online/typing status, never increments channel views.
- JSON everywhere, stable exit codes, no prompts except the interactive login.
- A Claude Code hook that asks you before any write, read-only override or access to the session files (see Safety model).
- Watchers: an optional, gated path for autonomous replies, off until you register one.
Install
go install github.com/AlexDobrushskiy/telegram-agent-cli/cmd/telegram-agent-cli@latest
Or download a release archive (macOS and Linux, amd64/arm64) from the
releases page, check it against
checksums.txt, and put telegram-agent-cli on your PATH.
From source: make build (writes bin/telegram-agent-cli), make test, make lint.
Setup
Setup changes your account and settings, so each step that writes runs with writes switched on for
that one command (TGAC_READONLY=0); everything else stays read-only.
-
Create an API app. Sign in at https://my.telegram.org with your phone number, open API development tools, and create an application (any name; platform "Desktop" is fine). Note the
api_id(a number) andapi_hash(32 hex characters). They identify your app, not your account; keep the hash private. -
Store the credentials. The hash is read from stdin so it never lands in shell history or process lists:
TGAC_READONLY=0 telegram-agent-cli auth init --api-id 1234567 --phone +15550000000 # api_hash: (paste, no echo) -
Log in, one of:
- QR (recommended).
TGAC_READONLY=0 telegram-agent-cli auth qrshows a QR code in the terminal. On your phone open Telegram → Settings → Devices → Link Desktop Device and scan it. If the account has a two-step verification password, you are asked for it in the terminal. - Login code.
TGAC_READONLY=0 telegram-agent-cli auth request-codesends a code to your Telegram app, thenTGAC_READONLY=0 telegram-agent-cli auth verify --code 12345. With two-step verification, put the password in an environment variable and pass its name:auth verify --code 12345 --password-env TG_2FA(or runauth password --password-env TG_2FAwhenverifyreturns"status":"password_needed"), withTGAC_READONLY=0as above. - Import a Telethon session you already have:
TGAC_READONLY=0 telegram-agent-cli auth import-telethon --session ~/path/to/name.session(reads the auth key from Telethon's SQLite file, then checks it withgetMe;--forceoverwrites an existing session,--no-verifyskips the check).
- QR (recommended).
-
Check it:
telegram-agent-cli doctorreports config, file modes,authorizedandreadonly. The session shows up in Telegram under Settings → Devices astelegram-agent-cli; terminating it there logs the tool out. -
Guard your agent (Claude Code):
telegram-agent-cli hook install --dry-run # show the change TGAC_READONLY=0 telegram-agent-cli hook installOptionally copy
skills/telegram-agent-clito~/.claude/skills/so agents know how to use the tool.
Commands
All output is JSON on stdout. --chat accepts a marked id (users positive, groups negative,
channels and supergroups -100…), @username, a t.me link, +phone, or a dialog name (exact
match first, then substring; an ambiguous name fails and lists the candidates). Prefer ids in
scripts. Dates are ISO 8601, UTC.
Read
| Command | What it does |
|---|---|
chats list [--limit 30] [--unread] [--archived] [--type user|bot|group|supergroup|channel|forum] [--query TEXT] |
Dialogs with unread/mention counts and the last message |
chats show --chat X |
One chat: type, username, members/about where available |
messages list --chat X [--limit 50] [--asc] [--after DATE] [--before DATE] [--min-id N] [--max-id N] [--offset-id N] [--topic N] |
Messages of one chat, newest first; page older with data.next_offset_id |
messages search QUERY [--chat X] [--from Y] [--limit 30] [--after DATE] [--before DATE] |
Full-text search, account-wide without --chat |
messages show --chat X --id N[,N…] |
Specific messages |
messages context --chat X --id N [--around 8] |
A message with its neighbours |
messages mentions [--chat X] [--unread] [--limit 30] [--scan 50] |
Messages that @-mention or reply to you (--unread does not mark them read). Without --chat or --unread it searches your --scan most recent groups |
messages wait-new --state FILE [--chats ID,…] [--dms] [--groups] [--mentions] [--timeout 600] [--interval 30] [--since DATE] |
Block until new incoming messages arrive after the watermark in FILE, return them, advance the watermark. First run records the watermark and returns []. Empty list on timeout |
contacts search QUERY [--limit 20] |
Your contacts and chats (mine), then Telegram's public directory (public) |
media download --chat X --id N --output DIR |
Save a message's photo/voice/file; returns the path |
me · doctor · version · auth status |
Account, health, build, login state |
telegram-agent-cli chats list --unread --limit 20
telegram-agent-cli messages list --chat @example_user --limit 20 --asc
telegram-agent-cli messages search "flight" --after 2026-01-01
telegram-agent-cli messages context --chat -1001234567890 --id 4242 --around 5
telegram-agent-cli media download --chat 123456789 --id 77 --output /tmp/tg-media
Write (needs TGAC_READONLY=0)
| Command | |
|---|---|
send text --chat X --message TEXT [--reply-to N] |
Send a message |
send file --chat X --path FILE [--caption TEXT] [--reply-to N] |
Send a file |
In read-only mode these fail with exit 3 ("code":"readonly") before connecting. Run them only with
the account owner's OK on the exact text: TGAC_READONLY=0 telegram-agent-cli send text --chat 123 --message "…".
Login and maintenance (needs TGAC_READONLY=0, except auth status)
auth init · auth qr [--timeout 3m] · auth request-code · auth verify --code C [--password-env VAR] ·
auth password --password-env VAR · auth import-telethon --session PATH · auth logout (terminates
this session). In read-only mode they fail with exit 3 ("code":"readonly") before doing anything.
Watchers
watch reply --watcher NAME --chat ID --text TEXT [--reply-to N] [--dry-run] · watch list ·
watch check --watcher NAME. See Watchers.
Hook
hook claude-pretooluse (the hook itself, reads stdin) · hook install [--settings PATH] [--binary PATH] [--dry-run] ·
hook uninstall [--settings PATH] [--dry-run].
JSON contract
Success:
{"schema_version": "1", "ok": true, "data": {"messages": [{"id": 4242, "chat_id": 123456789, "chat_name": "Example User",
"date": "2026-05-01T09:30:00Z", "from_me": false, "sender_id": 123456789, "sender_name": "Example User",
"text": "see you at 10", "reply_to": 4240, "media": {"type": "photo", "size": 183211}}]}}
Message fields: id, chat_id, chat_name, date, from_me, sender_id, sender_name, text,
and when present edited, reply_to, forwarded_from, media{type,name,mime,size,url,title,question},
album, service, views, mentioned. Chats: id, name, type, username, unread,
mentions, pinned, archived, muted, last{id,date,from_me,text,media}.
Failure:
{"schema_version": "1", "ok": false, "error": {"code": "flood_wait", "message": "telegram: FLOOD_WAIT, retry in 30 s", "details": {"retry_after": 30}}}
| Exit | error.code |
Meaning |
|---|---|---|
| 0 | ok | |
| 1 | internal |
unexpected error |
| 2 | usage |
bad flags or arguments |
| 3 | auth, readonly |
not logged in, or a write in read-only mode |
| 4 | not_found, ambiguous |
chat/message not found, or a name matched several chats |
| 5 | telegram, flood_wait |
Telegram RPC error or rate limit (details.retry_after seconds) |
| 6 | lock_busy |
another call held the account lock longer than TGAC_LOCK_WAIT |
| 7 | watcher_refused |
a watcher rule refused the reply (reason in the message) |
schema_version changes only on breaking changes to these shapes.
Safety model
Four layers, each independent:
-
MTProto middleware (in the binary). Every request is checked against an exact allowlist of read methods (pinned by a test that walks every request type in gotd, so an upgrade cannot widen it) before it is sent. Writes pass only when
TGAC_READONLYis0,false,nooroff(case-insensitive, surrounding spaces ignored); unset or anything else means read-only. Login methods are allowed only insideauthsubcommands, and approving another device's login (auth.acceptLoginToken) is never allowed. The commands themselves are gated too: in read-only modesend, everyauthcommand butstatus, andhook install|uninstallrefuse before touching Telegram, the data dir or the settings file. A command is a write unless it is on the binary's read list, so a new or renamed one cannot slip through. This is the main barrier; the hook's jobs are catching theTGAC_READONLYoverride, protecting the data dir and the Claude Code settings, and flagging calls off the read list. -
Read-only default in the agent's environment.
hook installsetsTGAC_READONLY=1in Claude Code's settings, so a write needs an explicit override in the command itself. -
The PreToolUse hook. Before Claude Code runs a tool,
telegram-agent-cli hook claude-pretooluseinspects it (Bash, and Monitor's background commands, the same way) and forces a permission prompt ("ask") when the call:- runs
telegram-agent-cliwith a subcommand off the read list (send,authother thanstatus,hook install, unknown ones), including throughsudo,env(also-S/--split-string),timeout,script,busybox,sh -c,bash -lc,eval,ssh host [--] …,watch,tmux,hyperfine,find -exec, shell functions,$(…), pipes, heredocs fed to a shell, shell scripts it can read, orgo run …/cmd/telegram-agent-cli.xargswithout-Iand GNUparallelappend their input, so the fixed part must already be a full read command (xargs telegram-agent-cli messages show --chat 1 --idpasses,xargs telegram-agent-cliasks); - runs it from interpreter code (
python -c,node -e,ruby/perl -e,php -r,awk,osascript, heredocs, script files): string literals after the name are its argv (['telegram-agent-cli', 'auth', 'logout']), a literal holding a whole command line is parsed as shell, and a call the hook can't resolve (a variable, concatenation,%-format) asks; - runs content it can't see (a pipe into a shell or interpreter from anything but literal
echo/printf/cat,bash <(…),source <(…),sh -c "$(…)",eval "$(…)", a script named by a variable, a script that isn't a readable regular file) when that content is decoded on the way in (base64 -d,xxd -r,openssl enc -d,uudecode,rev,tr), or when the visible command also names the binary, aTGAC_variable, the watcher variable, the data dir or an MTProto library. Otherwisecurl … | bash,eval "$(ssh-agent -s)"andbash "$SCRIPT"pass, since the hook runs on every Bash call, not only Telegram ones; - sets
TGAC_READONLYto a false, empty or computed value in any form:export TGAC_READ''ONLY=0,read/printf -v/for/declare -n,${TGAC_READONLY:=0}, or code such asos.environ['TGAC_READONLY'] = str(0)and$ENV{TGAC_READONLY}=0(only a plain literal that stays read-only, such as'1', passes); also aTGAC_name built from parts; - sets or exports the watcher session variable (
TGAC_WATCHER_SESSION, orwatcher_session_envfrom the data dir'sconfig.json); - sets or reads
TGAC_HOME(TGAC_HOME=…,$TGAC_HOME,os.environ['TGAC_HOME']); a bare mention is fine; - references a data dir:
$TGAC_HOMEand the default~/.config/telegram-agent-cli. Each word is normalised first (quote removal,~,$HOME,${HOME},$XDG_CONFIG_HOME, variables set earlier in the command, the cwd andcd,./..///, globs and braces, symlinks) and compared case-insensitively; a longer name such astelegram-agent-cli-notes.mdis not the dir. Atelegram-agent-cli/segment followed by a data-dir file name (config.json,session.json,peers.json,pending-login.json,watchers.json,answered.jsonl,PAUSE-ALL) counts whatever comes before it; - reads a directory tree that contains a data dir:
grep -r,rg(with--hidden, or a path inside.config),find … -exec/-deleteor piped toxargs,tar,zip,cp -r,rsyncand similar on~,~/.configor/; - changes Claude Code settings:
settings.json/settings.local.jsonin any.claudedir or$CLAUDE_CONFIG_DIR, andmanaged-settings.json. File tools ask when the text they write or replace nameshooks,PreToolUse,disableAllHooks, aTGAC_variable or the binary, or when thehooks/envpart of the file would change. Bash asks only when a settings file is the target of a write: a redirect,tee,mv/rm(also of a.claudedir), the destination ofcp/ln/rsync,sed -i/perl -i,dd of=,git checkout/restore. Reading or mentioning it passes, and code asks only when it names a settings file and also writes a file; - mixes an MTProto library (Telethon, gotd, Pyrogram…) with a session file;
- uses Read on
config.json,session.json,peers.jsonorpending-login.jsonin the data dir, Grep on the data dir or on a directory that contains it (~,~/.config,/, or no path while the cwd is one of those), or Edit/Write/MultiEdit/NotebookEdit anywhere under it; - writes code (any string field of Write/Edit/MultiEdit/NotebookEdit, not the replaced text) that
overrides
TGAC_READONLY, sets the watcher variable, touches the data dir, writes Claude Code settings, or pairs an MTProto library with a session. Documentation files (.md,.txt,.rst…) are not scanned, sogo testormakecan't run unchecked code that was written silently.
The name inside an argument does not count:
git commit -m "…telegram-agent-cli send…",grep telegram-agent-cli README.mdor aclaude -p "…"prompt pass silently. Values of-m/--message/--body/--titleand grep/rg/git greppatterns are treated as text, unless they contain an expansion such as$(…).How Claude Code handles "ask" (verified 2026-10-28 on Claude Code 2.1.307): interactive sessions show the permission prompt in every mode, including
bypassPermissions; in headlessclaude -pruns, where nobody can answer, the call is denied. - runs
-
Watchers have their own gate (registry, scopes, rate limits, pause files); see below.
Limits. The hook is a second check, not a sandbox:
- Code running as your OS user can read the session file. The hook only sees the tool call, not what a program does once it runs.
- Interpreter code is followed through string literals, not executed: code that computes the command, builds a variable name in a way the hook can't read, or decodes its own payload can get past it. An agent can also write its own MTProto client.
- Programs that run other files on their own (
make,go test,npm run) are not followed; their files are checked when an agent writes them with the file tools or a heredoc, not when they run. - Content the hook can't see (a download piped into a shell,
$(…)run as code, an unreadable script) is only flagged when it is decoded on the way in or the visible command links to the account. Obfuscated content with no visible link to the tool isn't caught; the binary's own write gate (TGAC_READONLY) is the main barrier. - The prompt is a question to you, not your approval. In
bypassPermissionsmode it is the only thing between the agent and the command, so read each one before approving.
The session file is the real boundary: it is full access to the account. If you run agents you don't trust, run them as a different OS user or in a container that cannot read the data dir.
Watchers
A watcher is a long-running agent session allowed to answer certain incoming messages on its own,
for example a support helper that replies to people who write first. It is the only autonomous send
path, and nothing is allowed until you add an entry to watchers.json in the data dir yourself
(edit it by hand; the hook asks before an agent writes there):
{
"helper": {
"session": "the-watcher-session-id",
"chats": {"123456789": "customer who wrote first"},
"scope": "dm-first",
"groups": false,
"mentions_only": true,
"max_chars": 300,
"per_hour": 20,
"per_chat_seconds": 20,
"backoff_minutes": 15,
"dir": "/path/to/helper-workdir",
"approved": "who approved this and when"
}
}
Everything a watcher is trusted with lives in this entry, which only you edit: session is the id
the calling session must present (env TGAC_WATCHER_SESSION, configurable), chats is the
allowlist (chat id → note). The optional dir is the watcher's own workdir; a PAUSE file there
pauses it, and the watcher may keep notes there, but nothing in it grants anything. Every send
attempt is logged to <data dir>/watchers/NAME/sends.jsonl, where a watcher can't rewrite its own
rate history; <data dir>/watchers/NAME/PAUSE also pauses it. watch reply works in read-only
mode (the guard lets exactly that one messages.sendMessage through) and refuses (exit 7, reason
in error.message, a stable id in error.details.rule) unless:
- no
PAUSE-ALLin the data dir, the watcher is registered with a valid entry, noPAUSEfor it; - the caller's session id equals the entry's
session(refusals never show either value); - the text is 1…
max_charscharacters, and the per-watcher (per_hour: its log or its ledger rows, whichever is larger), global (global_per_hour: all watcher logs or the ledger) and per-chat (per_chat_seconds) limits hold; - the chat is a 1:1 with a person (not a bot, not Saved Messages) that fits the scope:
listed= inchats;contacts= a mutual contact or listed;dm-first= they wrote first (for flows that answer new people); or a group (never a broadcast channel) with"groups": truethat is listed; - 1:1: the latest message is theirs and no watcher answered it yet; you haven't written in the chat
within
backoff_minutes(watcher replies, known fromanswered.jsonl, don't count); - group:
--reply-tois required; the target is not yours, not inanswered.jsonl, and no message of yours replies to it; withmentions_onlyit mentions you or replies to you; you haven't written in the group withinbackoff_minutes; - a chat too busy to verify these rules from its recent history is refused, not guessed.
Checks and the send run under a lock in the data dir, so two watchers cannot answer one message.
The registry is re-read after taking that lock and again right before sending: removing the entry,
changing session, delisting the chat or adding a PAUSE stops a reply already in flight.
--dry-run runs every check and reports what it would send. watch list and watch check --watcher NAME validate the registry offline and show only whether a session binding is set. Stop
everything at once with touch <data dir>/PAUSE-ALL.
A typical loop: messages wait-new --state state/wm.json --dms → the agent drafts a reply →
watch reply --watcher helper --chat ID --text "…".
Telegram terms and ban risk
Using your own api_id with a third-party client is allowed by Telegram's
API Terms of Service. What gets accounts limited or banned is
behaviour that looks like spam or abuse: bulk or cold messaging, adding many contacts, joining many
groups, sending at machine speed, or hammering the API. Keep sends human-paced and in conversations
people started, respect FLOOD_WAIT (the tool returns it as exit 5 with retry_after; wait, don't
loop), and expect Telegram to terminate sessions it considers suspicious. You are responsible for
what your agents send from your account.
Configuration
Data dir: $TGAC_HOME, default ~/.config/telegram-agent-cli/ (mode 0700, files 0600):
| File | |
|---|---|
config.json |
app credentials and device info (below) |
session.json |
the MTProto session: full account access |
peers.json |
cache of chat access hashes |
pending-login.json |
between auth request-code and auth verify |
.lock |
one process talks to Telegram at a time |
watchers.json, answered.jsonl, PAUSE-ALL |
watcher registry, shared ledger, global stop |
config.json keys:
| Key | Default | |
|---|---|---|
api_id, api_hash |
from my.telegram.org | |
phone |
international format, used by code login | |
device_model |
telegram-agent-cli |
shown in Settings → Devices |
system_version |
OS name | |
app_version |
binary version | |
lang_code |
en |
|
watcher_session_env |
TGAC_WATCHER_SESSION |
env var naming the calling agent session |
global_per_hour |
30 |
watcher sends per hour across all watchers |
Environment:
| Variable | |
|---|---|
TGAC_HOME |
data dir |
TGAC_READONLY |
0/false/no/off (case-insensitive, spaces trimmed) enables writes; anything else, or unset, is read-only |
TGAC_LOCK_WAIT |
seconds to wait for the account lock (default 90) |
TGAC_WATCHER_SESSION |
the calling agent session's id, for watch reply |
CLAUDE_CONFIG_DIR |
where hook install finds settings.json (default ~/.claude) |
Claude Code hook details
hook install edits ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json, or
--settings PATH). It sets env.TGAC_READONLY to "1" and adds two PreToolUse entries, matcher
Bash|Monitor and matcher Read|Edit|Write|MultiEdit|NotebookEdit|Grep, each running
<absolute path of this binary> hook claude-pretooluse with "timeout": 10 and
"onFailure": "block". That fails closed: if the hook cannot start, crashes or times out, the tool
call is blocked. onFailure needs Claude Code 2.1.295 or later; older versions ignore it and let the
call through. The hook itself gives up after 3 seconds and asks instead of waiting.
It keeps every other key and their order, appends to an existing entry with the same matcher instead
of adding a second one, moves the guard out of entries with an older matcher (such as plain Bash),
and updates the path if the binary moved. It writes atomically and saves the previous file as
settings.json.bak-YYYYMMDD-HHMMSS (with a -N suffix if that name exists). When settings.json
is a symlink, the link is kept: the target file is rewritten and the backup sits next to the target,
named after it. Running it again changes nothing. --dry-run prints the diff and the resulting
settings. Use --binary PATH when the binary is reached through a package manager's stable path.
hook uninstall removes the entries and TGAC_READONLY=1.
For other agent harnesses, the hook reads Claude Code's PreToolUse JSON on stdin
({"tool_name":"Bash","tool_input":{"command":"…"},"cwd":"…"}) and prints
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"…"}}
or nothing; it always exits 0. hook claude-pretooluse --help prints its own help.
Development
make build, make test, make lint. Re-record the demo GIF with vhs demo/demo.tape (needs
VHS and jq); it builds with -tags demo, which swaps
Telegram for an in-memory account of fictional chats, and writes docs/demo.gif.
License
MIT, see LICENSE. Security issues: see SECURITY.md.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
telegram-agent-cli
command
Command telegram-agent-cli gives AI agents safe, read-only-by-default access to a Telegram user account.
|
Command telegram-agent-cli gives AI agents safe, read-only-by-default access to a Telegram user account. |
|
internal
|
|
|
cli
Package cli holds the cobra commands.
|
Package cli holds the cobra commands. |
|
config
Package config holds the data dir layout, config.json, env switches and file-mode helpers.
|
Package config holds the data dir layout, config.json, env switches and file-mode helpers. |
|
guard
Package guard refuses every MTProto request that is not on an exact read allowlist before it leaves the process.
|
Package guard refuses every MTProto request that is not on an exact read allowlist before it leaves the process. |
|
hook
Package hook is the Claude Code PreToolUse guard and its settings installer.
|
Package hook is the Claude Code PreToolUse guard and its settings installer. |
|
output
Package output holds the JSON envelope and the stable error/exit-code contract.
|
Package output holds the JSON envelope and the stable error/exit-code contract. |
|
tgclient
Package tgclient wraps gotd: connection, locking, session storage, peer resolution and the JSON shapes.
|
Package tgclient wraps gotd: connection, locking, session storage, peer resolution and the JSON shapes. |
|
tgclient/tgfake
Package tgfake is an in-memory tgclient.Telegram for tests: no network.
|
Package tgfake is an in-memory tgclient.Telegram for tests: no network. |
|
waitnew
Package waitnew finds incoming messages newer than a per-chat watermark.
|
Package waitnew finds incoming messages newer than a per-chat watermark. |
|
watch
Package watch holds the watcher registry, per-watcher state, the send logs and the gate that decides whether a watcher may send one reply.
|
Package watch holds the watcher registry, per-watcher state, the send logs and the gate that decides whether a watcher may send one reply. |