usage - WireCatLabs/tg-cli GitHub Wiki

Using tg

tg lets you, or your AI agent, read and write your own Telegram account from the terminal. This page is the tour: from the first login to reading, sending and running groups, in the order you will need it. Open it when you start with tg, or when you want to know what is possible before you ask your agent.

After reading it you know how to name a chat, how to read and search without anyone seeing that you looked, how to send safely, and how to get answers a script can use. Every command and option is in the command reference; this page explains how they fit together.

Terms used on this page:

  • Profile: one Telegram account set up on this computer, with its own login and settings. The first word of a command picks it (profiles).
  • Chat: any conversation — a one-to-one chat, a group, a channel or Saved Messages.
  • Local store: the database on this computer where tg keeps every message it reads (the local store).
  • Send guard: the checks every send passes — a read-only profile, the list of allowed recipients and an hourly limit (the send guard).

Each command does one thing, prints its answer and exits. Only tg personal watch, tg personal serve and tg personal mcp stay running, and each of them says so.

tg [profile] [options] <resource> <action> [arguments]

What you can do

Area What you can do Start with
Read chats, messages, one message and its neighbours, forum topics tg personal chats list, tg personal messages list <chat>
Catch up other people's unread messages; what you promised and what is open tg personal inbox, tg personal review
Voice turn voice messages into text, on this computer tg personal messages transcribe
Files download a message's files or a whole chat's tg personal messages download
People contacts, a person's profile, a bot check tg personal contacts list, tg personal contacts profile
Search messages, files, dates, people; discussions by meaning tg personal search messages, tg personal search conversations
Send text, files, voice, replies, scheduled messages tg personal messages send
Change edit, forward, pin, delete, react, vote, mark read tg personal messages edit, tg personal reactions add
Organize folders tg personal chats folders list
Groups members, invite links, join requests, topics, admins tg personal chats members list, tg personal chats link create
Follow new messages as they arrive; keep the local store current tg personal watch, tg personal server start
Check what a command did; what a profile may do tg personal runs list, tg config show

Read a chat with your agent

Once your account is connected, ask for a short recap. This task reads messages; it sends nothing.

Your request:

Summarise the five latest messages in Book club. Show decisions and open questions. Send nothing.

Command:

tg personal messages list "Book club" --limit 5 --json

Example agent answer:

Decision: the next discussion is Thursday at 18:00.

Still open: which meeting place to use.

I can show the messages behind this recap. Nothing was sent.

The recap above is fictional. Ask the agent to open the source messages before relying on its interpretation. Setup, message actions and permissions are explained in the sections below.

Get started

npm install -g @wirecat/tg-cli
tg personal setup         # guided app registration, login and agent skill
tg personal chats list --limit 5  # your newest chats
tg personal messages list me  # Saved Messages, the latest 20

Allow about five minutes for setup. History downloads are separate: choose a chat and an amount before tg personal messages sync <chat> --last 100. An agent can read tg skill show without logging in; tg personal setup --agent codex selects the skill for one agent explicitly. tg setup --help explains the flags. Nothing more is needed to read.

To discover the arguments for a task, use tg commands search messages --json for one command or tg commands messages --json for a group. Both include global options and exit codes. Inspect each command path in a separate call; tg commands --json returns the whole tree.

Log in

tg personal setup is the first-run command. It defaults to automatic app registration and QR login; --app browser and --method phone choose the alternatives. For login alone, or to finish an interrupted or expired login, use:

tg personal session start               # QR code: Settings → Devices → Link Desktop Device
tg personal session start phone         # phone number, the code Telegram sends, your 2FA password
tg personal session start phone --sms   # the same, asking for the code by SMS instead of in the app
tg personal session start --qr-file login.png  # the QR code as a picture, for an agent to show you

The first login also asks for your own Telegram app from my.telegram.org; --app auto fills in the site for you. Both steps, and what is kept where: login, sessions and profiles.

No secret is ever an argument. The app hash and the 2FA password are asked without showing what you type; the login code and the phone number are asked, or read from stdin. An argument is visible to every process on the machine in ps, and stays in your shell history.

For CI, TG_API_ID and TG_API_HASH give the app without the keyring; they win over it.

tg personal account show       # who this profile is logged in as; the phone as its last four digits
tg personal account show --show-phone  # the whole phone number
tg account list                # every profile on this computer and the account each is logged in as
tg personal account sessions list  # every device and app logged in to the account; ends nothing
tg personal session end        # log out on Telegram's side, and delete the session here

session end ends the session on Telegram's side too: the device disappears from the app's list. To end a session from elsewhere, use the app: Settings → Devices.

Profiles: the first word

Several accounts live side by side. The profile is the first word, not an option:

