Pi Telegram Agent user guide
中文指南 · Back to the project README
This guide is for operators and users. You can connect 1..N configurable AI companions to one Telegram supergroup and use Pi’s native interface without first learning the internal architecture.
Shortest path
- Prepare the group ID and a BotFather token; authenticate and select the default model with Pi
/loginand/model. - Run
bun run pifrom the repository. - Run
/tg configin Pi and wait for the all-bots feed to open.
Read in order:
- Installation and first setup: Telegram and Pi-model preparation plus the native wizard.
- Configuration and additional bots: typed config, secret boundaries, routing, and N-bot setup.
- Chat and observe in Pi: attach, compose, history, and telemetry.
- Daily operations: daemon lifecycle, configuration changes, backups, and multi-group isolation.
- Troubleshooting: move from an observable symptom to a safe next action.
- Cost design overview: how routing, cache, context, media, and UI avoid wasted calls and tokens.
Product boundaries
- One deployment = one Telegram supergroup + 1..N bots.
- Closing Pi does not stop the daemon. Telegram is the chat venue; Pi is the local observation and control interface.
- Multiple groups require isolated working directories and all data/session/process resources.
telegram.config.tsis trusted executable local code, not a sandbox for downloaded configuration.- Tracked files contain no valid credentials or deployment personas; older Git history may still contain removed personas.
Installation and first setup
This project runs 1..N AI bots, each with its own persona, inside one Telegram supergroup. It is designed to be fast (a resident daemon routes messages directly), context-optimized (the provider prefix cache means repeated context is not billed again), and simple (one configuration track, no intermediate concepts) — low cost is the result of those three.
There is exactly one configuration track: telegram.config.ts for non-secret settings, .env for secrets such as tokens, and Pi for model authentication. The wizard below writes these files for you in its final step.
1. Prepare the local environment
Install Bun, clone the repository, and enter the project directory:
git clone <repository-url> pi-extension-telegram-agent
cd pi-extension-telegram-agent
bun run pi
bun run pi performs a frozen-lockfile install only when the project Pi CLI is missing, then starts the locked Pi 0.84.1. It does not read a sibling ../pi checkout. Run bun install --frozen-lockfile when you need an explicit installation step.
2. Prepare Telegram
For every bot:
- Create it with BotFather and retain the token.
- Disable group privacy in BotFather so the bot receives ordinary group messages.
- Add the bot to the target supergroup. Grant send permission if the group’s restrictions require it.
- Obtain the supergroup’s numeric ID. The wizard accepts bare positive, negative, or
-100...forms and normalizes them.
Never paste a token into the group, an issue, logs, or Git. Give every bot a distinct token environment-key name.
3. Prepare the Pi model
In the project Pi session:
- Run
/loginand complete Pi’s native provider authentication. - Run
/modeland select the default provider and chat model. The default media mode (vision) works with any chat model; image input is required only if you later opt intomedia.mode: "context", which attaches photos, static stickers, and sampled video frames to the chat model’s context directly and refuses to start with a text-only model (image_input_unsupported). The Telegram runtime uses reasoningoffunlesstelegram.config.tsexplicitly overrides it, even if the interactive Pi session uses another thinking level.
The Telegram project reads Pi’s merged global/project model settings and Pi auth store. It does not copy model credentials into this repository. First setup uses tools.search: false, so a TinyFish key is not required.
4. Run /tg config
/tg config remains available in Pi help and completion when configuration is missing or invalid. The daemon does not need to be running.
Before opening input dialogs, the wizard locally preflights the displayed provider/model:thinking against Pi’s catalog and authentication. It makes no model request. The wizard then asks for:
- a Chinese or English public persona template;
- the Telegram supergroup ID;
- local bot ID, Telegram display name, token environment-key name, and BotFather token;
- final write confirmation.
Pi’s current native input dialog does not mask passwords. The BotFather token remains visible while entered. Use a private terminal and do not record or share the screen. The wizard never places it in notifications, process arguments, the Pi session, or provider context. Provider authentication stays in Pi and is never requested here.
Pressing Esc at any step leaves no partial deployment. After confirmation, the wizard atomically creates:
.env: the Telegram token, mode 0600, ignored by Git;telegram.config.ts: Telegram deployment fields; the wizard pins the Pi provider/model it just preflighted and inherits the defaults — reasoning/search/run_js/vision off, media modevision, bounded context/cache/retention — mode 0600, ignored by Git;personas/<bot-id>.local.md: local persona, mode 0600, ignored by Git.
5. Confirm readiness
The wizard validates the complete deployment with the production loader, then invokes the controlled daemon restart. Pi opens the all-bots feed only when the command exits successfully and explicitly reports daemon ready.
If Pi has no valid default model or authentication, the preflight stops before any dialog or write. Use Pi /login and /model, then run /tg config again.
When Telegram credentials or networking prevent readiness, the validated files remain and the UI does not claim a connection. Run:
/tg status-daemon
/tg restart
Inspect the redacted tail of data/daemon.log when needed. Do not overwrite configuration repeatedly just to retry.
Existing configuration
Running /tg config again lets you:
- validate the current deployment;
- edit the project-root
telegram.config.tssource in Pi and retain an exact backup after confirmation; - explicitly back up and replace a default source;
- cancel without changing a byte.
Next: Configuration and additional bots.
Configuration and additional bots
/tg config is the recommended entry point. To add bots or tune advanced fields, edit the ignored telegram.config.ts, then run bun run restart or /tg restart in Pi.
File boundaries
| File | Contents | Commit it? |
|---|---|---|
telegram.config.ts | Group, bots, Pi model selection, routing, cost bounds, tools | No |
.env | Telegram/TinyFish tokens and router secret | No |
personas/*.local.md | Real deployment personas | No |
telegram.config.example.ts | Public typed schema example | Yes |
personas/template.*.md | Public generic persona templates | Yes |
.env uses the project’s colon format, not dotenv equals syntax:
telegram_bot_token: 123456:REPLACE_WITH_BOTFATHER_TOKEN
router_secret: REPLACE_WITH_RANDOM_LOCAL_SECRET
Cost-first one-bot configuration
import { defineConfig } from "./src/config.ts";
export default defineConfig({
group_peer_id: 1234567890,
provider: "openai-codex",
model: "gpt-5.6-luna",
reasoning_effort: "off",
cache_retention: "short",
compaction_model: "openai-codex/gpt-5.6-luna:low",
max_suffix_tokens: 12_000,
max_message_tokens: 4_096,
context_window: 65_536,
media: {
mode: "vision", // default; "context" attaches images to the chat model directly
max_images_per_turn: 4,
download_concurrency: 2,
},
vision: {
enabled: false,
foreground_media_limit: 2,
concurrency: 2,
},
telemetry_retention_days: 90,
raw_update_retention_days: 30,
message_event_retention_days: 365,
bots: [{
id: "friend",
name: "Mochi",
token_env: "telegram_bot_token",
persona_path: "personas/friend.local.md",
routing_p: 0.1,
sticker_sets: [],
tools: { send: true, search: false, run_js: false },
}],
});
See the repository’s telegram.config.example.ts for annotated advanced defaults. TypeScript config is trusted local code. Edit only configuration you maintain; do not execute unreviewed snippets.
Add a second or third bot
- Add a distinct token key to
.env. - Copy a public template to a new ignored persona.
- Append an object to
bots;idmust be unique and contain only letters, numbers,_, or-. - Perform a controlled restart, then verify with
/tg attach <id>and/tg status <id>.
{
id: "helper",
name: "Nori",
token_env: "helper_bot_token",
persona_path: "personas/helper.local.md",
routing_p: 0,
tools: { send: true, search: false, run_js: false },
}
routing_p: 0 disables only probability sampling. Mentions, direct replies, and the configured name remain explicit triggers. The sum of every bot’s routing_p must be <= 1, and configuration order defines deterministic probability-bucket order.
Each bot has an isolated Telegram poller, agent session, model selection, state, and telemetry. Bots share one Pi model runtime/auth snapshot plus the target group and canonical SQLite history.
Pi model and tool overrides
The public example pins an explicit cost-first profile: Luna, reasoning off, short cache retention, and Luna low for compaction. The wizard pins the provider/model that it already preflighted through Pi, so a later Pi default change cannot silently change this deployment. Existing hand-written configuration may still omit those two fields for compatibility and inherit Pi’s merged defaults, but omitted reasoning_effort means off rather than inheriting Pi’s thinking level. The daemon uses Pi’s native resource loader for user-installed provider extensions, so plugin model capabilities and cost metadata match interactive Pi; project extensions do not enter bot sessions. A per-bot override may select another catalog entry; switching provider requires both provider and model. Authentication always comes from Pi, never this configuration or .env.
reasoning_effort must be both a valid Pi-wide enum and a level supported by the selected model. Pi’s SDK silently clamps unsupported values to a nearby level; to prevent cost, behavior, and status from disagreeing, Telegram agent refuses to start before any Telegram/provider call and reports the requested and supported values. The same check covers main bots, compaction_model, and an enabled vision model. Use Pi /model to inspect selectable levels; for example, deepseek-v4-flash accepts only off, high, and max.
media.mode selects how media reaches a model. The default "vision" works with any chat model: when vision.enabled is true, the auxiliary_visual_model describes each media item as text and the chat model reads that description. The opt-in "context" mode instead attaches photos, static stickers, and sampled video frames to the chat model’s context directly as images; vision descriptions never enter the context — no media-update events are produced, even for descriptions persisted earlier — so the main model must accept image input — a text-only chat model makes the daemon refuse to start with image_input_unsupported; check a model’s input capabilities with Pi /model. The compaction_model is exempt: it only summarizes text.
Compaction uses only the configured compaction_model; failures do not switch to the main model. provider_retries limits additional attempts for retryable chat and summary failures; 0 disables both Pi and adapter retries. Each request deadline covers stream creation and consumption. Cancellation or daemon shutdown aborts the summary request.
Custom OpenAI-compatible endpoints (self-hosted gateways, proxies, etc.) are registered through Pi’s native ~/.pi/agent/models.json — no project-side extension is needed: declare baseUrl, api: "openai-completions", and apiKey (which may reference an environment variable as "$ENV_VAR") under providers, and give each model explicit input (e.g. ["text","image"]), contextWindow, and maxTokens. After registering, confirm the model in Pi /model, then set provider/model in this config; if you plan to use media.mode: "context", the model declaration must include image input or the daemon fails fast at startup.
These controls are bounded by default:
max_suffix_tokens: 12000andmax_message_tokens: 4096cap new provider-visible Telegram context;context_window(default 65,536) caps the main model’s effective context window;compaction_thresholdmust stay at or belowcontext_window− 16,384 (Pi’s response reserve);cache_retention: "short"controls the main chat request, while compaction always uses its configured cheap task model with provider cache retention disabled;media.modedefaults to"vision". In the opt-in"context"mode,max_images_per_turn(default 4, ~1.1K tokens each) caps the images attached to one provider call, and media beyond the cap or the context budget degrades to text placeholders;download_concurrency(default 2) caps parallel Telegram downloads and video frame extractions;vision.enabledis false by default and applies to vision mode. When enabled, each turn handles at mostforeground_media_limituncached media items and all bots share one FIFO gate withconcurrencyactive jobs. A video occupies one slot from Telegram download through frame extraction and its provider request. Video frame sampling — needed by context mode and by enabled vision — requiresffmpegandffprobeon the daemon host PATH; missing tools make videos fall back to text placeholders (skipping before download and consuming no provider tokens) and produce an operator-only installation hint, without affecting daemon readiness, chat, images, or sticker sending;- telemetry, raw updates, and immutable message events default to 90, 30, and 365 days. Old message events are pruned only after every known bot cursor consumed them and no direct-reply obligation references them.
Changing model, media mode, reasoning, cache policy, persona, tools, serializer, or another cache-visible field produces a new context fingerprint. The next controlled restart preserves the old session file but starts a new session before restoration, so stale context is never resumed under a new identity.
tools controls:
send: Markdown text converted locally to Telegram message entities, plus static, animated, and video sticker delivery; ordinary prose keeps ordinary weight; an optional reaction (one of Telegram’s fixed reaction emoji) lands on thereply_tomessage as an attitude signal and never replaces an owed reply;search: enables bounded TinyFish search and single-page retrieval through one tool; it requires the TinyFish key selected bytinyfish_key_envin.env;run_js: constrained deterministic computation; it is off by default because model-provided JavaScript still has a residual sandbox risk.
Search and run_js are disabled unless their fields are explicitly true; there is no legacy default. Before enabling search, add the TinyFish credential to .env (the default key name is tiny_fish_api_key); it is unrelated to Pi model authentication. Once enabled, the agent can search explicitly or read one public HTTP(S) page when an answer needs its contents. It never eagerly fetches every group link and does not support authenticated, private, or local targets.
Routing and administrative commands
- Mention > reply > configured name > probability. Bot messages never trigger bot-to-bot runs.
routing_pcontrols a normal human message’s response opportunity, not a quota for final group posts. Each eligible message produces one deterministic value and enters at most one cumulative bucket. When the sum is 1, every eligible message has exactly one probability target.sampling_cooldown_msapplies only to probability routing; it defaults to 2000, and 0 disables cooldown.- A busy or cooling probability target is skipped without reassignment. Mentions, replies, and configured names use the explicit path. Even after a run starts, the persona may remain silent and delivery may fail, so public-message ratios need not equal
routing_p. - Empty
telegram_adminsdenies Telegramcompact/set. When needed, prefer your own positive numeric user ID; never copy a placeholder ID. - Telegram
/set <routing_p|cooldown_ms> <value>writes through totelegram.config.ts(atomic write plus full validation; any failure rolls the file back). The in-memory effective value updates immediately and survives restarts.
Use bun run debug for read-only deployment diagnostics (see Operations and Troubleshooting).
Multiple groups
One deployment has one group_peer_id. Multiple groups require isolated working directories and data/session/database/PID/socket resources. Do not run a second group in one checkout by only switching configuration files.
Next: Chat and observe in Pi.
When context images exceed the byte budget, that compaction temporarily reduces the retained-history window, then restores the setting. Shared media files use reference-based cleanup. Pending routing handoffs protect their raw updates from retention; control-message exclusion identities persist independently of telemetry.
Cache schema 20 automatically starts a new context epoch and preserves old session files; no manual database changes are needed. Mentions, including caption mentions, take precedence over replies across all bots, regardless of bot ordering.
Chat and observe in Pi
Open the feed
The daemon stays online independently. Open or close Pi whenever needed:
bun run pi
Successful first setup attaches the global feed automatically. Later, choose the scope explicitly:
/tg attach # Group messages + every bot's LOCAL events
/tg attach friend # Group messages + friend LOCAL/usage only
/tg more # Load one older history page
/tg detach # Disconnect live IPC but retain the transcript
The Telegram feed is one TUI-only Pi custom entry. Pi owns scrolling, resizing, selection, themes, and image layout. One line above the editor groups the feed scope, connection state, and compose guidance. While attached, the extension uses Pi’s official footer API for the path and Telegram usage/model rows, while hiding the unrelated operator-usage row. Displaying messages does not put them into the current Pi agent’s provider context.
Use Tab or Pi’s selection menu after /tg . Bot arguments come from the currently validated config.
Send directly
After attach, the Pi editor sends to Telegram by default. A filtered feed uses that bot directly. A global feed opens Pi’s native selector for every submission when several bots exist, and bypasses it when only one exists.
/tg attach friend # Send directly as friend
/tg attach # Choose an identity for each message when needed
/tg compose friend # Optional: pin friend for consecutive messages
/tg compose off # Temporarily return the editor to Pi
/tg compose # Restore the current feed scope
The attached-feed header shows either send as ... or choose bot on send immediately after attached; choosing and sending update there in place. Canceling the selector restores the exact editor text and sends nothing. Compose intercepts only interactive editor input; RPC and extension sources continue to Pi. Attachments are blocked instead of silently sending only their caption.
An explicit failure restores the editor text. If the acknowledgement is lost or the connection drops during send, the outcome is unknown:
- compose closes automatically;
- the extension does not retry;
- inspect the Telegram group;
- send again only when the message is absent.
This boundary prevents a remote success plus local acknowledgement failure from creating duplicate messages.
Status
/tg status # Global Telegram telemetry
/tg status friend # Lifetime + latest details
Pi /tg status and Telegram /status share the unified telemetry semantics: lifetime covers retained SQLite llm_runs, including compaction calls, while detailed status takes live used/window/percent from the corresponding Pi session rather than the latest run or a historical prompt sum. The attached footer keeps its previous latest-run semantics and Pi-native path and usage/model rows, while compose guidance stays in the feed header; /tg detach restores Pi’s default footer.
Local events, streams, and media
- Assistant thinking/text/tool partials update one Pi-native card in place. Persistent LOCAL/Telegram events replace them at completion; partials are not stored in SQLite.
- Local assistant text when a bot does not call
sendremains feed-only and never reaches the group. media.modeselects the media pipeline. In the default"vision"mode, photo, sticker, and video vision runs lazily only when a real bot turn needs media context andvision.enabledis true; a video contributes at most three fixed representative frames, all interpreted in one vision call. In the opt-in"context"mode the main model sees context images directly with no vision model and no description text — descriptions persisted earlier never enter the context either: photos and static stickers are attached as images, and videos (including video stickers, GIF animations, and video notes) are sampled into 1-3 representative frames. Media beyondmedia.max_images_per_turnor the context budget degrades to text placeholders. Opening the UI never adds a provider call.- In vision mode, a vision description belongs to the shared group message, so global and every one-bot feed render it directly below the media as a
Visionline. A one-bot filter limits only LOCAL events and usage. - Voice, audio, non-video documents, and TGS animated stickers are always text placeholders — a limit of the current model API, which accepts image content blocks only. User- and bot-sent static photos/stickers share the local inline display path. Videos, animations, video notes, video documents, and video stickers retain a media placeholder in the feed even when the model receives sampled frames or a vision description. Inline visibility still follows Pi terminal capabilities; text, media labels, and vision descriptions remain readable fallbacks.
Web search and link reading
After enabling tools.search for a bot and configuring a TinyFish key, the agent can use one tool on demand: a query returns at most five compact results, while a URL reads one public HTTP(S) page. Group links are never fetched eagerly; retrieval happens only when the answer needs page contents.
Page text has an 8,000-character local guard and a 2,048-token provider-output cap, then is enclosed in a fixed untrusted-content boundary. Instructions in a page do not become agent instructions. Authenticated URLs, localhost, and private or link-local targets are rejected before the request. Events and logs retain only hostname, character count, and fixed outcome categories—not the URL path/query/fragment or page body.
Daemon commands in Pi
/tg start
/tg restart
/tg stop
/tg status-daemon
/tg restart closes compose and old IPC, then replaces the whole deployment through controlled process management. A ready result restores the feed; failure retains the transcript and gives a diagnostic.
Next: Daily operations.
Daily operations
Canonical commands
bun run start
bun run status
bun run restart
bun run stop
start: starts in the background and waits for PID/socket readiness; invalid config fails before any bot polls.restart: serially stops the deployment’s PID owner and orphan processes, waits for every PID/file/socket to disappear, then starts one replacement.status: verifies that the PID belongs to this repository’s daemon instead of trusting the file alone.stop: gracefully stops bots, agents, and IPC with SIGTERM.
Logs are in data/daemon.log. The controller shows only a bounded credential-redacted tail. Never post a full .env or unreviewed logs.
Configuration changes
Configuration is not hot-reloaded. After changing telegram.config.ts, .env, or a persona, run:
bun run restart
You can also use /tg config in Pi to validate or safely edit an existing source. Replacement retains local .bak-<nonce> files. Confirm the new deployment is ready before applying your backup-retention policy; do not delete backups as incidental cleanup.
Data and backups
Persistent resources default to data/ and local project session directories. SQLite is canonical history; Telegram is not the restore source.
Before backup:
- run
bun run stop; - confirm
bun run statusno longer reports running; - copy configuration, personas, data, and session resources to an access-controlled destination;
- keep
.envand private personas out of public artifacts.
Never start two daemons against one copied database in the same directory.
Telegram group controls
Public read commands are /help and /status.
/status shows only the bot that actually received the command; use /status@bot_username to target one explicitly. Its rich message shows runtime state, provider/model/effective reasoning, current context/window/percentage, system/tool/summary/message/free segments as one red/purple/brown/blue/green square per rounded 1,024 tokens, average tok/s/send/think time, latest conversation request, retained SQLite totals, cache hit rate, latency/cost, routing, and latest compaction. The square bar occupies its own line and the legend follows line by line. Current context comes directly from the Pi session; after compaction it remains unknown until the next main request instead of showing a previous epoch. It shares the unified telemetry semantics with Pi /tg status. If Telegram definitively rejects the rich-message method or format before creating a message, the daemon sends one independently generated plain-text projection instead. It does not resend after uncertain outcomes.
Only telegram_admins may run:
/compact
/set <routing_p|cooldown_ms> <value>
A command acts on the bot that received it; append @bot_username to target a specific bot. The deterministic control plane consumes these commands outside persona/provider context. compact uses the existing auxiliary summarization model and may incur cost. Busy bots are not aborted. set writes through to telegram.config.ts, so the new value survives restarts.
Real verification
Default bun test avoids Telegram/provider calls; the test preload mechanically rejects every non-loopback network access. Networked scripts require a bot selection:
bun run scripts/smoke-pi.ts --bot friend
bun run scripts/e2e-agent.ts --bot friend
bun run scripts/e2e-compaction.ts --bot friend
These commands may incur cost or post group messages. Read the daemon runbook first and record the selected bot, expected side effects, and rollback.
Why working directories must be isolated
One working directory currently hosts one group deployment. This is not merely a UI limitation: the following resources belong to the working directory and have no deployment namespace:
- the single
group_peer_idand canonical SQLite history, including each bot’s consumed cursor, visible references, and reply obligations; - agent sessions and context epochs;
- each poller’s Telegram update offset and the shared router secret;
- the daemon PID, control lock, and Unix socket.
Running a second group in one checkout by only switching configuration files therefore does not create two deployments. It can feed one group’s history into another group’s model context, skip updates through the wrong offset, or make daemons compete for one PID/socket.
Use a separate clone or worktree for a second group. Give it independent .env, config, personas, and Telegram bot tokens, plus a separate data/database, sessions, PID/lock/socket, and daemon working directory. Do not merely copy the database or point both directories back to shared data.
This boundary follows the project’s minimal-design principle: reuse an existing, inspectable filesystem isolation boundary instead of adding namespaces, hot reload, and another control plane for an unrequested multi-tenant product. Read the cost design overview for the philosophy and the project description for the authoritative boundary.
Next: Troubleshooting.
Troubleshooting
Choose a safe next action from the observable symptom. Do not delete data, PID files, or sockets just to experiment. The daemon runbook owns full recovery procedures.
bun run pi does not start
Run:
bun install --frozen-lockfile
bun run pi --version
The expected version is the project-locked Pi 0.84.1. If installation fails, retain the error and fix registry/network access. Do not hide the problem by switching to an unlocked global Pi.
/tg config is missing
Confirm you started bun run pi from the repository root and package discovery loaded .pi/extensions/tg-extension.ts. config is static and does not depend on an existing deployment. If it is completely absent, inspect Pi/package loading instead of creating an empty config file.
The wizard refuses configuration
- Field error: correct the fields named in the notification; values are never echoed.
- Existing files: choose validate/editor or explicitly confirm backup-replace. Cancellation preserves bytes.
- Pi model preflight: leave the wizard, use Pi
/loginand/model, then retry. No deployment file was written.
Config is valid but the daemon is not ready
/tg status-daemon
/tg restart
Then inspect data/daemon.log. Typical causes include an invalid Telegram token, unreachable network, changed Pi login/default-model settings, a model absent from Pi’s catalog, a context-mode main model without image input (image_input_unsupported — only when media.mode: "context"; check capabilities with Pi /model), or a bot missing from the target group. Valid files remain, so you do not need to paste the token again.
daemon starting persists
Configured sticker sets may make the first Telegram catalog fetch slower. Vision work occurs only when explicitly enabled, and context-mode media preparation (downloads and frame sampling) runs lazily for real turns; neither is part of the startup path. Run bun run status and inspect redacted logs. A live child after the 60-second wait is reported only as starting; readiness requires a real socket connection.
After changing a model, persona, cache policy, tools, or another cache-visible field, a session ready (new) line is expected. The context fingerprint deliberately prevents restoring the old session under the new identity; the old file is retained for recovery/audit.
Telegram 401 or no group messages
- 401: rotate or correct that bot’s token, ensure
token_envselects the right key, then restart. - Ordinary messages are absent: disable group privacy for that bot in BotFather and confirm it joined the intended supergroup.
- The bot cannot send: inspect group permissions. Do not grant unrelated administrator rights for ordinary reading.
Telegram 409 / duplicate poller
Another process is long-polling with the same token. Run bun run restart; the controller verifies and recovers this deployment’s real daemon and orphans. Do not blindly signal the PID-file number or start concurrently.
Pi feed or compose disconnects
no connected Telegram feed: run/tg attach [bot]and wait for the snapshot connection.unknown bot id: use/tgcompletion or inspect configured IDs.- Unknown compose outcome: inspect the group and retry only when absent.
/tg detachand closing Pi do not stop the daemon; attach again later.
Images do not render inline
A new user- or bot-sent static photo/sticker first shows its media label, then the daemon downloads it in the background and updates the same Pi card. This does not depend on routing or the media pipeline; animated/video media retains a text placeholder in the feed. On startup, legacy absolute cache paths are rebased by filename when the file exists in the current data/media; missing entries are cleared before at most 100 recent static display gaps still referenced by current context, an unconsumed event, or a pending reply are backfilled.
After successful compaction, a bounded batch of local media files no longer referenced by any configured bot is removed automatically. An old Pi card falling back to its label is therefore expected and does not mean that the message, vision description, or Telegram file mapping was lost; a future turn can reacquire the source — reusing a persisted vision result or preparing context images again. Restart does not unconditionally download those files again just for historical display.
If new media remains label-only, inspect only the fixed media_cache_ready/skip/error category and queue number in redacted logs, then check the 1 MiB limit, static-image format, terminal image capability, and project Pi version. Pi still selects Kitty, iTerm2, or native text fallback. Do not add terminal escapes or bypass Pi components. Record terminal type, tmux state, media kind, fixed outcome, and whether a local path exists—never a token, absolute path, or private image contents.
A video has no description or stays a text placeholder
Videos (including video stickers, GIF animations, and video notes) reach a model only through sampled frames: 1-3 frames attached to the main model in context mode, or one vision call over at most three frames in vision mode. A persistent placeholder — or, in vision mode, a missing description — therefore means frame sampling or the vision call is unavailable or failed. Run bun run debug first. video_transcoder_unavailable means the host lacks ffmpeg or ffprobe; start, restart, and status also explain that the package is used only for frame sampling and suggest installation. The warning never blocks daemon readiness or posts into the group: the video skips before Telegram download or a provider call, so it consumes no provider tokens while chat, static images, and all sticker formats continue. Install the FFmpeg distribution package and restart; this failure is not cached permanently. video_probe_failed or video_frame_extraction_failed means the local tools could not read the file; check the 20 MiB bound and format support. Logs never contain its path, stderr, or video contents.
Search or page retrieval fails
- Confirm that the bot has
tools.search: true, that.envcontains the key selected bytinyfish_key_env, and then perform a controlled restart. invalid_urlmeans the target is not an allowed public HTTP(S) URL or contains userinfo or a local/private/link-local address. Never disable the guard to access an internal service.*_timeout,*_http_*,*_response_too_large, andfetch_*are fixed categories. They affect only the current turn; there is no background retry or URL substitution.- Retain only the fixed category and hostname when diagnosing. Never paste the API key, a signed URL’s path/query/fragment, or page contents.
Still unable to recover
Collect only non-sensitive evidence:
bun run statusoutput;bun run pi --version;- a manually reviewed, redacted tail of
data/daemon.log; - the failed command, bot ID, and whether the config was newly written or replaced an existing file;
- terminal and tmux details when UI is involved.
Never submit .env, real personas, full group messages, tokens, API keys, or unredacted absolute paths.
Cost design overview
The project promises no fixed savings percentage. Provider pricing, group activity, persona length, and model cache behavior all vary. Measure your deployment through /tg status and retained SQLite telemetry.
“Minimal” means fewer mechanisms, not fewer safeguards: minimize state, interfaces, network requests, and provider-visible bytes while preserving transactions, timeouts, redaction, tests, and observability. The seven mechanisms below are the current expression of that philosophy, not a roadmap for a general platform.
1. Deterministic routing decides whether to call a model
Local code handles mentions, replies, configured names, and HMAC probability buckets. An unmatched ordinary message creates no provider run. A probability target that is busy or cooling down is not reassigned or sampled again.
This avoids an entire unnecessary call instead of shaving a few tokens after starting one. See Routing architecture.
A healthy direct-address turn (@mention, reply, or configured name) with no public send gets at most one repair turn, provided the addressed messages remain visible. Further silence stays pending instead of looping. Sent, partially sent, and unknown Telegram outcomes are never automatically resent. Ordinary probabilistic silence adds no call.
2. A stable provider prefix reuses cache
The shared protocol comes first, followed by the persona, then a bounded sticker catalog, then fixed-order tool schemas. This maximizes the byte-identical prefix shared by bots. The catalog holds capped s<id>: <emoji> <description> lines (the description is the persisted vision text, degrading to s<id>: <emoji> then s<id> when absent; set names and formats never appear in model-visible text) and remains pinned. A separate list of at most eight recent user stickers that are visible in the current context and sendable by this bot is stored independently, rendered with the same line grammar. The provider sees it once, after the latest Telegram batch, rather than repeated after every historical batch. It is omitted when the suffix budget cannot fit it. All three formats are sent with Telegram’s original file id.
A fingerprint covers the Pi/provider/model/cache policy, protocol, persona, serializer, compaction, extensions, and tools. A cache-visible change increments the schema and creates a new session/epoch before restoration; an old session file is retained but never resumed under a different identity. UI, telemetry, and operator commands may not alter provider bytes. See Cache engineering.
3. Bounded context carries only necessary facts
SQLite retains canonical Telegram history and an immutable event stream. Each bot consumes that stream with a monotonic cursor, while separate visible references describe only full messages still present in the current context. The model receives a token-bounded event batch with direct addresses (@mention / reply / configured-name keyword) first; logs, raw rich JSON, UI state, and unbounded tool output never enter provider context.
The default new-suffix cap is 12,000 tokens and the per-event cap is 4,096. This separates the complete local source of truth from the context necessary for one run. See Architecture and the data model.
4. Compaction changes epochs at an explicit boundary
The main model’s effective context window is capped by context_window (default 65,536). When context reaches the configurable trigger threshold (default 32K, at most context_window − 16,384), it produces a summary, retains the configured amount of recent verbatim text (default 1 token — effectively nothing, leaving only the summary; 20,000 is the recommended production value), and starts a new epoch. Failed or empty summaries do not fabricate an epoch. Structured details replace visible references, while the business-consumption cursor never moves backward or replays compacted history.
Compaction uses a configured cheap task model with provider cache retention disabled, so it is not an every-turn online optimizer. Configuration controls the threshold and retained amount; telemetry validates the result. See Cache engineering and test status.
5. Media reaches a model through one of two bounded modes
Context images count toward retention before Pi prepares compaction, including when text alone would fit the retained window. If the summary model declares image input, discarded images enter that same summary call in message order. Text-only models or missing files retain the available text and report degraded-image counts. Images add summary input tokens, not separate per-image calls. Inputs estimated to exceed the summary model’s window are refused without changing the active context; select a summary model with enough context capacity.
media.mode defaults to "vision": an auxiliary visual model describes each media item as text and the chat model reads that description, so any chat model works. Vision stays disabled until vision.enabled is true. When explicitly enabled, each turn has a media cap and all bots share one FIFO concurrency gate; there are no process-local hourly or daily quotas. Video download, extraction, and the single provider request hold one global slot; each video contributes at most three frames but only one provider call. Results are persisted by media identity (media.vision), reused across bots, and appended as immutable media-update events instead of rewriting old context. UI updates consume cached results without adding a model call.
The opt-in "context" mode costs no extra model calls: the main model sees media directly, so preparation is a Telegram download plus local transcode only — but it requires a chat model with image input (checked at startup). Photos and static stickers become one converted image; videos (including video stickers, GIF animations, and video notes) are sampled into 1-3 frames. Each attached image is charged a fixed 1,100-token estimate against the context budget, bounded by both media.max_images_per_turn (default 4) and that budget; media beyond either limit degrades to a text placeholder instead of an image. The same media identity is prepared once and shared across all bots, with the prepared files recorded in media.context_files so pruning and context packing share one source of truth. Parallel downloads and frame extractions are capped by media.download_concurrency (default 2).
In both modes, voice, audio, non-video documents, and TGS animated stickers remain text placeholders because the current model API accepts image content blocks only; the sticker catalog stays a fixed prefix in the system prompt (carrying persisted descriptions) and never carries images. Static photos and stickers from users and bots enter canonical SQLite first and share one bounded display cache; SQLite stores only a cache-relative filename, so moving a deployment does not pin TUI rendering to the previous absolute path. If FFmpeg is missing, videos skip before download and fall back to text placeholders (consuming no provider tokens), while chat, static images, and sticker sending continue.
After a successful compaction, the daemon deletes a bounded batch of local media files no longer referenced by any configured bot; unconsumed messages and pending replies remain protected. Messages, vision descriptions, sticker short IDs, and Telegram file mappings remain durable, so a future turn can reacquire the source while reusing an existing vision result or preparing context images again. Restart does not automatically download that unreferenced history again.
See the Media architecture.
6. Pages are retrieved only on demand and stay bounded
Search and page reading share one tool instead of adding a fourth stable schema entry. A query returns at most five compact results. A URL creates one request only when the model explicitly needs it; page text has an 8,000-character local guard and a 2,048-token provider-output cap. Turns that do not use the feature add no retrieval request or dynamic page tokens.
Deterministic code handles the untrusted-content boundary, URL safety, and log redaction without another model call. A fetch still consumes one TinyFish request and adds bounded text to the current dynamic context, so actual cost depends on call frequency and page length.
7. UI and telemetry use side channels
The Pi-native feed, assistant partials, feed-status widget, /tg status, and Telegram controls use local IPC, SQLite, and the deterministic control plane. They remain outside personas and main provider context.
Opening Pi, scrolling history, or viewing usage therefore does not create a chat-model call. See the Pi-native transcript architecture and Cache engineering.
Evaluate your deployment
- Use
/tg status [bot]or Telegram/statusunder the unified telemetry semantics to record runs, current context/window, prompt miss/read/write, output, reasoning, latency, and cost; “lifetime” means the configured SQLite retention window.≈marks a local strict-prefix estimate used when the provider omits cache token details; it is not proof of an actual provider hit. Each run freezes cost under its original provider usage and actual provider/model rate at response time, so local estimates never recalculate cost, while totals after a model switch retain old-model cost and add new-model cost; subscription providers may expose only an equivalent pay-as-you-go estimate. - Compare similar activity periods; do not mix providers, personas, or group sizes in one conclusion.
- Base compaction-threshold changes on
bun run debugandllm_runstelemetry context data; do not tune by intuition. - Before changing prompts, tools, or serialization, follow the cache process in the development guide.
- Compare cost per useful public reply as well as cost per run; a silent or failed run is still a provider cost.
- Before adding a capability, try to remove one layer, tool, model call, or dynamic field. Do not expand a one-group deployment into a multi-tenant system without an explicit requirement.