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 uniqueness
  • handleAttachments() — 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 NOTHING
  • UpdateSession — increments turn_count, updates tokens
  • ResetSession — DELETE row (on /new command)
  • 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 receiving
  • Send/Edit/Delete — message operations with MessageRef
  • SendMedia — attachments (photo/document/media group)
  • Queued — notify user of queue position
  • NewTurnRenderer — create live status display, returns TurnRenderer + MessageRef
  • FinishTurn — deliver final response or error
  • Callbacks() <-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() -> error

Returns 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:

  1. Cancel any previous turn on same sessionKey (only one active turn per session)
  2. Spawn subprocess with retry (3 attempts, exponential backoff 500ms → 2s)
  3. Parse NDJSON stream from stdout in goroutine
  4. Emit events, track tokens, detect session info
  5. 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 lifecycle
  • OnSessionStart, OnSessionReset — session events
  • OnTurnStart, OnTurnEnd — turn lifecycle
  • OnToolCall — tool invocations
  • OnResponseSent — delivery confirmation

Modifying hooks (sequential by priority, can transform or cancel):

  • OnMessageReceived — rewrite/drop incoming messages
  • BeforePrompt — inject context, modify prompt
  • BeforeResponse — 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:

  1. Register(task) / RegisterTo(task, chatID, threadID) — before Start
  2. Start() — validate cron expressions, init cron engine, check missed runs, start
  3. Stop() — 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 sessions
  • GET /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 parsing
  • github.com/go-telegram/bot — Telegram Bot API
  • github.com/robfig/cron/v3 — cron scheduling
  • modernc.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.go suffix, testify not used (standard testing package)
  • golangci-lint v2 with strict config (see .golangci.yml)