tg personal chats list    # profile "personal"
tg work chats list        # profile "work"
export TG_PROFILE=work    # or for a whole shell session

The first word is the profile whenever it is not a command. So a profile cannot be called chats: such a name is refused, with the reason. A name is letters, digits, dot, dash and underscore.

Each profile has its own session, its own app, its own settings and its own list of allowed recipients. TG_PROFILE_LOCK pins a process to one profile, so an agent cannot pick one with fewer limits (profiles).

Naming a chat

Wherever a command takes <chat>, it accepts:

  • a title, or part of one: "Book club", book
  • an id: -1001234567890
  • a username: @example_channel
  • me for Saved Messages

A title that fits more than one chat is an error that lists the candidates with their ids. tg never guesses: a message sent to the wrong conversation cannot be taken back. Take the id and repeat the command with it. An id never changes, so once you have it, use it.

messages show and messages context also take a msg: locator in place of the chat and the id, as search messages --json prints it for each hit.

A person (<person>, in contacts show) is an id, an @username or part of their name.

Reading

Reading marks nothing read. No command below shows the other side that you looked. Only tg personal chats mark-read and tg personal messages list --mark-read do (below).

Chats

tg personal chats list                     # newest first, archived chats included
tg personal chats list 'unread_count>0 kind:group'  # only groups with unread messages
tg personal chats list 'title:*book*'   # titles containing "book"; at least 3 characters
tg personal chats show "Book club"         # kind, unread count, last message, who is in it

--kind is one of dialog (one-to-one), group, channel or saved. The filters look at every returned chat. Groups and channels have their own section.

Messages

tg personal messages list "Book club"           # the latest 20, oldest first
tg personal messages list "Book club" --limit 50
tg personal messages list "Hiking" topic:12   # one forum topic; topics list shows the ids
tg personal messages show "Book club" 4242      # one message
tg personal messages context "Book club" 4242   # it, and 5 messages either side
tg personal messages context "Book club" 4242 --before-n 2 --after-n 10

In context, the message you asked for is marked ◀ in the terminal and "anchor": true in JSON.

topic:<id> in the query reads back from the topic's newest message, or from id<…. The General topic (1) is refused: Telegram gives its messages no topic id, so read the whole chat instead. With --offline, topic: keeps the stored messages of that topic.

Message links

tg personal messages link <chat> <message> or tg personal messages link <msg:locator> returns { item: { ref, url, access, reason } }. Channel and supergroup permalinks can be public or restricted; a link grants no membership. Dialogs, basic groups and Saved Messages return a locator. Offline validates the stored target and returns no permalink. A locator for another account is refused. This singular command differs from messages links, which explains conversation relationships (topic search).

What needs an answer

tg personal inbox            # other people's unread messages, in every chat
tg personal inbox 'date>2h'  # everything that came in during the last two hours
tg personal inbox --new      # what arrived since the last --new — for scheduled runs
tg personal inbox --new --jsonl  # the same for a script: one message per line

inbox shows other people's messages, never yours, each with its chat. It leaves out muted and archived chats unless a message mentions you or replies to you; --all takes them in. It says on stderr how many it left out.

inbox --new moves a saved point. The next --new starts from where this one stopped, so each message is shown once. The first --new looks back 24 hours. inbox without --new, and inbox 'date>…', leave the point where it is. Plain inbox answers the same until the messages are read in the app, since it marks nothing read.

One run reads at most 20 chats; the rest are a missing entry, with the step that reads one. In a chat with more than --limit messages waiting, the newest are shown, and stderr says how to read the rest.

Who owes what: review

tg personal review                         # the last 3 days
tg personal review 'date>2026-09-23T09:00'  # from where the last review ended
tg personal review 'chat:"Book club"' --json

Every message — yours and other people's — in every chat where something happened since the date> in the query. It is for working out what you promised, what you are waiting for and what is still unclear; sorting it is your job or an agent's. It marks nothing read.

The answer's first next step, review.next, is the next review: it starts where this one ended (date>"<until>"). Run the next review from that step, and nothing falls between two reviews. When the review is incomplete — too many chats at once, or a chat cut short to its newest 300 messages — it says so, and it is better not to move the boundary. It reads at most 20 chats in one run.

Unanswered questions

tg personal review --unanswered            # questions nobody answered in 24 hours
tg personal review 'chat:Neighbours' --unanswered 4h

--unanswered [hours] keeps only questions waiting for you or for a group's admins. A question is a message with ? in it (a link's ? does not count), or a reply to you or to an admin. It is answered when you or an admin replied to it, or were the next to speak after the person who asked. Questions younger than the hours given (24 by default) are left out: nobody has had time to answer. When a group's admins are not known, the command says so, and only your answers count.

