CLAUDE.md — tagopi
Telegram bot that bridges users with Claude Code CLI via streaming subprocess.
Quick Reference
go build ./... # build
go test -race -count=1 ./... # test (race detector on)
go test ./internal/bot/... # test single package
CI: GitHub Actions on PRs to main — build, test (-race), lint (golangci-lint v2).
Linter config: .golangci.yml (v2 format). Enabled: bodyclose, copyloopvar, gocritic, misspell, noctx, prealloc, revive, unconvert, unparam, wsl_v5. Formatters: gofmt, goimports.
Pre-commit: golangci-lint via .pre-commit-config.yaml.
Architecture Overview
cmd/tagopi/main.go ← bootstrap, wiring, graceful shutdown
internal/
config/ ← TOML config loading & validation
bot/ ← core message loop, turn execution, queue, file handling
store/ ← session persistence (SQLite)
files.go ← incoming file attachment management
transport/ ← messaging abstraction
telegram/ ← Telegram Bot API implementation
agent/ ← LLM backend abstraction
claude/ ← Claude Code CLI subprocess wrapper
hook/ ← lifecycle event interception framework
scheduler/ ← cron-based background task runner
store/ ← task execution persistence (SQLite)
gateway/ ← optional HTTP API for state inspection
obs/log/ ← structured logging (slog wrapper)
Request Flow
Telegram API → Transport.Start() → incoming channel
→ Bot.Run() receives message
→ hook: OnMessageReceived (can rewrite/cancel)
→ "/new"? → reset session + hook: OnSessionReset
→ Queue.Enqueue() → worker acquires per-session lock
→ Bot.handleMessage()
→ Store.GetOrCreateSession()
→ hook: OnSessionStart
→ File attachments? → handleAttachments() saves to workDir/incoming
→ File only: send paths, return
→ File + text: append paths to prompt
→ hook: BeforePrompt (can inject context)
→ runTurn()
→ register cancel callback
→ Agent.Send(prompt) → spawns claude subprocess
→ stream events: tool calls, text deltas, thinking
→ StatusMsg renders live progress in Telegram
→ hook: BeforeResponse (can suppress/rewrite)
→ Transport.FinishTurn() → deliver response
→ hook: OnResponseSent, OnTurnEnd
→ Store.UpdateSession() (tokens, turn count)
→ release per-session lock
Package Details
cmd/tagopi — Entry Point
Loads TOML config, opens shared SQLite connection, creates all services, runs message loop. Handles SIGINT/SIGTERM for graceful shutdown with hook dispatch (OnBoot, OnShutdown).
internal/config — Configuration
Root type: Config. Sections:
[transport.telegram]— bot_token, allowed_chat_ids, allowed_user_ids, per-chat work_dir overrides[agent.claudecode]— claude_bin, permission_mode, allowed_tools, max_budget_usd, max_turns, model, mcp_config, system_prompt, work_dir, idle_timeout_sec[bot]— max_workers (global concurrency), max_queue_per_session, incoming_dir (file attachments)[store]— db_path (default: “tagopi.db”)[gateway]— addr (empty = disabled)[scheduler.telegram]— chat_id, thread_id (delivery defaults)
Loaded from TOML via config.Load(path). Validates that at least one transport and agent are configured. See config.toml.example.
internal/bot — Core Bot
Bot orchestrates: Transport, Agent, Store, MessageQueue, CallbackRouter, Hooks.
MessageQueue — two-level concurrency:
- Level 1: global worker pool (N goroutines read from shared channel)
- Level 2: per-session mutexes enforce serial turn execution per chat/thread
Key: sessionKey = "chatID" or "chatID:threadID" for forum topics.
Turn execution (turn.go):
- Loads/creates session, fires hooks at each stage
- Streams agent events to TurnRenderer (transport-provided)
- Registers callback for cancel button → context cancellation → kills agent
- Saves token usage (input/output/context window) to store
CallbackRouter (callback.go): maps "channelID:messageID" → handler function. Used for cancel button during active turns. Handlers are transient (registered on turn start, removed on completion).
Queue (queue.go): Enqueue() returns 1-based position. If position > 1, user sees “Queued (#N)“. Per-session depth tracked to enforce max_queue_per_session. Panic recovery in workers.
File handling (files.go, turn.go): Manages incoming file attachments from Telegram.
saveFile()— streams attachment to workDir/incoming_dir with UnixNano timestamps for uniquenesshandleAttachments()— processes all attachments: saves files, sends paths to user or appends to prompt- Path validation prevents directory traversal attacks
- Flow: file-only messages → save + send paths; file+text → save + append paths + proceed to agent
internal/bot/store — Session Persistence
SQLite table sessions: key (PK), session_id, chat_id, thread_id, created_at, updated_at, turn_count, latest_input_tokens, cumulative_output_tokens, context_window.
GetOrCreateSession— race-safe via INSERT ON CONFLICT DO NOTHINGUpdateSession— increments turn_count, updates tokensResetSession— DELETE row (on/newcommand)ListSessions— ordered by updated_at DESC
Single shared *sql.DB connection (SQLite doesn’t benefit from pooling).
internal/transport — Transport Abstraction
Interface Transport:
Start(ctx) <-chan IncomingMessage— begin receivingSend/Edit/Delete— message operations withMessageRefSendMedia— attachments (photo/document/media group)Queued— notify user of queue positionNewTurnRenderer— create live status display, returnsTurnRenderer+MessageRefFinishTurn— deliver final response or errorCallbacks() <-chan Callback— interactive button events
Key types: IncomingMessage (includes Attachments []Attachment), OutgoingMessage (with Actions for buttons), MessageRef (channelID + messageID), TurnContext (token tracking), TurnOutcome (status + text), TurnRenderer (ToolStarted/ToolDone/Thinking/Compacting), Attachment (Type, Filename, Stream io.ReadCloser, FileID).
internal/transport/telegram — Telegram Implementation
Telegram struct implements Transport. Uses go-telegram/bot library with long polling.
File downloads (convert.go): Opens HTTP streams for Telegram files via Bot API. Uses GetFile to get file path, then opens stream from https://api.telegram.org/file/bot{token}/{path}. Populates IncomingMessage.Attachments with io.ReadCloser streams that must be closed by consumer. Supports both documents (preserves filename) and photos (generates filename from FileID prefix).
Access control: optional allowlists for chat IDs and user IDs.
StatusMsg (status.go): live-updating message showing progress:
- Header:
"working · 42s [step 3] [ctx 67%]" - Lines: tool calls (› started, ✓ done), thinking (⊹), compaction (♻)
- Shows last 5 lines, throttled at 500ms for non-completion events
- Cancel button (InlineKeyboard) removed on finish
- On FinishTurn:
replace()edits status msg with response, auto-splits at 4096 chars
MarkdownV2 (mdv2.go): converts standard Markdown → Telegram MarkdownV2. Handles code blocks, inline code, links, bold, headers. Uses placeholder mechanism to prevent double-escaping. Fallback to plain text on parse failure.
Queued message adoption: when processing starts, NewTurnRenderer converts the “Queued (#N)” message into the status message (avoids message spam).
internal/agent — Agent Abstraction
Interface Agent:
Send(ctx, sessionKey, sessionID, prompt, opts) -> (<-chan Event, <-chan TurnResult, error)
Close() -> errorReturns two channels: streaming events and final result (cost, duration, tokens, session info).
Event types: TextDelta, TextDone, ToolStart, ToolDone, Compacting, Compacted, Error, Done.
internal/agent/claude — Claude Code CLI
Claude struct wraps the Claude Code CLI as a subprocess.
Send() flow:
- Cancel any previous turn on same sessionKey (only one active turn per session)
- Spawn subprocess with retry (3 attempts, exponential backoff 500ms → 2s)
- Parse NDJSON stream from stdout in goroutine
- Emit events, track tokens, detect session info
- On completion: let process exit gracefully (saves session data to disk)
CLI arguments built from config: --session-id, --resume, --output-format stream-json, --verbose, --max-turns, --permission-mode, --allowedTools, --model, --mcp-config, --append-system-prompt, -p <prompt>.
Process management (process.go): spawner interface for testability. Cleanup waits ~10s then SIGKILL. Strips CLAUDECODE env vars to prevent recursion.
Stream parsing (stream.go): reads NDJSON lines (1MB buffer). Handles idle timeout with process-alive check (extends to 10min if process still running, e.g. sub-agent). Tracks:
LatestInputTokens— prompt size of last API call (overwritten each message)CumulativeOutputTokens— sum of all output tokens (reset on context compaction)ContextWindow— model’s context window size
internal/hook — Lifecycle Hooks
Two categories:
Void hooks (fire-and-forget, parallel execution):
OnBoot,OnShutdown— app lifecycleOnSessionStart,OnSessionReset— session eventsOnTurnStart,OnTurnEnd— turn lifecycleOnToolCall— tool invocationsOnResponseSent— delivery confirmation
Modifying hooks (sequential by priority, can transform or cancel):
OnMessageReceived— rewrite/drop incoming messagesBeforePrompt— inject context, modify promptBeforeResponse— post-process or suppress response
Handler interface: embed BaseHandler for no-op defaults, override needed methods. Priority: High(900), Normal(500), Low(100). Higher runs first for modifying hooks.
Runner: Register(handler) sorted by priority. SetServices() injects Transport+Store into hook contexts. Void hooks run in parallel goroutines with panic recovery. Modifying hooks run sequentially; Cancel short-circuits the chain.
internal/scheduler — Background Tasks
Cron-based task runner using robfig/cron/v3.
Task interface: Name(), Schedule() (cron expression), Run(ctx, Env).
Env provides: Agent, Transport, Store, Logger (named per-task), ChatID, ThreadID.
Scheduler lifecycle:
Register(task)/RegisterTo(task, chatID, threadID)— before StartStart()— validate cron expressions, init cron engine, check missed runs, startStop()— stop cron, cancel context, wait up to 30s for in-flight tasks
Missed run detection (missed.go): on startup, compares last execution time with expected schedule. Records status “missed” if a run was skipped.
Persistence (store/): SQLite table task_runs with status tracking (running/ok/error/missed).
internal/gateway — HTTP API
Optional REST API for state inspection.
GET /api/sessions— list all sessionsGET /api/sessions/{key}— get single session
Enabled by setting [gateway] addr in config.
internal/obs/log — Logging
Global slog-based logger with stage/status structured fields.
Stages: Boot, Receive, Session, Send, Stream, Tool, Response, Update.
Convenience: StageStart/End/Error(stage, msg, ...), Debug/Info/Warn/Error(msg, ...).
Init via Init(handler) (once). Falls back to slog.Default().
Key Design Patterns
- Two-level concurrency: global worker pool + per-session locks. Multiple users served concurrently, but each chat/thread processes one turn at a time.
- Streaming events: Agent emits events via channel, Transport renders them in real-time (status message with tool progress, thinking indicators).
- Hook interception: Void hooks for observation, modifying hooks for transformation. Panic-isolated, priority-ordered.
- Transport abstraction: Bot logic is platform-agnostic. Telegram is the only implementation, but the interface supports others.
- Session management: sessionKey (local) maps to sessionID (Claude CLI). Sessions are lazy-created, reset on
/new. - Graceful shutdown: context cancellation propagates through all layers. Agent subprocess gets time to save session before SIGKILL.
- MarkdownV2 with fallback: try formatted message, retry as plain text on Telegram API error.
- Race-safe persistence: INSERT ON CONFLICT for session creation, single SQLite connection.
Dependencies
github.com/BurntSushi/toml— config parsinggithub.com/go-telegram/bot— Telegram Bot APIgithub.com/robfig/cron/v3— cron schedulingmodernc.org/sqlite— pure Go SQLite (no CGO)
Code Style
- Go 1.24, standard library where possible
internal/for all packages (no public API)- Interfaces defined at consumer site (Transport, Agent, Store)
- Error wrapping with
fmt.Errorf("...: %w", err) - Structured logging via
obs/log(slog) - Tests use
_test.gosuffix,testifynot used (standardtestingpackage) - golangci-lint v2 with strict config (see
.golangci.yml)