Multi-platform bot (Telegram + Discord) that routes messages to Pi/Codex coding-agent sessions. Each chat/channel gets its own persistent Pi RPC session, and scheduled LLM crons run through Pi print mode.
Telegram Cloud Discord Gateway
│ │
▼ (long polling) ▼ (websocket)
┌────────────────┐ ┌────────────────┐
│ grammY Bot │ │ Discord.js │
│ telegram-bot │ │ discord-bot │
└───────┬────────┘ └───────┬────────┘
│ │
▼ ▼
┌─────────────────────────────────┐
│ Platform Context (interface) │
│ - sendMessage, editMessage │
│ - sendTyping, sendFile │
│ - sendDraft (DM streaming) │
└───────────────┬─────────────────┘
│
▼
┌──────────────────┐
│ Message Queue │
│ - 3s debounce │
│ - mid-turn │
│ collect (20) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Session Manager │
│ - 1 per chat │
│ - LRU eviction │
│ - idle timeout │
│ - resume on │
│ respawn │
└────────┬─────────┘
│ spawns pi --mode rpc
▼
┌──────────────────┐
│ Pi RPC Runtime │
│ - per-agent │
│ workspace │
│ - model from │
│ config │
└────────┬─────────┘
│
▼
OpenAI Codex
Both platforms share one Session Manager and use the same stream-relay logic via the PlatformContext interface. Each platform provides an adapter that handles platform-specific message I/O (Telegram: grammY Context, Discord: discord.js Channel).
Message queue sits between platform bots and Session Manager. Rapid messages are debounced (3s window) into a single prompt. Messages arriving while a session is processing are collected (up to 20) and delivered as reliable follow-up prompts after the current turn completes. Passive echo context and shutdown notices can still be steered best-effort into an active Pi turn.
Context injection: Each message includes metadata — current time, chat type (DM/group/topic), topic name, sender username, and emoji reactions. The agent knows where it is, when it is, and who it's talking to. Reactions are delivered as messages so the agent can respond to a thumbs-up or a ❤️ without the user typing anything.
Cron jobs run separately via launchd plists. Each plist calls run-cron.sh <task-name>, which invokes cron-runner.ts to spawn a one-shot Pi print-mode run with the cron's prompt.
Config: config.yaml defines agents (workspace + model), bindings (chatId/channelId -> agentId), and non-secret pointers to runtime token sources. User-specific overrides live in config.local.yaml (gitignored, deep-merged over config.yaml). At least one platform (Telegram or Discord) must be configured. Tokens resolve from a private SOPS/age file first, with explicitly configured environment variables as deployment overrides.
- macOS (launchd required for bot service management)
- Node.js 22.19+ and npm (Pi package dependencies require Node >=22.19.0)
jq— required by hook scripts (brew install jq)sopsandage, with an age identity available to the launchd user unless you configure only explicit token environment variables- The
pibinary on launchdPATHand Pi auth initialized for the launchd user withpi /login - A Telegram bot token from @BotFather (or Discord bot token)
1. Clone and install
git clone https://github.com/fitz123/claude-code-bot.git ~/.minime
cd ~/.minime/bot && npm install2. Configure for your environment
config.yaml ships with working defaults. Create config.local.yaml for your overrides:
cp config.local.yaml.example config.local.yamlEdit config.local.yaml — set workspaceCwd to the absolute path of the repo or project directory the agent should work in, and chatId to your Telegram user ID (send /start to @userinfobot to find it).
crons.yaml ships with example crons (all disabled). Create crons.local.yaml for your own crons:
cp crons.local.yaml.example crons.local.yamlCreate .claude/settings.local.json with required settings:
{
"outputStyle": "Your output style name",
"autoMemoryEnabled": true,
"autoMemoryDirectory": "/absolute/path/to/your/workspace/memory/auto"
}3. Configure runtime token secrets
Runtime SOPS files are private deployment artifacts. They are gitignored, are not part of the public repo, and should decrypt to the configured key paths without exposing plaintext in logs or commits.
Install the tooling and create the age identity as the same user that owns the launchd jobs:
brew install sops age
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txtUse the printed public recipient in a local .sops.yaml, or pass it directly with sops --age <age-recipient> .... Example creation rule:
creation_rules:
- path_regex: config/.*\.sops\.yaml$
age: age1replace_with_your_public_recipientconfig.yaml already points Telegram at config/secrets.sops.yaml key telegram.bot_token. This bot runtime file is resolved relative to the control workspace, not relative to agent workspaces. Create it with SOPS/age so the decrypted document contains only control-workspace runtime secrets:
mkdir -p config
sops config/secrets.sops.yamlExample decrypted shape, with encrypted values in the file:
telegram:
bot_token: ENC[...]
discord:
bot_token: ENC[...]
tavily:
api_key: ENC[...]Tavily web-tool secrets use the same control-workspace path by default: config/secrets.sops.yaml key tavily.api_key, described in web-tools setup. Do not copy Telegram, Discord, or Tavily secret values into agent workspaces.
4. Initialize Pi auth
pi /loginRun this as the same user that owns the launchd jobs. Pi manages its own auth in ~/.pi/agent/auth.json; the bot does not store coding-agent credentials.
5. Create launchd service
mkdir -p ~/.minime/logs
cp bot/telegram-bot.plist.example ~/Library/LaunchAgents/ai.minime.telegram-bot.plistEdit the plist — replace WORKSPACE, LOG_DIR, and USER_HOME with your paths.
6. Validate and start
cd ~/.minime && npx tsx bot/src/config.ts --validate
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.telegram-bot.plist7. Verify
launchctl list | grep ai.minime.telegram-bot
tail -f ~/.minime/logs/telegram-bot.stdout.logSend a message to your bot in Telegram to confirm it responds.
Discord: Add discord.tokenSopsKey for discord.bot_token in the private SOPS file, or configure discord.tokenEnv as an explicit environment override. See config.yaml for full reference.
Crons: Add your crons to crons.local.yaml (copy from crons.local.yaml.example), then generate and load plists:
cd ~/.minime/bot && npx tsx scripts/generate-plists.ts
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.cron.<name>.plistOptional rules: cp .claude/optional-rules/memory-protocol.md .claude/rules/custom/
ADR governance: mkdir -p reference/governance && cp reference/governance/decisions.md.example reference/governance/decisions.md
The bot runs as a launchd service: ai.minime.telegram-bot.
# Check status
launchctl print gui/$(id -u)/ai.minime.telegram-bot 2>&1 | head -5
# Restart (graceful — validates config, sends SIGTERM, waits for drain, returns new PID)
bot/scripts/restart-bot.sh
# Restart after editing ~/Library/LaunchAgents/ai.minime.telegram-bot.plist
bot/scripts/restart-bot.sh --plist
# Stop
launchctl bootout gui/$(id -u)/ai.minime.telegram-bot
# Start (if stopped)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.telegram-bot.plistWarning: Graceful restart sends SIGTERM — the bot injects a shutdown message into active sessions and waits up to 60s for turns to complete before exiting. Idle sessions close immediately. launchd auto-restarts via KeepAlive. Still, active work is interrupted — always confirm before restarting. Use --plist after editing the plist on disk, because launchd caches the plist at bootstrap time and a plain SIGTERM restart would pick up the stale cache.
-
Edit
crons.local.yaml— add a new entry:- name: my-task schedule: "30 9 * * *" prompt: > Do the thing. agentId: main deliveryChatId: YOUR_CHAT_ID
Cron field reference:
Field Type Required Description namestring yes Unique identifier for the cron job schedulestring yes 5-field cron expression, local timezone type"llm"or"script"no "llm"(default) runs a one-shot Pi print-mode backend;"script"runs a shell commandengine"pi"no Optional compatibility field for LLM crons. Omit or set "pi". Ignored for script cronspromptstring for llm Prompt sent to the selected LLM cron engine commandstring for script Shell command to execute agentIdstring yes Must match an agent in config.yamlorconfig.local.yamldeliveryChatIdnumber no Telegram chat ID for delivery (falls back to config default) deliveryThreadIdnumber no Telegram forum topic ID for delivery timeoutnumber no Per-cron timeout in ms (default: 900000 = 15 min) enabledboolean no Set falseto disable without deleting (default:true)Minimal LLM example:
crons: - name: read-only-example schedule: "0 9 * * *" type: llm engine: pi agentId: main prompt: "Summarize read-only status and include NO_REPLY if there is nothing notable."
Pi cron behavior:
- LLM crons always run Pi print mode with
pi -p --no-session --no-extensions, fixed modelopenai-codex/gpt-5.5, and the agentsystemPrompt/workspace context. - Agent
thinkingmaps to--thinking; absent values default tomedium, and invalid configured values fail validation. - The
pibinary must be on the launchd cronPATH, and Pi auth must exist at~/.pi/agent/auth.jsonfor the launchd user. Runpi /loginas that user before enabling LLM crons. - Set
enabled: false, convert the cron totype: script, or unload the cron plist to stop a problematic cron. Engine values other thanpiare rejected.
Cron result handling:
- Empty stdout with empty stderr is a successful no-delivery run.
NO_REPLYis a successful no-delivery LLM run.- Pi stderr-only success, non-zero exit, signal exit, or spawn timeout are failures and trigger the existing
⚠️ Cron FAILnotification plus the failure metric.
- LLM crons always run Pi print mode with
-
Generate launchd plists:
cd ~/.minime/bot && npx tsx scripts/generate-plists.ts
-
Load and test:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.cron.my-task.plist launchctl start ai.minime.cron.my-task tail -f ~/.minime/logs/cron-my-task.log
To remove: launchctl bootout gui/$(id -u)/ai.minime.cron.<name>, delete from crons.local.yaml, regenerate.
-
Add an agent and binding to
config.local.yaml:agents: new-agent: id: new-agent workspaceCwd: /absolute/path/to/workspace-new model: gpt-5.5 bindings: - chatId: 111111111 agentId: new-agent kind: dm label: New Agent DM
See config.yaml for all binding options including
requireMention,voiceTranscriptEcho,typingIndicator, and per-topic overrides for forum supergroups. -
Restart (the script validates config before sending SIGTERM):
bot/scripts/restart-bot.sh
-
Add the Discord token to your private SOPS file at
discord.bot_token, or expose it through a configured environment variable. -
Add the
discordsection toconfig.local.yaml:discord: tokenSopsKey: discord.bot_token # tokenEnv: DISCORD_BOT_TOKEN bindings: - guildId: "YOUR_GUILD_ID" agentId: main kind: channel label: My Server requireMention: true
See config.yaml for per-channel overrides and guild-wide defaults.
-
Required bot permissions/intents: Guilds, GuildMessages, MessageContent (privileged), DirectMessages. Slash commands (
/start,/reconnect,/clean,/status) are registered per-guild on startup.
Telegram bindings are optional; the bot can run Discord-only.
Token resolution checks SOPS first, then a configured environment variable:
| Field | Source | When to use |
|---|---|---|
secrets.sopsFile + telegramTokenSopsKey / discord.tokenSopsKey |
SOPS/age file read with sops -d --extract |
Canonical control-workspace deployment backend for bot platform tokens |
telegramTokenEnv / discord.tokenEnv |
Environment variable name read from process.env |
Explicit environment override for launchd, Linux, containers, or systemd |
Example:
secrets:
sopsFile: config/secrets.sops.yaml
telegramTokenSopsKey: telegram.bot_token
telegramTokenEnv: TELEGRAM_BOT_TOKEN
discord:
tokenSopsKey: discord.bot_token
tokenEnv: DISCORD_BOT_TOKENSOPS key paths are dot paths whose segments must match [A-Za-z0-9_-]+, such as telegram.bot_token. A configured *SopsKey requires secrets.sopsFile; relative SOPS file paths resolve against the control workspace, not the config file directory or any agent workspace. Invalid key syntax or a missing secrets.sopsFile is a config error. Runtime lookup failures such as a missing file, decrypt failure, or blank decrypted value fall back to the configured env var, then fail with sanitized source/key/env/failure-kind details if no source resolves.
Legacy telegramTokenService and discord.tokenService Keychain settings are rejected with migration errors. Telegram token resolution is required only when Telegram bindings are configured; Discord-only deployments can set bindings: [] and provide a Discord token source.
The bot maintains persistent context across sessions through a memory system rooted at each agent workspace.
MEMORY.mdat the agent workspace root is a curated index of memory files. Keep it concise — the Pi context assembler loads it into the agent's initial context on every session.memory/auto/holds typed memory files (user,feedback,project,reference) with frontmatter, written by the agent or thememory-consolidationnightly cron.memory/diary/holds narrative digests from consolidation runs.
MEMORY.md is auto-loaded via the @MEMORY.md line in CLAUDE.md. CLAUDE.md remains the agent workspace context entry point even though Pi/Codex is now the runtime; the Pi context assembler follows that import and includes the workspace memory index.
Do not remove the @MEMORY.md line from CLAUDE.md. Without it, your workspace MEMORY.md will not be auto-loaded and the agent will start every session with no memory index. See .claude/rules/platform/memory-protocol.md for the full protocol.
The package exposes a built CLI as minime-bot after npm run build, npm pack, or package installation. Package-installed Pi dependencies require Node >=22.19.0, matching bot/package.json engines.node. From a source checkout, the same workspace validator is available through npm scripts:
cd bot
npm run build
npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace
npm run validate-config
node dist/cli.js --helpInstalled-package commands use the same surface:
minime-bot --help
minime-bot config validate --workspace /path/to/control-workspace
minime-bot workspace validate --workspace /path/to/control-workspace--workspace takes precedence over MINIME_WORKSPACE_ROOT; under ADR-081 it names the control/app workspace that owns config, crons, bindings, runtime state, and global secret references. If neither is set in the current source checkout, the workspace defaults to the repository root. Relative agent workspaceCwd values resolve against the control workspace. Absolute agent workspaces are allowed to live outside the control workspace after existence/directory validation.
MINIME_CONFIG_PATH and MINIME_CRONS_PATH override the control workspace config and crons files. Relative override values resolve against the control workspace. They are non-secret path references and are propagated to Pi children only when explicitly configured.
Validation is structural by default. These commands load config with secret resolution disabled, parse crons, and print effective paths without decrypting SOPS files or printing secret values. The validator hard-fails missing or non-directory control workspaces, invalid or missing config paths, invalid crons paths, missing or non-directory configured agent workspaces, and missing package Pi extension directories. It warns for missing crons files and missing optional agent context files or rules directories.
Interactive agents run through Pi RPC + OpenAI Codex. The optional per-agent provider field remains only as a compatibility field:
provider |
Backend | Status |
|---|---|---|
| omitted | Pi RPC + OpenAI Codex (pi --mode rpc) |
Supported |
pi |
Pi RPC + OpenAI Codex (pi --mode rpc) |
Supported |
agents:
main:
id: main
workspaceCwd: /absolute/path/to/workspace
model: gpt-5.5
# provider: pi
# thinking: highEach agent must set an explicit Pi-appropriate model (for example, model: gpt-5.5). The top-level defaultModel key is accepted for old config overlays but is no longer inherited by agents. The bot refuses to start if an agent omits model.
Agents may also set thinking, which is passed as Pi --thinking. Allowed values are off, minimal, low, medium, high, and xhigh.
The typed Pi RPC module (bot/src/pi-rpc-protocol.ts) handles JSONL splitting, spawn/send helpers, and event translation into the bot's existing stream relay shapes. The Session Manager spawns Pi RPC, streams responses to Telegram/Discord, persists and resumes session IDs across restarts, and sends user prompts with followUp semantics so they queue instead of being rejected if Pi is still busy.
The Pi binary (@earendil-works/pi-coding-agent) is resolved from PATH; the bot prepends /opt/homebrew/bin to the spawned process's PATH, so ensure pi is reachable there or on the inherited PATH. Auth is managed by Pi itself, which reads ~/.pi/agent/auth.json (the bot does not create or manage that file).
Every pi --mode rpc spawn suppresses Pi's ambient extension discovery with --no-extensions, then loads first-party extensions as repeatable --extension <abs-path> args appended by buildPiSpawnArgs (see resolvePiExtensionArgs). Loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. Source checkout runs load the TypeScript wrappers under bot/.claude/extensions/; built and installed package runs load generated wrappers under bot/dist/extensions/pi/ or node_modules/minime/dist/extensions/pi/, including copied subagent agents/*.md and prompts/*.md resources.
| Extension | Wrapper | What it does |
|---|---|---|
| web-tools | bot/.claude/extensions/web-tools.ts |
Registers web_search + web_fetch, Tavily-backed. The wrapper reads tavily.api_key from config/secrets.sops.yaml under the control workspace passed through MINIME_WORKSPACE_ROOT, independent of the Pi session cwd. A missing key warn-logs a sanitized message but leaves the tools registered; failures return a graceful "unavailable" result instead of throwing. |
| subagent | bot/.claude/extensions/subagent/ |
The vendored official subagent extension (directory), adapted only to spawn an isolated pi -p child on the openai-codex provider. Exposes the subagent tool (single / parallel / chain) that the Agent/Task delegation skills invoke. Children load web-tools only; they never load subagent/index.ts, so recursive spawning stays disabled. Child wrapper resolution fails closed if a required wrapper is missing. Child errors warn-log. |
web-tools setup (optional): add a Tavily API key to the control-workspace SOPS file at <control-workspace>/config/secrets.sops.yaml using key tavily.api_key. The decrypted shape can share the same file as Telegram and Discord tokens, or contain only the web-tool secret if those tokens use explicit environment overrides:
tavily:
api_key: ENC[...]Omit tavily.api_key to leave the tools registered-but-unavailable. The web-tools wrapper never reads secrets from agent workspaces and never receives the plaintext Tavily key through env or argv.
Kill-switch: set PI_EXTENSIONS_DISABLED=1 in the bot's environment to spawn Pi RPC chat sessions with no explicit first-party wrappers; the spawn still passes --no-extensions, so ambient discovery does not load other extensions. A configured wrapper missing on disk fails loudly instead of silently dropping part of the first-party extension contract.
Rollback:
- Disable Pi extensions (no deploy): set
PI_EXTENSIONS_DISABLED=1in the bot's launchd environment, thenbot/scripts/restart-bot.sh --plist(env-var changes are plist-level — see Start / Stop). Pi RPC chat spawns drop explicit first-party wrappers immediately while still blocking ambient extension discovery. - Cron rollback: set
enabled: false, unload the cron plist, or change the job totype: scriptand reload its plist. LLM crons only run through Pi. - Code:
git revert <merge-commit>in this repo →git fetch upstream && git merge upstream/mainin the workspace →bot/scripts/restart-bot.sh.
All log output uses structured format: TIMESTAMP LEVEL [tag] message.
| Setting | Type | Default | Description |
|---|---|---|---|
logLevel (config.yaml) |
string | "info" |
Log verbosity: debug, info, warn, error |
LOG_LEVEL (env var) |
string | — | Overrides logLevel from config when set |
When metricsPort is set in config.yaml, the bot exposes a Prometheus-compatible /metrics endpoint at that port. By default it binds to 127.0.0.1. Set metricsHost: "0.0.0.0" when the scrape source is reachable only via a non-loopback interface (e.g. Linux when a Prometheus container scrapes via host.docker.internal — that resolves to the docker bridge gateway, not loopback like on macOS Docker Desktop). When exposing on 0.0.0.0, the host firewall must restrict external access.
metricsPort: 9090See bot/src/metrics.ts for the full list of exported metrics.
For dashboard continuity, token, cost, and turn-duration metrics keep their legacy bot_claude_* names (bot_claude_tokens_*, bot_claude_cost_usd_total, bot_claude_turn_duration_seconds). In the Pi-only runtime these are metric names only: they record usage and duration reported by the active runtime, not a Claude subprocess. Pi retry/resume metrics remain under bot_pi_*.
Cron runs also write best-effort Prometheus textfile metrics for node_exporter. These do not appear on the bot's metricsPort endpoint. Configure node_exporter with --collector.textfile.directory=/opt/homebrew/var/node_exporter/textfile, or override the directory with CRON_HEALTH_TEXTFILE_DIR for tests or alternate installs. Ensure the launchd cron user can create and write the directory. Each cron gets collision-resistant textfiles with the raw cron name escaped as the cron label:
minime_cron_last_success_timestamp{cron="<name>"}is updated only after successful runs and remains present after later failures.minime_cron_last_exit_code{cron="<name>"}is updated on every run.
The Pi RPC provider (see Provider backends) registers its own metrics: bot_pi_turn_duration_seconds (histogram, label agent_id), the retry counters bot_pi_retry_total, bot_pi_429_total, bot_pi_overload_total, and bot_pi_retry_unknown_total (every retry increments bot_pi_retry_total plus exactly one signal-specific counter), and bot_pi_session_resume_discarded_total (label agent_id, incremented once per graceful resume-recovery — a stored Pi session id Pi could not find, discarded for one fresh start).
Telegram and Discord /status use the same compact local renderer. The normal
healthy output shows session count, uptime, agent/provider, model,
thinking, processing-or-idle state, and session id. It omits noisy
diagnostics such as RSS memory, PID, restart count, and last success unless those
values are actionable: dead process, non-zero restarts, or missing/stale last
success.
Codex quota in /status is also local-only. The command never calls Pi, Codex,
or the network; it only reads the sampler cache from CODEX_QUOTA_STATE_FILE (or
the default cache path). If the cache is fresh, /status shows 5-hour and weekly
used/left percentages, reset ETA, plan/active-limit metadata when present, sample
age, and last probe attempt. If the cache is stale, missing, or malformed, it says
so explicitly instead of probing live.
A separate low-frequency sampler runs a tiny Pi SSE probe, loads only
bot/.claude/extensions/codex-usage.ts, and writes:
- JSON cache:
CODEX_QUOTA_STATE_FILE, defaulting tobot/.tmp/codex-quota-state.jsonwhen run frombot/. - Prometheus usage textfile:
codex_usage.prominCODEX_QUOTA_TEXTFILE_DIR,NODE_EXPORTER_TEXTFILE_DIR, or/opt/homebrew/var/node_exporter/textfilewhen that directory is writable. - Prometheus probe textfile:
codex_usage_probe.promin the same textfile dir.
The sampler fails loudly if no configured or writable Prometheus textfile directory is available, rather than writing metrics to an unsupervised fallback directory.
A sampler attempt is considered successful only when the Pi child exits cleanly
and the JSON cache is refreshed by quota headers from that attempt. Missing
headers, write warnings, timeouts, and non-zero exits record a failed attempt,
preserve the last successful quota values, and leave codex_usage.prom unchanged.
The JSON cache is still updated with lastAttempt, lastAttemptTimestamp, and
probeSuccess when the existing cache is valid, so /status can report the
latest failed probe without probing live.
Normal Pi conversations must stay on transport: auto. Do not set global
~/.pi/agent/settings.json or the bot workspace .pi/settings.json to
transport: "sse" for live sessions. Codex quota headers are available on Pi's
Codex SSE path, but live Codex SSE can fail before any response body with
Codex SSE response headers timed out after 10000ms
(DEFAULT_SSE_HEADER_TIMEOUT_MS = 10_000). Forcing that path globally would make
interactive Telegram or Discord conversations less reliable. The sampler creates
its own isolated project cwd and writes { "transport": "sse" } only there, so a
slow or failed SSE header probe cannot degrade live sessions.
Sampler command:
cd ~/.minime/bot
CODEX_QUOTA_TEXTFILE_DIR=/opt/homebrew/var/node_exporter/textfile \
CODEX_QUOTA_STATE_FILE=$PWD/.tmp/codex-quota-state.json \
npx tsx scripts/codex-quota-sampler.tsSupported CLI flags:
| Flag | Description |
|---|---|
--model <model> |
Probe model; same normalization as CODEX_QUOTA_MODEL. |
--textfile-dir <dir> |
Prometheus textfile directory. |
--state-file <file> |
Codex quota JSON state file. |
--sampler-cwd <dir> |
Isolated cwd that receives .pi/settings.json. |
--timeout-ms <ms> / --timeout <ms> |
Wall-clock timeout for the Pi child. |
--pi-bin <path> |
Pi binary path/name. |
--prompt <text> |
Minimal prompt sent to Pi. |
--dry-run |
Print resolved command/settings without launching Pi or writing attempt metrics. |
--help / -h |
Print sampler help. |
Supported environment variables:
| Variable | Description |
|---|---|
CODEX_QUOTA_MODEL |
Probe model. Defaults to openai-codex/gpt-5.5; unqualified names are prefixed with openai-codex/. |
CODEX_QUOTA_TEXTFILE_DIR |
Directory for codex_usage.prom and codex_usage_probe.prom; takes precedence over NODE_EXPORTER_TEXTFILE_DIR. |
NODE_EXPORTER_TEXTFILE_DIR |
Fallback textfile directory used when CODEX_QUOTA_TEXTFILE_DIR is unset. |
CODEX_QUOTA_STATE_FILE |
JSON cache read by /status. The bot and sampler must agree on this path. |
CODEX_QUOTA_SAMPLER_CWD |
Isolated sampler project cwd. Defaults to the system temp dir. |
CODEX_QUOTA_TIMEOUT_MS |
Wall-clock timeout for the Pi child. Defaults to 20000. |
CODEX_QUOTA_DRY_RUN |
Boolean-like dry-run switch; prints the resolved command without launching Pi. |
CODEX_QUOTA_PI_BIN |
Pi binary path/name. Defaults to pi. |
CODEX_QUOTA_STALE_MS |
/status stale threshold for cached quota data. Defaults to 1800000 (30 minutes). |
JSON cache shape:
{
"provider": "codex",
"sampledAt": "2026-06-05T12:00:01.000Z",
"lastSuccess": "2026-06-05T12:00:01.000Z",
"lastSuccessTimestamp": 1001,
"lastAttempt": "2026-06-05T12:00:00.000Z",
"lastAttemptTimestamp": 1000,
"probeSuccess": true,
"planType": "Pro",
"activeLimit": "primary",
"windows": {
"5h": { "usedPercent": 12.5, "remainingPercent": 87.5, "resetTimestamp": 2800 },
"week": { "usedPercent": 88, "remainingPercent": 12, "resetTimestamp": 7200 }
}
}Prometheus textfiles:
| Metric | File | Description |
|---|---|---|
codex_usage_5h_percent |
codex_usage.prom |
Last successful 5-hour usage percent. |
codex_usage_weekly_percent |
codex_usage.prom |
Last successful weekly usage percent. |
codex_usage_5h_reset_timestamp |
codex_usage.prom |
Unix reset timestamp for the 5-hour window. |
codex_usage_weekly_reset_timestamp |
codex_usage.prom |
Unix reset timestamp for the weekly window. |
codex_usage_last_success_timestamp |
codex_usage.prom |
Unix timestamp of the last successful quota sample. |
codex_usage_info |
codex_usage.prom |
Low-cardinality provider, plan_type, and active_limit labels. |
codex_usage_last_attempt_timestamp |
codex_usage_probe.prom |
Unix timestamp of the last sampler attempt. |
codex_usage_probe_success |
codex_usage_probe.prom |
1 only when the attempt refreshed the quota cache. |
Run the sampler every 15-30 minutes. Keep CODEX_QUOTA_STALE_MS at least as
large as the scheduled interval plus normal launch jitter; the default 30-minute
threshold fits a 15-minute schedule, while a 30-minute schedule should set a
larger value such as 2700000.
Example private crons.local.yaml entry. Redirect stdout/stderr so the bot's
script cron runner skips success delivery and only notifies the delivery chat on
failure:
crons:
- name: codex-quota-sampler
type: script
schedule: "*/15 * * * *"
command: >
cd /absolute/path/to/minime/bot &&
CODEX_QUOTA_TEXTFILE_DIR=/opt/homebrew/var/node_exporter/textfile
CODEX_QUOTA_STATE_FILE=/absolute/path/to/minime/bot/.tmp/codex-quota-state.json
npx tsx scripts/codex-quota-sampler.ts
>> /absolute/path/to/minime/logs/codex-quota-sampler.log 2>&1
agentId: main
deliveryChatId: 111111111
timeout: 60000Equivalent cron-style invocation:
*/15 * * * * cd /absolute/path/to/minime/bot && CODEX_QUOTA_TEXTFILE_DIR=/opt/homebrew/var/node_exporter/textfile CODEX_QUOTA_STATE_FILE=/absolute/path/to/minime/bot/.tmp/codex-quota-state.json npx tsx scripts/codex-quota-sampler.ts >> /absolute/path/to/minime/logs/codex-quota-sampler.log 2>&1Prometheus alert expression:
codex_usage_5h_percent > 85 OR codex_usage_weekly_percent > 90
Post-merge private rollout: add a script cron or launchd entry for
scripts/codex-quota-sampler.ts, confirm the bot and sampler use the same
CODEX_QUOTA_STATE_FILE, and add the optional monitoring rule above to the
private Prometheus configuration.
Rollback: disable the sampler cron/launchd job first. If the bot was configured
with custom quota env vars, unset CODEX_QUOTA_STATE_FILE,
CODEX_QUOTA_TEXTFILE_DIR, NODE_EXPORTER_TEXTFILE_DIR, and
CODEX_QUOTA_STALE_MS from the bot/sampler environment as applicable, then
restart the bot only if its launchd environment changed. Existing live sessions
remain on transport: auto; disabling quota visibility does not touch active Pi
conversations. Remove or silence the private Prometheus alert separately if it
was enabled.
Two complementary counters track outbound Telegram API traffic. Both increment per-attempt (the inner transformer runs once per autoRetry attempt), so rate(errors) / rate(calls) over the same window yields the attempt-level error ratio. Exception: sendMessageDraft is excluded from autoRetry (see issue #117), so its counters reflect one increment per logical call — attempt-level and call-level rates coincide for that method.
| Metric | Labels | Description |
|---|---|---|
bot_telegram_api_calls_total |
method, binding |
Total Telegram API call attempts (success or failure). The binding label is the originating binding's label (or its agentId when no label is set). Calls without a chat_id payload (e.g. getUpdates, getMe) use the sentinel none; calls whose chat_id does not match any configured binding use unbound. Raw chat_id is never emitted as a label value. |
bot_telegram_api_errors_total |
method, error_code |
Total Telegram API errors. error_code is the numeric Telegram error code (e.g. 429) or http_error for transport-level failures. |
Operationally, none is dominated by the getUpdates poll loop and is expected to be the highest-volume series. A non-zero unbound rate indicates traffic to a chat that no longer matches any configured binding — typically a stale cron target or a removed binding, and worth investigating.
Example PromQL queries:
# 5-minute attempt-level error ratio, per method
sum by (method) (rate(bot_telegram_api_errors_total[5m]))
/
sum by (method) (rate(bot_telegram_api_calls_total[5m]))
# Per-binding call rate — identifies the noisiest binding during a 429 burst
sum by (binding) (rate(bot_telegram_api_calls_total[5m]))
The telegram-api warn logs for Rate limited and HTTP error include chat_id= and (when present) message_thread_id= extracted from the API payload, so a single grep over the bot's stderr log identifies which binding triggered a burst. Methods without a chat_id (getUpdates, getMe, setWebhook, etc.) log without those fields — not with chat_id=undefined. Example:
2026-05-15T16:15:17.160Z WARN [telegram-api] Rate limited: method=sendMessageDraft chat_id=<numeric-chat-id> message_thread_id=7 retry_after=3
2026-05-15T16:15:17.622Z WARN [telegram-api] Rate limited: method=getUpdates retry_after=1
Older versions shipped config.yaml.example which you copied to config.yaml (gitignored). The current version tracks config.yaml directly and uses config.local.yaml for user overrides.
If you have a local config.yaml from the old workflow, git will refuse to pull because the file is now tracked. Migrate before pulling:
# 1. Back up your current config
cp config.yaml config.local.yaml
# 2. Move the untracked file aside so git can check out the new tracked version
mv config.yaml config.yaml.pre-tracked-backup
# 3. Pull — git will restore config.yaml with upstream defaults
git pull
# 4. Edit config.local.yaml — keep only your overrides (workspaceCwd, chatId, secret source pointers, bindings)
# Remove anything that matches the upstream defaults in config.yamlYour config.local.yaml is deep-merged over config.yaml at startup, so you only need to keep what differs from the defaults.
Run pi /login as the launchd user and ensure pi is on the launchd PATH.
For every agent, set an explicit model and replace effort with thinking. Remove provider: claude, fallbackModel, defaultFallbackModel, maxTurns, and allowedTools; those fields now fail validation instead of being treated as runtime controls.
For LLM crons, remove engine: claude; omit engine or set engine: pi. CRON_PI_DISABLED=1 no longer rolls back to Claude. Disable or unload the cron, or convert it to type: script.
Remove Claude OAuth / Claude Code env setup. Pi auth is read from ~/.pi/agent/auth.json, and bot wrappers scrub inherited CLAUDE_CODE_*, ANTHROPIC_*, and CLAUDECODE values.
Minime started as a Telegram/Discord bridge for Claude Code and later moved to Pi/Codex as the single supported runtime. The current architecture keeps the same multi-chat session manager, launchd cron isolation, workspace guardrails, and memory conventions while delegating interactive sessions and LLM crons to Pi.
The official plugin is an MCP server that adds Telegram tools to an already-running Claude Code session.
- Not a standalone bot. Requires an active Claude Code session on your computer. Close the lid and it stops
- No cron or scheduled tasks. No autonomous work while you're away
- Single session. No parallel workspaces, no multi-agent
- Supports group chats but not forum topic routing
- No workspace health management or memory consolidation
It's a remote control for your terminal session, not an autonomous bot.
ccbot runs Claude Code inside tmux and bridges it to Telegram via two channels: JSONL transcript polling for content, and terminal scraping for interactive UI.
What ccbot does better: tool use visibility (which tool was called, what it returned), thinking content as expandable blockquotes, and interactive permission handling — approve or deny tool calls from Telegram via inline keyboard.
The trade-off is fragility. Hardcoded regex patterns match Claude Code's terminal UI text — prompt wordings, spinner characters, chrome separators. Any Claude Code TUI update can silently break detection. Input goes through send_keys() with empirical timing delays. Two polling loops (JSONL at 2s + terminal scrape at 1s per window) add overhead that scales linearly with sessions.
No cron system, no multi-agent, no workspace management, no Discord. Single-user remote control with excellent visibility into what Claude is doing.
Ductor is the closest alternative. Also spawns the CLI binary, also ToS-compliant, also supports forum topics.
| claude-code-bot | ductor | |
|---|---|---|
| Language | TypeScript (grammY) | Python (aiogram) |
| Codebase | ~3k LoC | ~150 modules |
| Forum topic sessions | Yes | Yes |
| Multi-agent with isolated workspaces | Yes | Yes |
| Cron system | launchd plists (per-cron process isolation) | In-process scheduler |
| Crash safety | Atomic JSON writes, launchd auto-restart | Atomic writes, in-flight turn tracking, process registry |
| Workspace health | Structural audits | Agent health with exponential backoff |
| Memory consolidation | Nightly summarization cron | File sync |
| Platforms | Telegram + Discord | Telegram + Matrix |
| Runtime support | Pi/Codex | Claude Code, Codex, Gemini |
Neither project is strictly better than the other — feature sets are comparable. Ductor covers more CLIs and has deeper crash recovery (in-flight turn tracking, process registry, stream coalescing). Minime is narrower: a TypeScript wrapper around Pi/Codex sessions that delegates process isolation to launchd and workspace checks to explicit validation.