Saved voice transcripts count in this filter too. Add --transcribe to transcribe voice messages that have no saved transcript before the questions are selected. A voice message that could not be transcribed keeps the review incomplete: an empty result does not prove there are no unanswered questions. Keep the previous boundary while the answer has a missing entry.

Voice messages

tg personal messages transcribe "Book club" 4242  # by Telegram where it can, else a model here
tg personal messages transcribe "Book club" 4242 --local  # only the model on this machine
tg personal messages list "Book club" --transcribe  # every voice message shown that has no text yet
tg personal inbox --transcribe
tg personal review --transcribe
tg personal messages list "Book club" --transcribe --model gigaam-v3

Local transcription requires a complete mono or stereo Ogg Opus recording without a fixed ten-minute cutoff. Longer recordings need more memory and processing time.

Telegram transcribes for Premium accounts, and a few messages a week on the free trial. Without it, a model on this machine does the work, and the recording never leaves the computer. A model is downloaded once, and only when you ask:

tg personal models audio list          # the models, which is downloaded, which is the default
tg personal models audio download parakeet-v3  # once, checked against the sha256 this version expects
Model Languages Size
parakeet-v3 — the default 25: Bulgarian, Czech, Danish, German, Greek, English, Spanish, Estonian, Finnish, French, Croatian, Hungarian, Italian, Lithuanian, Latvian, Maltese, Dutch, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Swedish, Ukrainian 670 MB
gigaam-v3 Russian — the best of the three for Russian 232 MB
gigaam-v3-ctc Russian — a little faster, rougher with capital letters 225 MB

--model picks another model for one command, beside --transcribe or in messages transcribe; transcribe_with and speech_model in the settings choose the defaults (configuration). A transcript is kept in the local store and reused by message lists, inboxes and reviews. Calling messages transcribe again can request a new transcript or run recognition again. --transcribe can take minutes.

Files

tg personal messages download "Book club" 4242 --output-dir ~/Downloads  # one message's files
tg personal messages download "Book club" --all --output-dir ~/tg-files  # every file of the chat, newest first

Photos, files, videos and voice notes are saved; the folder is created if it is missing. A file keeps its own name; one without a name gets the message id. A download never overwrites a file. With --all, a name already taken gets the message's id in front. --all remembers where it stopped in a small file beside the downloads, and the same command continues from there. Saved files are readable only by you. Sending files, formats and searchable text: file attachments.

People

tg personal contacts list              # people you have a one-to-one chat with, newest first
tg personal contacts list 'name:*ann*' --order name
tg personal contacts show @example_user  # their bio and the chats you share
tg personal contacts lookup            # who has a phone number — asks for it, or reads it from stdin
tg personal contacts sync              # your whole Telegram contact list into the local store
tg personal contacts profile @example_user  # flags, last seen, registered, messages per shared chat
tg personal contacts context @example_user --chat "Book club"  # their latest messages there
tg personal contacts check @example_user  # does the account look like a bot or a spammer

contacts list is the people you have a one-to-one chat with. contacts sync brings in the rest of your Telegram contact list too. name: and notes: search the people the local store keeps, so they need a contacts sync (or messages read before) first; with nothing stored, the query is refused. contacts lookup never takes the number as an argument: pipe it in, or type it when asked. A person's profile, their recent messages, the bot check and what it sends where: people.

Changing the address book and your profile:

tg personal contacts add @example_user  # under the name they show
tg personal contacts rename @example_user Ann "from work"  # a name only you see
tg personal contacts remove @example_user  # the chat stays
tg personal contacts block @example_user  # they need not be a contact
tg personal contacts unblock @example_user
tg personal contacts import people.txt  # one "number, name" per line; never numbers as arguments
tg personal account update --first-name Ann --description "about me" --photo me.jpg
tg personal account sessions end --others  # logs out every other device, your phone too; asks first

contacts import answers how many it sent and who Telegram knew, never a number. account sessions end asks before it goes; --yes answers in a script.

Pages

A list shows limit rows (20 by default). chats list, contacts list, chats members list and topics take --page and --all:

tg personal contacts list --limit 5    # five a page
tg personal contacts list --limit 5 --page 2  # the sixth to the tenth
tg personal contacts list --all        # every row, no paging

⚠ A page number over a live list can repeat or skip a row. The newest is on top, so a message that arrives between page one and page two moves someone across the border.

A chat's messages have no pages: they take a query on id and date, and --cursor for the next page, which pages exactly:

