tg drives a Telegram user account over MTProto from the command line. It's built for
agents testing Telegram bots: send a command, wait for the reply, press inline buttons, and read
the result, with one command per step and compact output that always shows buttons.
It isn't a chat client and it isn't a Bot API library. Anything without a dedicated command
goes through tg raw, which covers the whole MTProto API.
uv tool install 'git+https://github.com/ruslan-korneev/tg-kit@v0.3.0' # puts `tg` on PATH
uv tool install 'tg-kit[qr] @ git+https://github.com/ruslan-korneev/tg-kit@v0.3.0' # + QR-code loginFrom a local checkout, for development:
uv tool install --editable . # or --editable '.[qr]'The agent skill lives in skills/telegram/ and follows the
Agent Skills format, so the skills CLI installs it
into Claude Code, Codex, Cursor, Gemini CLI, OpenCode and the other agents it supports:
npx skills add ruslan-korneev/tg-kit -g # every detected agent, user-wide
npx skills add ruslan-korneev/tg-kit -g -a claude-code -a codex # chosen agentsLeave out -g to install into the current project instead (.claude/skills/, .agents/skills/,
…). The skill needs the tg CLI on PATH (see Install) and a logged-in account (see Log in).
A Telegram pane inside Claude Code. Your Telegram folders and chats sit next to the session; ⏎ on
a message hands it to Claude; a bot is tested with single keys; and any chat can be given to an
agent, from "follow it" to "do the work it asks for". The pane reads and writes Telegram only
through tg.
It is two plugins from this repository's marketplace. dialogs-pane is the pane.
dialogs-pane-guard holds a delegate Worker to its grant and hard stops. Without the guard
everything works except delegate, which does not start.
claude plugin marketplace add ruslan-korneev/tg-kit # or a local checkout's path
claude plugin install dialogs-pane@tg-kit
claude plugin install dialogs-pane-guard@tg-kitThe pane needs tg v0.3.0 or later on PATH (it uses tg watch, tg folders and tg topics)
and a logged-in account (see Log in). tg topics --help tells you whether yours has them.
The pane's colours are its palette option, a row of /config: tokyo-night by default, any
theme tg themes shows (see Themes), NAME256 for tmux, theme to follow Claude Code's own
theme, or ansi for a terminal with 16 colours.
Type /tg in a session. The pane opens on your first Telegram folder. In fullscreen, from 110
columns, it docks beside the transcript: the chat list on top, the open chat below. In a narrower
terminal it sits above the prompt and shows one view at a time. A "start here" block names the
keys that matter until you press d.
- The pane takes the keys while its header shows
● keys.ctrl+x tabhands them over, andEscgives them back to the prompt. mlists every key the current view can use, and each entry is pressable./tg helpprints the same table in the transcript;/tg statusshows what runs.hgoes one level up.qhides the pane; the footer then keeps the unread count and what runs./tgbrings the pane back.
- Folders and chats.
1–9open your first nine Telegram folders, in Telegram's order.hfrom the chat list lists every folder, the Archive, For you and Bots.ssearches every chat.y(For you) gathers what waits for you: agents' drafts first, then people who mention you or wrote to you. - Hand a message to Claude. ⏎ on a message opens the send sheet, and a digit sends it with an
intent:
⏎ 1sends a bot's reply as Fix. The intents follow who wrote the message: Fix, Reproduce, Explain and Test for a bot; Do it, Explain and Draft reply for a person.apicks another agent,ishows what it will read, andxorvmark several messages first. Claude gets a<tg-packet>: the whole message record, with phone numbers, usernames, ids and names masked. - Test a bot. In a bot chat, digits press the buttons of the message under the cursor, and
nwrites to the bot.r … rrecords your sends and presses into a script under.tg-recordings/;1on the sheet that follows hands it to an agent as a live test.fdiffs a reply with the last reply to the same message.⏱marks each reply's latency. - Watch and summarize.
wtells you about new messages in a chat, above the prompt too while the pane is hidden.tsummarizes a chat. - Give a chat to an agent.
aopens the chat's agent card, a ladder:1follow (new messages go to Claude's context),2summarize,3draft (the agent drafts, you send with ⏎),4auto (it replies within limits) and5delegate (it replies and does the work in your repo). Each kind of chat gets only the levels that fit: in a bot chat or a channel an agent only reads, and delegate is for person chats. A draft waits as a row of its chat, in For you and above the prompt.
- Stop everything:
0stops every follow, autopilot and Worker at once, from any view./tg stopdoes the same from the prompt, even while Claude is mid-turn. Settings stay, and the card restarts a chat. - Your keys, your writes. What you send with your own key goes out as yours: a message after
n, a draft you read whole, a button press. What an agent sends on its own (auto, delegate, an agent replaying a recording) needs the chat inallow.toml. The pane never adds it silently: the card asks in Claude Code's dialog, No first, showing the exacttg allow add … --yes, and only your Yes runs it. The add is kept;tg allow removetakes it out. BotFather stays read-only, andreadonly = truein tg's config refuses every write. - What
delegatemay do. The Worker works in a fresh git worktree of each repo you grant, never in your checkout. It runs only the repo's own checks (read offpackage.json,pyproject.tomlwithuv.lock,Makefile,justfile,Cargo.toml,go.mod), local git and read-only commands. Everything else is refused,sh -c,evaland scripts it writes included. It never runstgand never reads tg-kit's config, so the chat has one voice: the agent that replies as you. Spending money, pushing, merging, deploying, sharing your files, deleting outside the worktree and agreeing to a deadline or price on your behalf stop and ask you (hard stops). The one thing the guard cannot read around: a check you allow runs the repository's own code. Grant a check only to code you trust to run.
The design is in docs/design/dialogs-pane.md (the packet,
autopilot, delegate and its guard) and
docs/design/dialogs-pane-ux.md (the views, the keys, the
states, and decisions 1–12).
-
Store the app credentials from https://my.telegram.org in the system keychain once:
tg auth set-app --api-id 123456 # prompts for api_hash (hidden)Or set
TG_KIT_API_ID/TG_KIT_API_HASHin the environment, which takes precedence. -
Log in. This step is interactive (code + 2FA), so a person runs it:
tg login # account "main"; --account NAME for more; --qr for QR login tg whoami
Each login creates a new device session named tg-kit (it shows up in Telegram → Settings →
Devices). Don't copy auth keys from other tools. One key used by two clients at once risks
AUTH_KEY_DUPLICATED, and then Telegram revokes the key for both.
tg logout --yes ends the session on Telegram's side and deletes the local files.
tg send @example_bot /start --wait 10s # send, then print the reply (with buttons)
#677791 09-29 14:02 @example_bot: Choose an option
[0:0 cb "Settings"] [0:1 cb "Help"]
[1:0 url "Docs" → https://example.com]
tg press @example_bot 677791 "Settings" # press; prints the edited message / new replies
tg press @example_bot 677791 --row 0 --col 1
tg read @example_bot --after 677791 # anything that arrived later, oldest first--wait subscribes to the chat before sending, then collects new messages and edits
until nothing new arrives for --quiet (1.5 s by default) or the budget runs out. A message
edited several times is shown once, in its final state, as ✎×N. If nothing arrives, the
command exits 6. press always waits (5 s by default). Only incoming messages count, so
tg send me … --wait always exits 6: Saved Messages never replies.
Button labels match exactly first, then case-insensitively, then ignoring emoji, spaces and punctuation. If a label matches nothing or more than one button, the command exits 4 and lists every button. Pressing a button depends on its type:
| Type | Result |
|---|---|
callback (cb), game |
GetBotCallbackAnswer; prints toast: / alert: / url:, then the effects. A bot that never answers the callback triggers a warning, not a failure |
reply keyboard (reply) |
sends the label as a message, as the Telegram app does |
url, login, webapp, switch, copy, profile |
prints what the button holds and presses nothing |
phone, geo, peer, poll, buy, disabled, 🔒 (needs 2FA) |
refused, exit 7 |
# read
tg dialogs [-n 30] [--unread] [--query TEXT] [--archived | --folder ID|TITLE]
tg folders [--counts | --chats] # Telegram folders in Telegram's order
tg topics PEER [-n 30] [-q TEXT] # a forum's topics (see Forum topics)
tg read PEER [-n 20] [--before ID] [--after ID] [--since 2h|2026-09-01] [--from PEER] [--reverse] [--topic ID] [--full]
tg msg PEER ID [ID…] # full messages, never truncated
tg search PEER QUERY [--filter photo|video|doc|link|voice|music|gif|round|…] [--since …] [--from PEER]
tg search --global QUERY
tg resolve SPEC # what a peer spec points at; check before writing
tg info PEER # bio / description / bot commands / members / linked chat
tg download PEER ID [--out DIR] [--max-size 200] # prints the path; never overwrites
tg drafts
tg transcribe PEER ID # voice → text (Premium or its trial)
tg watch PEER… [--jsonl] [--events message,edit,typing,delete] [--after ID | --since 2h] [--idle-exit 30s] [--topic ID]
# write: every command supports --dry-run and goes through the guard
tg send PEER TEXT|- [--reply-to ID] [--topic ID] [--no-preview] [--md|--html|--plain] [--silent] [--wait 10s]
tg send-file PEER PATH|URL [--caption TEXT] [--as-doc] [--topic ID] [--wait …]
tg edit PEER ID TEXT|-
tg delete PEER ID… [--only-me] --yes
tg forward FROM ID… --to PEER [--topic ID]
tg react PEER ID 👍 # "" removes the reaction
tg draft PEER TEXT|- [--topic ID]
tg mark-read PEER
# bots
tg press PEER ID (BUTTON | --row R --col C | --data STR | --data-hex HEX) [--wait 5s]
tg start @bot [PAYLOAD] [--wait …] # /start, or messages.StartBot with a deep-link payload
tg inline @bot QUERY [--peer PEER] # list results
tg inline @bot QUERY --pick N --to PEER [--topic ID] # send result N
# allowlist (writes to these peers skip --yes)
tg allow add PEER… --yes · tg allow list · tg allow remove ID|PEER…
# anything else
tg raw METHOD [PARAMS_JSON|-]
tg themes [NAME] # preview the colour themes (piped: their names)Global options work before or after the command: --account/-a NAME, --json,
--timeout SECONDS (default 30, plus any --wait, plus flood_sleep_threshold so that an
allowed flood-wait sleep is never cut short), -v (debug logs and tracebacks). --color auto|always|never and --theme NAME go before the command only (tg --color never read …);
see Colour.
For multi-line text, use - with stdin instead of shell escaping:
tg send @example_bot - <<'EOF'
line one
line two
EOFText longer than 4096 characters is split at paragraph, line or word boundaries and sent as several messages, and every id is printed. A caption over 1024 characters is refused (exit 2): send the file with a short caption, then reply to it with the text.
me / self (Saved Messages) · @username or username · t.me/username · t.me/username/45
or t.me/c/123/45 (a message link, where commands that take an ID accept it) · a numeric user id ·
-100… for channels and supergroups · -… for basic groups · +phone (contacts only) ·
title:Chat Name (must match exactly one dialog title; writes also need --yes).
A numeric id needs an access hash. tg looks it up in the local entity cache first, and on a miss
scans the latest 500 dialogs once. If it's still missing, the command exits 4 and suggests
resolving by @username.
A bare positive id that is not a known user is also tried as the channel -100<id> and the basic
group -<id>, so the "peer id" a Telegram app shows for a group or channel works as typed. A
user wins; if a channel and a group both match, tg refuses (exit 2) and lists the marked ids.
The default is compact, one line per message, with buttons on indented lines below it:
#677791 09-29 14:02 @example_bot: Choose a bot ⏎ second line
[0:0 cb "My bot"] [0:1 cb "Back"]
[1:0 url "Docs" → https://example.com/docs]
#677792 09-29 14:02 me ↩677791: /start
#677793 09-29 14:03 Alex K. [photo 1280×720 214KB] caption text
#677794 09-29 14:05 @example_bot ✎×2: updated text {👍3 ❤️1}
#812 09-29 14:06 [topic:12] @example_bot ↩809: a reply inside forum topic 12
Legend: #id, local time (with the year if it isn't the current one), [topic:N] in a forum
(see Forum topics), then the sender (me, @username, a display name, or the channel title).
Markers: ↩ID reply, ⤳X forwarded from
X, via @bot, ✎ edited (✎×N edits seen while waiting), 📌 pinned. ⏎ stands for a newline
in the text. Media tags: [photo W×H SIZE], [video …], [gif …], [voice 0:12],
[videonote 0:09], [audio NAME 3:05], [sticker 😀], [document NAME SIZE], [poll "…"],
[geo], [contact "…"], [dice 🎲=4], [service pin_message]. {👍3} are reactions. Links
hidden behind text show on an indented line as [link "text" → url], above the buttons. ✎
marks every edited message, including bot edits that the Telegram apps don't label. edited in
JSON is the last edit time, and edits counts only the edits seen during a --wait.
Listings cut text at 300 characters and append … (+N chars, tg msg PEER ID). --full and
tg msg never cut. Each call prints at most 200 messages.
On a terminal the text output has colour; it is presentation only. Ids, times and markers are
dim, [topic:N] and me are the accent, bots pink, other senders a colour by id (like Telegram's
name colours), media tags teal, unread counts red and mentions orange, error: red, fix: green.
Stripping the colour gives the exact plain line, and the plain line is what a pipe, a file,
--json and --jsonl always get. Colour is on when the stream (stdout or stderr, each on its
own) is a terminal, NO_COLOR is unset and TERM is not dumb. FORCE_COLOR=1 turns it on
anyway, and --color never|always or TG_COLOR=never|always overrides all of these.
COLORTERM=truecolor gives the colours of a named theme (see Themes); otherwise it uses the 16
ANSI colours, the same for every theme.
The theme is --theme NAME, else TG_THEME=NAME, else theme = "NAME" in config.toml, else
tokyo-night. An unknown name exits 2 and lists the themes (from config.toml, the message
names the file). tg themes shows every theme on a terminal, and tg themes NAME shows one: the
same sample of messages, dialogs, a topic and an error, on the theme's own background, in
truecolor even without COLORTERM. Piped, it prints only the names; --json gives each name,
appearance (dark or light), and which is the default and which is current.
catppuccin-mocha · dark |
catppuccin-latte · light |
tokyo-night · dark |
rose-pine-moon · dark |
gruvbox-dark · dark |
nord · dark |
dracula · dark |
kanagawa · dark |
Seven themes also come as NAME256 (all but tokyo-night): the same colours, with the few
moved that would land on one cell where tmux draws 256 colours. Use one of those when your tmux
does not pass truecolor through.
The colours are each upstream's published values, mapped onto tg's roles. Where a colour was too
faint on its own background (below 3:1), or would read as an unread count, a mention or me,
it was moved by lightness or hue; palettes/themes.json records every such change (adjusted).
The pictures are rendered from tg themes NAME by dev/theme_previews.py. The themes, with
thanks:
- Catppuccin Mocha (
catppuccin-mocha): catppuccin/palette, MIT - Catppuccin Latte (
catppuccin-latte): catppuccin/palette, MIT - Tokyo Night (
tokyo-night): folke/tokyonight.nvim, Apache-2.0 - Rosé Pine Moon (
rose-pine-moon): rose-pine/palette, MIT - Gruvbox Dark (
gruvbox-dark): morhetz/gruvbox, MIT - Nord (
nord): nordtheme/nord, MIT - Dracula (
dracula): dracula/dracula-theme, MIT - Kanagawa Wave (
kanagawa): rebelot/kanagawa.nvim, MIT
stdout carries a single JSON document and nothing else. Each message has this shape:
{
"id": 677791, "chat_id": 123456, "date": "2026-09-29T11:02:00Z",
"sender": {"id": 123456, "kind": "bot", "username": "example_bot", "name": "Example"},
"out": false, "text": "Choose an option", "reply_to": null, "topic_id": null,
"forward": {"from_id": -1000000000777, "from_name": "@example_news", "date": "…"} ,
"edited": null, "edits": 0, "pinned": false, "service": null, "via_bot": null,
"media": {"type": "photo", "size": 219136, "name": null, "mime": "image/jpeg",
"duration": null, "width": 1280, "height": 720},
"keyboard": "inline",
"buttons": [[{"row": 0, "col": 0, "type": "cb", "text": "Settings", "data_b64": "c2V0"}]],
"reactions": [{"emoji": "👍", "count": 3}],
"links": [{"text": "Privacy policy", "url": "https://example.com/privacy"}]
}sender.kindis one ofselfuserbotgroupchannelsupergroupunknown.mediakeeps only the keys that are set.media.typeisphotovideogifvideonotevoiceaudiostickerdocumentpollgeocontactdicegameinvoicewebpage, or a new kind's TL name.- Button objects may also carry
url,query+same_peer,user_id,copy_textandrequires_password. topic_idis the message's forum topic:1for General,nulloutside forums (see Forum topics).- Global search adds
chat_title.
Per command:
| Command | Document |
|---|---|
read, msg, search |
a list of messages |
dialogs |
a list of chats (below) |
folders |
a list of folders (below) |
topics |
a list of forum topics (below) |
send, send-file, edit, forward, start |
{"sent": [...]}, plus "replies": [...] with --wait |
press |
{"button": {...}, "answer": {"alert", "toast", "url"} | null, "replies": [...]} |
raw |
the TL result's to_dict(), with bytes as base64 and dates as ISO |
--dry-run |
the plan: {"action", "target", "topic", "steps": [{"method", "params"}], "guard"} |
tg watch streams, so it takes --jsonl instead of --json (see below).
tg dialogs prints one chat per line: id, kind, @username or title, (forum) for a
supergroup with topics, 📌 pinned, 🔕 muted, [N unread, M mentions], then the last
message. With --json each chat is:
{"id": 123456, "kind": "bot", "title": "Example", "username": "example_bot",
"unread": 2, "mentions": 0, "pinned": true, "muted": false,
"last_id": 677791, "last_date": "2026-09-29T11:02:00+00:00", "last_text": "Choose an option",
"forum": false}kindis one ofselfuserbotgroupsupergroupchannelunknown.mentionsis Telegram's count of unread mentions of you.muted: the chat's own mute, or, when it has none, your default for that kind of chat (private chats, groups, channels). If Telegram does not return a default, that kind counts as not muted and a note goes to stderr.pinnedis pinned in the list shown: the chat list, the archive (--archived), or the folder (--folder).forumis true for a supergroup with topics:tg topicslists them,--topicreads, writes and watches one.
tg folders lists your Telegram folders in Telegram's order, All chats wherever you put it:
2 Work [3 unread]
0 All chats [41 unread]
11 Team Projects (shared)
[{"id": 2, "title": "Work", "kind": "folder", "unread": 3},
{"id": 0, "title": "All chats", "kind": "all", "unread": 41},
{"id": 11, "title": "Team Projects", "kind": "shared", "unread": 0}]-
kind:all(All chats, always id 0),folder, orshared(a folder joined or shared by an invite link). -
unreadcomes only with--counts: the folder's unread chats that are not muted. It looks at the latest 500 chats of the chat list and of the Archive, plus the chats the folders list by name. -
tg folders --chatsis one pass for every folder. It implies--countsand makes the same Telegram calls. Each text line ends in· N chats. With--jsonit prints one document:{"at": "2026-10-11T09:30:00+00:00", "scan": 500, "folders": [ {"id": 2, "title": "Work", "kind": "folder", "unread": 1, "pinned": [-1000000000205], "chats": [-1000000000205, 123456]}, {"id": 0, "title": "All chats", "kind": "all", "unread": 1, "pinned": [123456], "chats": [123456, -1000000000205, 202]}, {"id": 11, "title": "Team", "kind": "shared", "unread": 0, "pinned": [], "chats": [202]}], "dialogs": [{"id": 123456, "kind": "bot", …}, {"id": -1000000000205, …}, {"id": 202, …}]}chatsholds every member in the folder's order, ids only: pinned first, then newest last message first.pinnedholds the folder's pinned chats; for All chats, the chat list's pins.dialogshas onetg dialogsrow for every chat in somechatslist, and no other chat. A row'spinnedis Telegram's own pin for the chat.scanis how many of the latest chats were looked at, in the chat list and again in the Archive. All chats and chat types see only those; listed chats are always fetched.atis when the pass ran, in UTC. A FLOOD_WAIT longer thanflood_sleep_thresholdexits 5. -
tg dialogs --folder ID|TITLElists one folder's chats: its pinned chats first, in its order, then by last message. A title matches exactly, then case-insensitively. An unknown folder exits 4 with the list of folders. -
Membership follows Telegram's rule: pinned or included chats are in; excluded ones are out; otherwise the folder's chat types (contacts, non-contacts, groups, channels, bots), minus muted, read or archived chats when the folder says so. An unread mention keeps a muted or read chat in. A shared folder holds only its listed chats. Chats joining by type come from the latest 500 of the chat list and of the Archive.
-
Listed chats are fetched by id, so an old one still shows. Telegram rate-limits that by chats: about 180 in 30 seconds. Past that, tg sleeps out the FLOOD_WAIT or exits 5. A listed chat Telegram refuses (left, banned, deleted) is left out and not asked for again for 6 hours.
-
--archivedis the Archive, which is not a folder, so it does not combine with--folder.
A forum is a supergroup with topics. Every message in it belongs to one topic, and every forum
has General, topic 1. Other topics' ids are the ids of the messages that opened them.
tg topics PEER lists a forum's topics in Telegram's order, pinned first: id, title, 📌
pinned, 🔒 closed, (hidden) (only General can be hidden), [N unread, M mentions], then the
last message:
12 Releases 📌 [5 unread, 1 mention] — v2.3 is out
1 General — welcome
-q TEXT is Telegram's search by title. With --json each topic is:
{"id": 12, "title": "Releases", "icon_color": 7322096, "unread": 5, "mentions": 1,
"pinned": true, "closed": false, "hidden": false,
"last_id": 810, "last_date": "2026-09-29T11:02:00+00:00", "last_text": "v2.3 is out"}- A chat that is not a forum exits 2: a user, bot, basic group or channel without a request, a supergroup without topics on Telegram's answer.
- Each message's
topic_id([topic:N]in text) comes from the message itself, with no extra request. In a forum,reply_tostays what Telegram says: a message posted straight into a topic replies to the topic's id, so itsreply_toequals itstopic_id. The text line then shows no↩; a reply to another message inside the topic shows↩IDas usual. --topic IDonread,watch,send,send-file,forward,inline --toanddraftworks in that topic. It looks the topic up first: one that does not exist exits 4, and a chat that is not a forum exits 2.read --topic IDreads that topic's messages (Telegram's replies to the topic). General keeps no list of its own, soread --topic 1picks General's messages out of the latest 1000 of the forum, and a note on stderr says where to go on (--before ID) if it found fewer than-n.send --topic IDposts into the topic; with--reply-to MSGit replies to MSG inside the topic (Telegram'stop_msg_id). Every part of a long text goes into the topic.--waitcollects only that topic's replies.send --reply-to MSGin a forum needs no--topic: tg reads MSG (one request, only for a reply in a supergroup) and replies inside MSG's topic, which the plan and the stderr target line then name. A--topicthat is not MSG's topic exits 2; MSG in a closed topic exits 7.draft --topic IDsaves the draft in that topic.- Known gap:
presstakes no--topic. Pressing a reply-keyboard button sends its text into the chat, which in a forum is General. watch --topic IDtakes one PEER and prints only that topic's messages, edits and typing. Telegram does not say which topic a deletion was in, so deletions are reported only for messages this watch has printed. A replay of General looks at up to 200 messages of the forum.- A write into a closed topic exits 7 before anything is sent, even with
--yes. The allowlist holds chats: an allowlisted forum is allowed in all its topics, and a topic never allows a chat that is not. The plan (--dry-run) names the topic as"topic": {"id", "title", "closed"},nullwithout--topic, and the first stderr line of the write ends with it.
tg watch PEER… stays connected and prints each event in the watched chats as it arrives: one
JSON object per line with --jsonl (flushed per line), compact lines without it. It runs until
Ctrl-C or SIGTERM (exit 0), until --idle-exit DURATION passes with nothing printed (exit 0),
or until the process reading its stdout pipe exits (exit 0). It is read-only.
{"event": "message", "id": 677795, "chat_id": 123456, "date": "2026-09-29T11:06:00Z", "sender": {"id": 123456, "kind": "bot", "username": "example_bot", "name": "Example"}, "out": false, "text": "Done", …}
{"event": "ready", "date": "2026-09-29T11:06:01.5Z", "peers": [{"chat_id": 123456, "kind": "bot", "username": "example_bot", "title": "Example"}]}
{"event": "typing", "chat_id": 123456, "date": "2026-09-29T11:06:10.2Z", "sender": {"id": 123456, "kind": "bot", "username": "example_bot", "name": "Example"}, "action": "typing"}
{"event": "message", "id": 677796, "chat_id": 123456, …}
{"event": "edit", "id": 677796, "chat_id": 123456, "edited": "2026-09-29T11:06:14Z", …}
{"event": "delete", "chat_id": 123456, "date": "2026-09-29T11:06:20.9Z", "ids": [677796]}messageandeditare the message object above plus"event". Aneditcarries the message's whole new state.editsis always 0 here. Both directions count:outis true for your own messages, including ones sent from another device.typing:actionistyping,cancel(stopped),record_audio,record_video,record_round,upload_photo,upload_video,upload_audio,upload_document,upload_round,choose_sticker,choose_contact,geo_location,game_play,text_draft, … (the TL action name in snake_case, so new kinds still come through). Telegram repeats atypingabout every 5 s while it lasts and treats it as over after about 6 s without one.sendercomes from the local cache and may be{"kind": "unknown", "name": "<id>"}.dateis when tg received it.delete:dateis when tg received it. In private chats and basic groups Telegram does not say which chat a deletion is in, so tg reports deletions there only for messages this watch has printed. Channels and supergroups report every deletion.readyis printed once, after tg has subscribed and replayed. Lines before it are the replay of--after ID(one PEER) or--since 2h|2026-09-01: up to 200 messages per chat, oldest first, asmessageevents. Lines after it are live. A message that shows up in both is printed once.chat_idinreadyis the id that events carry: formethat is your own user id.--events message,edit,typing,deletepicks the kinds (default all).readyis always printed.--topic IDkeeps one forum topic (one PEER; see Forum topics).- Connecting, resolving and the replay must finish within
--timeout; afterreadythere is no deadline. A lost connection exits 1 witherror: lost the connection to Telegram; rerun with--after LAST_IDto replay what was missed.
Compact lines, with [chat] added when several chats are watched:
ready: watching @example_bot
#677795 09-29 14:06 @example_bot: Done
~ 09-29 14:06 @example_bot: typing
✗ 09-29 14:06 deleted #677796
| Code | Meaning | What to do |
|---|---|---|
| 0 | ok | |
| 1 | unexpected error or timeout | read the message; a send that timed out may still have been delivered, so check with tg read |
| 2 | usage: bad arguments | fix the command |
| 3 | not logged in / session revoked | a person runs tg login |
| 4 | peer, message, button or account not found, or ambiguous | use the listing printed with the error |
| 5 | FLOOD_WAIT longer than the auto-sleep threshold | wait the printed number of seconds |
| 6 | --wait got no reply |
the bot is down or slow; --wait longer, or tg read --after ID later |
| 7 | refused by the guard or a policy; nothing was sent | see Safety |
Errors go to stderr as one line (error: …) followed by fix: …. There are no tracebacks
unless you pass -v. Ctrl-C exits 130 and SIGTERM exits 143, both after a clean disconnect;
tg watch exits 0 on both, since stopping is how it is meant to end.
~/.config/tg-kit/config.toml holds your settings (override the directory with
TG_KIT_CONFIG_DIR). tg only reads it:
default_account = "main" # needed only with several accounts
readonly = false # true → every write exits 7 (allowlist changes and logout too)
flood_sleep_threshold = 30 # sleep through FLOOD_WAITs up to this many seconds
theme = "tokyo-night" # the colour theme (tg themes); --theme and TG_THEME beat itThe write allowlist lives in ~/.config/tg-kit/allow.toml, and tg allow is its only writer:
tg allow add @example_bot --yes # resolves the peer and stores its id with labels
tg allow list # 123456 bot @example_bot Example Bot (added 2026-09-29)
tg allow remove @example_bot # or the id from the list[[peer]]
id = 123456 # the guard decides by this only
kind = "bot" # labels for humans, as of `added`
username = "example_bot"
name = "Example Bot"
added = "2026-09-29"- A write (send, edit, react, press, start, inline --pick, …) to a peer whose id isn't in the
allowlist needs
--yes. Without it, tg prints the resolved target and the exact plan, then exits 7. tg allow addneeds--yesor an interactive confirmation, because an allowlisted peer skips--yesfor good.removeonly tightens the guard, so it needs neither.delete,logoutandrawwrite methods always need--yes, andreadonlyblocks them all. A raw method is a read only when its name starts withGet/Search/Check/Resolve. There are exceptions:- every
auth.*method is a write; - every
Export*method is a write (it mints tokens and invites), exceptchannels.ExportMessageLinkandstories.ExportStoryLink; - a few read-named methods that change state or touch the 2FA password are writes:
messages.GetBotCallbackAnswer,messages.GetMessagesViews,contacts.GetLocated,account.GetTmpPassword, …. The full list israw.py::_WRITES_NAMED_AS_READS.
- every
- Usernames of write targets are always resolved over the network, never from the cache. A username that moved to another account can't carry an allowlisted write to the new owner.
- A
title:target always needs--yes. - The first stderr line of every write names its target:
→ @example_bot (bot, id 123456). --dry-runprints the plan object that the real run would execute, then sends nothing.- A forum topic never widens the allowlist: the allowlisted chat covers its topics. A write into a closed topic is refused (exit 7) whatever the flags.
- There's no "allow all my own bots" switch. No reliable API says which bots the account owns,
so add them with
tg allow add.
tg raw users.GetFullUser '{"id": "me"}'
tg raw messages.GetHistory '{"peer": "@example_bot", "limit": 5}'
tg raw messages.GetBotCallbackAnswer '{"peer": "@example_bot", "msgId": 5, "data": {"$hex": "6f6b"}}'
tg raw messages.SendReaction '{"peer":"me","msg_id":5,"reaction":[{"_":"ReactionEmoji","emoticon":"👍"}]}' --yes- Method names:
messages.GetHistory,messages.GetHistoryRequestandmessages.getHistoryall work. An unknown name exits 2 and suggests close matches. - Params: snake_case or camelCase keys.
- Peer-typed params (InputPeer, InputUser, InputChannel and InputDialogPeer, decided from the request's own annotations) take a peer spec. InputMessage params take a bare id.
- Bytes: a plain string (UTF-8),
{"$bytes": …},{"$hex": …}or{"$b64": …}. - TL objects:
{"_": "InputMessageID", "id": 5}. Dates: ISO or unix time. - An omitted required integer (offsets,
hash) becomes 0, an omitted nullable param becomes null, andrandom_idis generated.
Everything lives under ~/.config/tg-kit/sessions/ (the directory is 0700, the files 0600):
| File | Content | Written |
|---|---|---|
NAME.session |
auth key + DC (Telethon StringSession format) | at login and on a DC migration; read-only otherwise |
NAME.cache.db |
entity cache: id → access hash, username, name, kind | SQLite in WAL mode with a busy timeout, one short transaction per batch |
NAME.json |
who the account is (for tg accounts) |
at login and by whoami |
allow.toml (the allowlist) sits next to config.toml and is shared by all accounts, since peer
ids are global.
This split is what lets parallel tg processes work. Telethon's default SQLiteSession holds
one database for everything, and a second process then fails with database is locked. Here,
concurrent runs read the auth file without a lock and queue on the cache for milliseconds.
Update state isn't persisted: --wait subscribes fresh each time and never catches up.
uv sync
uv run pytest # unit tests: no network
TG_KIT_LIVE=1 uv run pytest tests/live # parallel runs against the logged-in account
uv run ruff check src tests && uv run ruff format --check src tests
uv run mypy # strict