This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
telegram-agent-kit is a published npm library (ESM-only) that wires LLM agents to
Telegram. It is runtime-agnostic with zero runtime dependencies in the core —
all I/O is supplied by the caller through thin injected interfaces. It runs on Node 20+,
Bun, Deno, and the browser (core layers).
npm run build # tsup → dist/ (ESM + .d.ts) for both entry points
npm run typecheck # tsc --noEmit
npm test # vitest run (whole suite)
npm run lint # biome check .
npm run format # biome format --write .Run a single test file or test by name:
npx vitest run test/format/md-to-html.test.ts # one file
npx vitest run -t "renders nested bold" # by test name
npx vitest # watch modeBuild before test if touching /deepagents. test/deepagents/no-peers.test.ts
greps dist/deepagents/index.js to prove the built bundle carries no runtime import of
the optional peers; it silently skips when dist/ is absent. CI runs build before
test for exactly this reason — replicate that ordering locally when verifying the
peer-isolation guarantee.
The dependency direction is strictly Bridge → Draft → Formatting; lower layers
never import higher ones. Each layer has a barrel index.ts; src/index.ts re-exports
all three layers (but not /deepagents, which is a separate package entry point).
- Formatting (
src/format/, pure, zero deps):mdToTelegramHtml,chunkText/safeSlice/chunkRich, and the rich helpersrepairRichTables/neutralizeRichMedia/extractTrailingCover. Browser-safe. - Draft engine (
src/draft/):createDraftStreamer— a throttle / keepalive / typing-heartbeat / preview-cap / drain state machine that animates a single live Telegram draft from a growing text. Tunables live insrc/draft/constants.ts(DEFAULT_DRAFT_CONSTANTS), overridable per call. - Turn-loop bridge (
src/bridge/):runTelegramTurnorchestrates one turn (turn-loop.ts) andsendReplyorchestrates the final send (send.ts). The four injectable interfaces are defined ininterfaces.ts. /deepagents(src/deepagents/, optional subpath):toAgentStream/streamAgentadapt a deepagents/langgraph agent to the kit'sAgentStreamcontract. Shipped as a separate entry (telegram-agent-kit/deepagents) so the core never pulls in langchain.
The caller implements these; the kit owns all orchestration over them.
BotClient— raw Bot API transport primitives, one HTTP call each, no chunking/rendering/fallback. Each must throwTelegramApiError(fromsrc/errors.ts) on a Bot API error so the deterministic-400 fallbacks fire.AgentStream—(input, { threadId, signal, configurable }) => AsyncIterable<RenderEvent>. ThethreadIdMUST reach the agent so the checkpointer snapshots/rolls back the same thread.configurableis an optional pass-through bag forwarded verbatim fromrunTelegramTurn'sconfigurableoption.Checkpointer—{ snapshot(threadId), rollback(threadId, id) }.ThreadStore—{ resolve(key, now), touch(key, now) }, keyed by{ chatId, agentId }so two bots sharing one chat id don't collide.
These are intentional and enforced by tests — preserve them when editing.
- Totality of
mdToTelegramHtml: it must never throw on arbitrary LLM output (pinned bytest/format/md-to-html.test.ts). The send path also wraps it in try/catch as defence-in-depth and falls back to plain text onnull. It auto-closes unclosed marks/fences only inpartial: true(draft) mode. - Deterministic-400 fallback chains keyed off
isBadRequest(err)(aTelegramApiErrorwitherror_code === 400— "rejected, not delivered", safe to retry without double-send). A non-400 error always propagates. The chains: rich → classic (sendRichMessage→ HTMLsendMessage), HTML → plain text, and photo → text. Draft sends additionally flip rich → plain for the rest of the turn on a 400 without spending themaxFailuresbudget. runTelegramTurnnever throws out — every failure path is caught and logged. Ordering matters: snapshot happens only afterpreStream(so a skipped turn leaves no rollback target); rollback fires only if a snapshot was taken and the turn did not complete; draft teardown is idempotent (tracked viadraftTornDown);beforeTurn/afterTurnhooks are isolated and never abort the turn./deepagentspeers are type-only.@langchain/coreanddeepagentsare optional peer deps imported withimport typeonly, and externalized intsup.config.ts. The built/deepagentsbundle must contain no runtime import of either (the no-peers dist-grep test). The langgraph config shape (thread_id) is centralized insrc/deepagents/to-agent-stream.ts— keep it there. That file also owns the reserved-key strip (thread_id,thread_ts,checkpoint_id,checkpoint_ns,checkpoint_map,run_id, plus any__pregel_*LangGraph internal-execution key) applied to the caller'sconfigurablebefore merging it under the kit-ownedthread_id(spread last, so it always wins) — pinned bytest/deepagents/configurable-strip.test.tsandtest/deepagents/to-agent-stream.test.ts.- Surrogate-safe splitting:
chunkText/safeSlice/chunkRichmust never sever a UTF-16 surrogate pair. Limits: classic textTELEGRAM_LIMIT4096 (target 4000), richRICH_LIMIT32768, photo caption 1024.
- Imports use explicit
.tsextensions (e.g.from './send.ts'). This is required by the tsconfig (allowImportingTsExtensions,moduleResolution: Bundler,verbatimModuleSyntax) and is how tsup resolves the source. Match it. strictTypeScript, ESM only ("type": "module"). No default exports; everything flows through barrelindex.tsre-exports.- Comment the why, heavily. The format/rich modules carry long doc-comments explaining each invariant (flanking rules, code-region tracking, fence anchoring). When you change behavior there, update the rationale comment too — those comments are the spec.
- Formatting: Biome, 2-space indent, single quotes (
biome.json). Runnpm run formatbefore committing. dist/andnode_modules/are gitignored; onlydist/is published (filesinpackage.json).