tg personal messages list "Book club" "id<4242"  # older than message 4242
tg personal messages list "Book club" "id>4242"  # newer than 4242, oldest first
tg personal messages list "Book club" "date>2h"  # what came in during the last two hours
tg personal messages list "Book club" "date>2026-09-20T09:00"
tg personal messages list "Book club" "date<1d"  # what came before this time yesterday

In the terminal, the line that names the next page goes to stderr; in JSON it is the page.next step, which gives --cursor. id< and id> take a message id. date> and date< take ISO 8601, or "this long ago": 30m, 2h, 1d. In messages context, --before-n and --after-n are counts of messages: there the point is already the message.

Find a chat, then write to it

tg personal chats list 'title:*book* kind:group'  # groups with "book" in the title
tg personal contacts list 'name:*ann*'  # people by name or @username
tg personal search all "contract"            # messages and notes this machine has kept
tg personal search messages "contract"       # the text of every message this machine has kept
tg personal search messages 'contract chat:"Book club"'
tg personal search messages 'body:/.*invoice.*(march|april).*/'

Chat and contact searches need at least three characters. search messages uses the search query language: invoice also finds other forms of the word, invoic* matches beginnings, and exact:invoice only that form. It reads what was fetched or kept by serve, and asks Telegram's own search too (--backend archive for the store only). body:/…/ matches a regular expression against the whole message. More: message search. Once you have the chat, use its id.

Sending

Nothing sends unless you typed a command that sends, and it asks no confirmation: the chat and the text are already in the line you typed. Every send goes through the send guard: a read-only profile, the profile's allow list, the list of allowed recipients and the hourly limit (the send guard). Every attempt is logged, never its text: tg personal sends list.

tg personal messages send me "a note to myself"
tg personal messages send "Book club" "See you at 7" --silent  # no notification
tg personal messages send "Book club" "a link, no card" --no-preview
tg personal messages send "Book club" "**Bold** and _italic_" --md  # Telegram Markdown
tg personal messages send "Book club" "<b>Bold</b> and <i>italic</i>" --html

--md uses Telegram's formatter: **bold** or *bold*, _italic_, __underline__, ~~struck~~ or ~struck~, ||spoiler||, inline code, fenced code with a language, [label](https://example.com) and quote lines starting with > . Styles may nest; code/pre cannot nest with other entities, links cannot nest, and quotes cannot nest. Without the flag text stays as typed. Backslash escapes a mark; word-internal _ and * stay literal. Unclosed inline marks stay literal; an unclosed fence is refused. Links support absolute http, https and mailto URLs. messages edit and media captions use the same formatter. Telegram __text__ is underline; MAX __text__ is bold. A single *text* is bold in Telegram.

