This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
tg is a single static Go binary for driving a Telegram personal account (or bot) from the
command line or an AI agent. It is built on gotd/td and is
designed to be agent-friendly: stable JSON output, consistent peer syntax, and explicit
safety gates.
This CLI is a thin layer over gotd/td. When you need the exact shape of a Telegram API
method, request, or response type, read the latest gotd/td source directly rather than
guessing — it is the source of truth for generated tg.* types and *tg.Client methods.
It is checked out locally at ../td or /src/gotd/td. Prefer the local checkout
(latest). Also confirm the version the CLI actually compiles against — it may differ from the
local checkout — with go list -m -f '{{.Dir}}' github.com/gotd/td (currently v0.157.1), and
read that module directory when the two disagree.
Useful entry points in gotd/td:
tg/tl_*_gen.go— generated request/response structs and*tg.ClientRPC methods (e.g.tg/tl_messages_export_chat_invite_gen.go). Optional fields useSetX/GetXhelpers.telegram/peers— the peer/access-hashManager(Resolve,ResolveUserID,Apply, …).telegram/message— theSenderfluent builder used for sending.telegram/query— paginated iterators (e.g.query.GetDialogs).
go build ./... # build everything
go run ./cmd/tg <command> --help # run the CLI locally (binary is package cmd/tg, named "tg")
go test ./... # all tests
go test -race ./... # race detector (what CI runs)
go test ./cmd/tg/ -run TestName # a single test
golangci-lint run ./... # lint (config in .golangci.yml; CI pins v2.12.1)
gofmt -l . # list misformatted files; gofmt -w to fix
go mod tidy # CI fails if this produces a diffGo 1.25. Releases are cut by pushing a v* tag, which triggers GoReleaser
(.github/workflows/release.yml); the release build embeds app credentials via -ldflags and
fails if the APP_ID/APP_HASH secrets are missing.
Commits must follow Conventional Commits (feat(...), ci:, fix:, …) — enforced by
.github/workflows/commitlint.yml.
The whole CLI lives in cmd/tg/ (package main). Supporting packages are under internal/.
Command pattern. Each command is a func (a *app) newXxxCmd() *cobra.Command method
(spf13/cobra) registered in root.go's newRootCmd. Commands carry a GroupID
(groupAuth/groupMessaging/groupChats/groupProfile). Parent-with-subcommands is done via
cmd.AddCommand(...) (see devices, chats, invite-link).
The app.run envelope. Almost every command body is:
return a.run(ctx, runParams{auth: authUser}, func(ctx context.Context, api *tg.Client) error {
// ... use api (*tg.Client) ...
})app.run (app.go) resolves the selected account(s) — --account all fans out across
configured accounts sequentially, labeling each result — connects, ensures the session is
authorized (or authorizes a bot via its token), and invokes the callback with a ready
*tg.Client. This is the single seam where connection, multi-account, and auth live; don't
reimplement it per command.
Output. Never print results directly. Build a result struct and call a.printer.Emit(v)
(internal/output). Emit writes either the JSON envelope {"schema":1,"data":…} (when
-o json) or, for text mode, calls the value's MarshalText(w io.Writer) if it implements
output.TextMarshaler. So every result type gets a JSON shape for free and an optional text
renderer. Generic acknowledgements use okResult{OK: true}. Logs/prompts go to stderr.
Peer resolution (peers.go) is central and shared. a.manager(api) returns a
*peerManager that embeds gotd/td's peers.Manager and bundles the persistent access-hash
cache (internal/peercache, a JSON file in the session dir — gotd/td's string sessions keep
no entity cache, so this persists hashes across invocations). Resolve peers with:
resolvePeer(ctx, m, arg)→tg.InputPeerClass, orm.Resolve(ctx, arg)/resolvePeerArg(ctx, m, arg)→ typedpeers.Peerwhen you must branch on user/chat/channel (e.g. to pickchannels.*vsmessages.*methods).
A <peer> argument accepts me/self, @username, a phone number, a t.me/… link, or
id:<n> (a raw numeric id from tg chats list --output json, resolved from the cache — works
only after the peer has been cached). For sending, builderFor(sender, peer) turns a peer
string into a message.RequestBuilder (the sender's resolver also understands id:).
Testing convention. Keep network code thin and factor the logic into pure functions that
are unit-tested directly (e.g. inviteLinkFromFull, newDevicesResult). For code that must
issue RPCs, use the mock invoker helpers in tests: newTestAPI(t) (tgmock, Expect()-based)
and newFuncAPI(t, fn) (callback invoker). Tests run with -race in CI.
- Destructive actions require a
--yesflag and return an error if it's absent (seedelete.go,pin.go); follow that pattern for anything irreversible. - Wrap errors with
github.com/go-faster/errors(errors.Wrap/Wrapf), tagging the RPC name, e.g.errors.Wrap(err, "messages.exportChatInvite"). Noteerrors.Wrap(nil)is non-nil — guard withif err != nilbefore wrapping. - Group/channel commands that differ by peer type switch on
peers.Channelvspeers.Chatand callchannels.*vsmessages.*accordingly.