--html reads the text as Telegram's HTML, the same as the Bot API's: <b>, <i>, <u>, <s>, <a href>, <code>, <pre language="…">, <blockquote> and <tg-spoiler>. Line breaks and spaces stay as typed. A mention link (tg://user?id=) or a custom emoji is refused. --md and --html cannot go together. messages edit takes --html too.

Text from stdin

Leave out the text and it is read from stdin. That is the only way to send several lines, and it keeps the text out of ps and your shell history:

printf 'first line\n\nthird line' | tg personal messages send me
tg personal messages send "Book club" < note.txt

Sending later

tg personal messages send "Book club" "Tomorrow" --at-time 2026-10-01T09:00  # local time
tg personal messages send "Book club" "In two hours" --at-time 2h  # or 30m, 1d from now
tg personal messages scheduled "Book club"                      # what waits to be sent there

--at-time hands the message to Telegram, which sends it even with this machine off. The time is rounded down to the minute. Less than a minute from now, or more than a year, is refused. The guard counts a scheduled message in the hour Telegram sends it. Cancel or change one in the Telegram app; tg does not.

Files, photos and voice messages

tg personal messages send "Book club" "The agenda" --file agenda.pdf  # byte for byte; the text is the caption
tg personal messages send "Book club" --photo picture.jpg     # recompressed by Telegram
tg personal messages send "Book club" --file trip.mp4         # a video plays in the chat
tg personal messages send "Book club" --file trip.mp4 --as-file  # the same video as a file to download
tg personal messages send "Book club" --voice note.ogg        # a voice message, alone, with no text
tg personal messages send "Book club" --file 3f9a.pdf --filename "Report Q3.pdf"  # the name others see

--photo takes a .jpg, .png or .webp. A .mp4 or .mov given with --file goes as a video unless you add --as-file. --voice takes an Ogg Opus file (.ogg, .oga, .opus) and goes alone: no text, no other file. Known credential files and folders, tg's own folders and the local store are protected. Ordinary hidden working folders are allowed; CLI --allow-any-file overrides protected paths when the owner intends to send that file.

--spoiler blurs a photo or a video until it is tapped; a document or a voice message cannot take one. --caption-above shows the text above the photo or file. Telegram lets only bots stop forwarding of one message; to protect content, turn on the chat's own setting in Telegram. Formats, downloads and text search inside files: file attachments.

Replying

tg personal messages send "Book club" "Agreed" --reply-to 4242

A reply is a send, so every send option works with it.

Reactions and polls

tg personal reactions add "Book club" 4242 👍  # replaces the reaction you had
tg personal reactions remove "Book club" 4242
tg personal polls show "Book club" 4250    # the poll and its answer ids
tg personal polls voters "Book club" 4250 'answers:<answer id>'  # who chose it; not in an anonymous poll
tg personal polls vote "Book club" 4250 <answer id>
tg personal polls vote "Book club" 4250 --retract
tg personal polls create "Book club" "Which day?" Monday Tuesday --anonymous
tg personal polls create "Book club" "Pizza now?" yes no --close-time 5m  # closes by itself; 5s to 10m
tg personal polls close "Book club" 4250   # your own poll; it cannot be reopened
tg personal polls create "Book club" "2+2?" 3 4 5 --quiz --correct 2 --solution "Four."  # a quiz; a vote is final

When you read a chat, reactions show under a message — 👍 3 🔥 1 (you: 🔥). A vote in a public poll shows your name to everyone in the chat. Vote by the ids polls show prints, never by an answer's position. --multiple lets people pick several answers. People can change their vote only in a poll made with --revote. A vote in a closed poll, two answers in a one-answer poll, a changed or retracted vote where the vote is final, and --retract with no vote are refused before anything is sent; so is closing a poll someone else made.

Editing, forwarding, pinning, deleting

tg personal messages edit "Book club" 4242 "the corrected text"  # your own message; --md or --html as in a send
tg personal messages forward "Book club" 4242 --to me       # checked against the chat it goes to
tg personal messages forward "Book club" 4242 --to "Hiking" --topic 12  # into one topic of a forum
tg personal messages pin "Book club" 4242                   # quiet unless --notify
tg personal messages unpin "Book club" 4242
tg personal messages delete me 4242 4243 --allow-dangerous  # at most 10, for you only
tg personal messages delete me 4242 --allow-dangerous --for-everyone

An edit reaches people who may have read the old text already. A forward is a new message: it goes through the same guard as a send, against the chat it goes to. A deletion cannot be undone, which is why it asks first: answer y, or add --allow-dangerous to skip the question. In a supergroup or a channel Telegram deletes only for everyone, so there only --for-everyone works.

What counts toward the hourly limit: a message, a forward, an edit, a pin that notifies, a new poll, closing a poll, and each deleted message. A reaction, a vote and a quiet pin do not.

When the outcome is unknown

Exit code 14 means the connection broke after the message left: it may have gone. The error carries a --operation-id. Repeat with it, and Telegram drops the second copy:

tg personal messages send "Book club" "See you at 7" --operation-id <id from the error>
tg personal messages forward "Book club" 4242 --to me --operation-id <id from the error>

A forward and a poll carry one too. A repeat without it is a second message to a person. A file is uploaded before the message is sent: tg tries a dropped upload three times, and if it still fails the error says nothing was sent — that one you can simply run again. Other writes — pin, react, mark read, delete, vote, folders, contacts — end in exit 14 the same way when Telegram does not answer; the message says whether repeating is safe. A folder creation is not: look in tg personal chats folders list first, or you may get two. A message sent with --at-time is never repeated: look in tg personal messages scheduled <chat> instead.

Marking a chat read

tg personal chats mark-read "Book club"      # up to the newest message
tg personal chats mark-read "Book club" --until-id 4242  # only up to this one
tg personal chats mark-read "Hiking" --topic 12  # only this forum topic
tg personal messages list "Book club" --mark-read  # read it, and mark it read up to the newest shown

The other side sees that you read it. It goes through the guard as the action read, and does not count toward the hourly limit.

Folders

tg personal chats folders list                     # your folders, in the order the app shows them
tg personal chats folders show "Trips"             # one folder, with the names of its chats
tg personal chats folders create "Trips" --chat "Hiking" --chat @kate
tg personal chats folders update "Trips" --title "Travel" --add "Climbing" --remove @kate
tg personal chats folders delete "Travel"          # the chats stay
tg personal chats folders order "Travel" "Work"    # these first; the rest keep their order after them
tg personal chats folders join https://t.me/addlist/AbCdEf  # a folder someone shared: joins every chat in it
tg personal chats folders create "Inbox" --include contacts,groups --skip muted,archived --emoji 📥
tg personal chats folders update "Inbox" --exclude-chat "Noisy group" --pin @kate
tg personal chats folders update "Inbox" --include none  # no kinds any more; only the chats named in it

A folder can take every chat of a kind by itself: --include with contacts, non-contacts, groups, channels and bots. --skip leaves out chats that are muted, read or archived. --exclude-chat keeps one chat out even when a kind takes it, and --pin puts a chat at the top. On update, --include and --skip replace what the folder had; --remove takes a chat off every list. A folder someone shared by a link takes no rules. --emoji must be one of the icons the Telegram app offers for folders; Telegram drops any other without an error, and the answer shows the folder as Telegram kept it.

A folder is named by its id or its title exactly. Only you see your folders; each change still goes through the guard, as an account change. join is different: the people in those chats see that you joined, as with tg personal chats join. "All chats" keeps its place when you order.

Channel comments

tg personal messages comments "Rozetked" 27644     # the comments under post 27644, oldest first
tg personal messages comments "Rozetked" 27644 "id<3732413"
tg personal messages send "My channel" "Thanks!" --comment-to 120

Comments live in the channel's discussion group: the answer names it as discussion, and a comment is a reply there, so the recipient list and the hourly limit count it against that group. A post whose channel has no discussion group, or that is closed to comments, ends in exit 6.

Posting as a channel

In a group where you may post as one of your channels, list who you can be, then pick one:

tg personal chats send-as "Book club"
tg personal messages send "Book club" "Meeting moved to 8" --send-as <id from the list>

The list always has you, and default marks the group's saved choice; reading it changes nothing. An id not in the list is refused. --send-as works with files, tg personal messages forward and tg personal polls create too — for a forward, the list is the one of the --to chat. To repeat an unknown outcome, give the same --send-as with the --operation-id.

A group can have a channel saved as its default sender — Telegram does this for the discussion group of your channel. There a send, forward or poll without --send-as is refused (exit 2) instead of going out as the channel: the error names --send-as <your id> to post as yourself and --send-as <channel id> to post as the channel.

Not in tg yet

Several photos in one message remain on the roadmap.

Groups and channels

Everything for a group you run — moderation rules, checks, the full list of settings — is in groups you run.

tg personal chats inspect https://t.me/+AbCdEf     # where an invite or public link leads; does not join
tg personal chats members list "Hiking" --all      # everyone, with their role and when last seen
tg personal chats events "Hiking"                  # who joined, left, was added or removed — 7 days
tg personal chats events "Hiking" 'type:(join OR leave) time>=2026-09-01T00:00'
tg personal topics list "Hiking"                   # a forum group's topics, newest activity first
tg personal search topics "Hiking" "gear"
tg personal topics show "Hiking" 12                # one topic: title, closed or pinned, last activity
tg personal review 'chat:Hiking' --unanswered    # questions nobody answered

All of these only read. events reads the chat's service messages: who did what, and to whom. The names are join, leave, add, remove, create, title and pin.

These change something, and the people in the chat see it.

For a forum, use tg personal topics enable <chat> and tg personal topics create <chat> <title>. A basic group requires --upgrade --yes; keep the new chat id returned by the upgrade. Read topics list after an unknown creation outcome instead of repeating the creation. Send to its id with tg personal messages send <chat> <text> --topic <id> or tg personal polls create <chat> <question> <answers> --topic <id>. tg personal topics edit <chat> <id> --title <new> renames a topic, --closed on / --closed off closes it to new messages or reopens it, --pinned on / --pinned off pins it at the top or unpins it, and on the General topic (id 1) --hidden on / --hidden off hides it from the topic list or shows it again. tg personal topics order <chat> <id...> puts the pinned topics in that order; it pins and unpins nothing. Repeating any of these is safe. tg personal topics delete <chat> <id> deletes a topic and every message in it, for everyone; it cannot be undone, so it asks first by default; an explicit topics.delete: allow or --allow-dangerous skips the question. The General topic cannot be deleted.

tg personal chats create "Hiking 2027" @olga 12345  # a supergroup; the people added are told
tg personal chats create "Trail news" --channel    # a channel; people join it by its link
tg personal chats join https://t.me/+AbCdEf        # by an invite link, or a public one
tg personal chats leave "Hiking 2027"
tg personal chats update "Hiking 2027" --title "Hiking 2028" --description "routes and dates"
tg personal chats update "Hiking 2027" --all-can-pin off --only-admins-add on
tg personal chats link show "Hiking 2027"          # the invite link, if you may see it
tg personal chats link reset "Hiking 2027"         # a new one; the old one stops working
tg personal chats link create "Hiking 2027" --approval --expire-time 7d  # another link; who joins asks first
tg personal chats link create "Hiking 2027" --max-uses 20  # at most 20 people join by it
tg personal chats update "Hiking 2027" --join-approval on  # everyone asks first, by any link
tg personal chats requests list "Hiking 2027"      # who asked to join, newest first
tg personal chats requests list "Hiking 2027" 'name:*Ana*'  # by name; or link:<link>
tg personal chats requests accept "Hiking 2027" 67890  # let them in; decline turns them away
tg personal chats requests decline "Hiking 2027" --all  # every pending request at once; --link narrows it
tg personal chats link list "Hiking 2027"          # your links, with how many joined and how many wait
tg personal chats link revoke "Hiking 2027" https://t.me/+AbCd  # stop one link
tg personal chats link update "Hiking 2027" https://t.me/+AbCd --no-approval --max-uses 50  # change only these
tg personal chats members add "Hiking 2027" @kate 67890  # they are told
tg personal chats members remove "Hiking 2027" @kate  # their messages stay
tg personal chats admins add "Hiking 2027" @kate --can pin,delete
tg personal chats admins remove "Hiking 2027" @kate

A new group is always a supergroup. Someone whose privacy settings stop them being added is named in the answer under providerMetadata.notAdded; the group is made anyway. chats join to a group whose admins approve who joins answers requested: true and exits 0: the request is sent, and you are in once an admin accepts it. Each goes through the guard as a chat change, and each person added counts toward the hourly limit.

chats link create makes another invite link and tells nobody: --approval makes whoever joins by it ask first, --expire-time stops it at a time (2026-12-01T09:00, or 30m, 2h, 7d from now), and --max-uses lets at most that many people in. chats link update changes the same three on one of your links, the group's own one too (--no-approval turns approval off, --expire-time never takes the expiry away); what you leave out stays. A link that asks first has no use limit, so --approval with --max-uses is refused. chats update --join-approval on makes everyone ask first, whichever link they use.

In a group whose admins approve who joins, chats requests list shows the pending requests, with the note a person sent; only admins see them, and reading tells nobody. A query narrows it: name: or username: finds people, link: keeps those who asked through one invite link; Telegram cannot do both at once. accept and decline answer one, by the id from the list. An accepted request counts toward the hourly limit, a declined one does not; the recipient list checks the group only. Someone who is already a member is answered with already: true, and a request that is gone ends in exit 6. --all answers every pending request, or with --link those that came by one link; they are counted first, and an accept that would go over the hourly limit is refused before anyone is let in. chats link list shows only your own links; revoking the group's own link makes Telegram issue a new one, which the answer shows.

chats update changes the title, the description and the two settings Telegram has, in one go; the answer is the group as it stands, and chats show shows the same settings. Moderation rules — chats rules and chats moderate — are in moderation rules.

Output: tables, JSON and exit codes

At a terminal tg prints a table; into a pipe, or with --json, it prints one JSON value on stdout and nothing else — no spinner, no tick, no warning. Notes, warnings and errors go to stderr in every mode. This is what lets a script, or your agent, rely on the answer.

tg personal chats list --json | jq -r '.items[].id'
tg personal messages list me --jsonl | jq -r .text  # one message per line
  • --json: one JSON value. Every list is one object, always the same shape: { "items": [...], "page": { "limit": 20, "has_more": true }, "meta": { ... } }; a list with more rows names the next step in page.next. --all and --offline answer with the same object. One thing, not a list, is { "item": {...}, "meta": {...} }.
  • A chat's messages page with --cursor, not a page number; page.next carries it.
  • --jsonl: one object per line, no wrapper; whether there is more is said on stderr only. A report (stats …) is one object, so --jsonl on it is refused; stats * evidence lists still stream.
  • An error that ends the command is { "error": { "code": "...", "message": "..." } } on stderr, and stdout is empty, so a refusal can never be taken for an empty result.
  • Partial reads and downloads may exit 0: inspect complete and batch or issue; partial download JSONL adds batch_summary.
  • Branch on the exit code, not on the text. The text changes; the code does not. 0 worked, 2 bad input, 4 not logged in, 5 the profile may not do this, 6 not found, 7 not on the list of allowed recipients, 8 a limit (the hourly limit, or Telegram's own), 9 Telegram did not answer in time, 14 unknown whether a message went. The full table is in exit codes.
  • Ids are strings. Never turn one into a number.
  • --quiet turns notes off; a failure is still said. -v and -vv add detail to the table view.
  • --timeout 30s bounds the whole command (500ms, 30s or 2m).
  • tg commands --json is the whole command tree, with effects: { remote, local } on every command: remote: "write" changes something in Telegram.
if ! tg personal messages send "Book club" "See you at 7" --json > /dev/null; then
  case $? in
    14) echo "it may have gone — repeat only with the same --operation-id" ;;
    4)  echo "run tg personal session start" ;;
  esac
fi

Connect your AI agent

An AI agent that runs commands in a terminal (for example Claude Code, Codex or Gemini CLI) reads tg's skill file: the rules and traps that --help cannot explain. tg personal setup installs it, and so does tg skill install (--for claude, agents or all, the default). To install it by hand:

mkdir -p ~/.claude/skills/tg-cli && tg skill show > ~/.claude/skills/tg-cli/SKILL.md   # Claude Code
mkdir -p ~/.agents/skills/tg-cli && tg skill show > ~/.agents/skills/tg-cli/SKILL.md   # Codex, Gemini CLI

Local Cursor also reads ~/.agents/skills/tg-cli/. The installed instructions come from the same package version as tg. Start a new agent session if it does not see the skill. How each agent finds skills: Codex, Gemini CLI, Cursor.

An agent without a terminal (for example Claude Desktop or Cursor) connects over MCP: the MCP server.

What a conversation looks like

tg personal messages list and tg personal search messages print a transcript at a terminal, not a table:

10:05:12  Anna
          Shall we call on Thursday?

10:09:03  Boris
          ↳ Anna: Shall we call on Thursday?
          Thursday works.
          📎 photo
          edited 10:09:30

Times are local, and a line marks each new day. ↳ is what a message answers, ↪ whose message was forwarded, 📎 an attachment. Control characters in a message or a name are shown as text, never run by the terminal. -v adds the ids of the message, the sender and the chat; -vv everything known about the message.

New messages as they arrive

tg personal watch                  # new messages, until Ctrl-C or --timeout
tg personal watch --jsonl          # one message per line, as messages list --jsonl
tg personal watch --jsonl | ./on-message.sh
tg personal watch --events --jsonl  # edits, deletions and reactions too
tg personal watch --jsonl --timeout 2m  # a timeout ends it normally, with exit code 0

With --events every line names its event: message, edit, delete or reaction. Without it, the lines are bare messages. Telegram does not say in which chat a message was deleted in a private chat or a small group, so such a line has no chat.

watch starts from now. What arrived while nothing was listening is not shown. To keep the local store current, including what came in while this machine was off, use serve, in the background or as a system service (keeping the store current):

tg personal server start  # serve in the background; answers once it is connected
tg personal server status
tg personal server install  # a systemd user unit or a launchd agent; starts nothing

What a command did

tg --trace chats list          # show each request on stderr, keep nothing
tg --record chats list         # keep it, show nothing
tg personal runs list          # what was kept, newest first

A failed run is always kept. A record holds operations, ids, counts and durations — never a message, a name, a chat title, a phone number or a key. In full: diagnostics.

The local store

Everything tg reads is kept on this machine, so that it can answer without the network:

tg personal chats list --offline                  # only from the store, never connect
tg personal messages sync "Project Alpha" --dry-run  # how much a fetch would take
tg personal messages sync "Project Alpha" --background  # a chat's history, as a job
tg personal store export "Project Alpha" --format markdown --output alpha.md
tg personal store backup ~/tg-store.db            # a copy of the store, while it is in use

An ordinary command still asks Telegram. --offline is for when there is no network, or when connecting is not wanted; a send with --offline is refused. Fetching, export, search, backup and the service: the local store.

Settings, and what a profile may do

Settings live in an optional config.json, and every value is decided in one order: option → environment variable → the profile in the file → the file's defaults → built in.

tg config show                                 # every setting, and where it came from
tg config set limit 50
tg work config set permissions.messages readonly   # profile "work" changes no messages
tg config set permissions.messages.send ask        # a yes or no before each send
tg config set sends_per_hour 10

permissions says what a profile may do, per command: deny, readonly, ask or allow. By default everything is allowed, and deleting messages and ending sessions ask first. A refusal is exit code 5, and the error names the command that allows it. The file has no field for a secret. Every setting and variable: configuration.

One person

tg personal contacts profile <person> shows what Telegram says about one person and how active they are in the chats you share. tg personal contacts check <person> scores them as a possible bot, fake or spammer, with every reason. tg personal contacts context <person> reads what the store holds about them: without --chat, across every account linked to them with contacts link; with --chat, their newest messages in each chat you name. What each one asks Telegram or a public spam list, and what it sends where: people.

A few limits to know: an estimated registration date is guessed from the account id with a community table, and there is no estimate past December 2024. The bot check reads up to 1,000 stored messages. contacts context returns message bodies, so it follows the messages permissions; linking and unlinking identities is controlled by contacts and never changes Telegram's address book.

Next

⚠️ **GitHub.com Fallback** ⚠️