# Claude Telegram Bot Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Telegram bot that manages Claude Code sessions on a local server — start/stop/switch sessions per project and chat with Claude through Telegram. **Architecture:** Single Bun process with grammy for Telegram, Claude Agent SDK for session management, JSON file for state persistence. Owner-only access via chat_id allowlist. **Tech Stack:** TypeScript, Bun, grammy, @anthropic-ai/claude-agent-sdk --- ## File Structure | File | Responsibility | |------|----------------| | `src/index.ts` | Entry point: load env, init store, create bot, start | | `src/bot.ts` | grammy Bot instance, owner middleware, register handlers | | `src/handlers/commands.ts` | All `/command` handlers with inline keyboard support | | `src/handlers/message.ts` | Plain text → Claude session routing | | `src/services/session-manager.ts` | Session CRUD, switching, project bookmarks, name generation | | `src/services/claude.ts` | Claude Agent SDK wrapper: createSession, sendMessage | | `src/state/store.ts` | Load/save state.json, AppState type definitions | | `src/utils/telegram.ts` | Split long messages, format markdown for Telegram | | `package.json` | Dependencies and scripts | | `tsconfig.json` | TypeScript config for Bun | | `.env` | Tokens and config (not committed) | | `.gitignore` | Ignore node_modules, .env, dist | --- ### Task 1: Project Scaffolding **Files:** - Create: `package.json` - Create: `tsconfig.json` - Create: `.gitignore` - Create: `.env` - Create: `src/index.ts` (placeholder) - [ ] **Step 1: Initialize git repository** ```bash cd /d/claude_telegram_bot git init ``` - [ ] **Step 2: Create package.json** ```json { "name": "claude-telegram-bot", "version": "1.0.0", "type": "module", "scripts": { "start": "bun run src/index.ts", "dev": "bun --watch run src/index.ts" }, "dependencies": { "grammy": "^1.35.0", "@anthropic-ai/claude-agent-sdk": "^0.1.0", "dotenv": "^16.4.0" } } ``` - [ ] **Step 3: Create tsconfig.json** ```json { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src", "types": ["bun-types"] }, "include": ["src"] } ``` - [ ] **Step 4: Create .gitignore** ``` node_modules/ dist/ .env ``` - [ ] **Step 5: Create .env with placeholder values** ``` TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_OWNER_ID=your-telegram-user-id ANTHROPIC_API_KEY=your-anthropic-api-key ``` - [ ] **Step 6: Create placeholder entry point** Create `src/index.ts`: ```typescript console.log("Claude Telegram Bot starting..."); ``` - [ ] **Step 7: Install dependencies** ```bash cd /d/claude_telegram_bot bun install ``` - [ ] **Step 8: Verify it runs** ```bash bun run src/index.ts ``` Expected: prints "Claude Telegram Bot starting..." - [ ] **Step 9: Commit** ```bash git add package.json tsconfig.json .gitignore src/index.ts bun.lock git commit -m "feat: scaffold project with bun, grammy, claude-agent-sdk" ``` --- ### Task 2: State Store **Files:** - Create: `src/state/store.ts` - [ ] **Step 1: Implement Store class** Create `src/state/store.ts`: ```typescript import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname } from "path"; import { homedir } from "os"; import { join } from "path"; export interface SessionInfo { sessionId: string; cwd: string; status: "idle" | "busy"; } export interface AppState { projects: Record; sessions: Record; activeSession: string | null; } const STATE_DIR = join(homedir(), ".claude-telegram-bot"); const STATE_FILE = join(STATE_DIR, "state.json"); const DEFAULT_STATE: AppState = { projects: {}, sessions: {}, activeSession: null, }; export class Store { private state: AppState; constructor() { this.state = this.load(); } private load(): AppState { if (!existsSync(STATE_FILE)) { return { ...DEFAULT_STATE, projects: {}, sessions: {} }; } const raw = readFileSync(STATE_FILE, "utf-8"); return JSON.parse(raw) as AppState; } save(): void { if (!existsSync(STATE_DIR)) { mkdirSync(STATE_DIR, { recursive: true }); } writeFileSync(STATE_FILE, JSON.stringify(this.state, null, 2)); } get data(): AppState { return this.state; } } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/state/store.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/state/store.ts git commit -m "feat: add state store with JSON persistence" ``` --- ### Task 3: Session Manager **Files:** - Create: `src/services/session-manager.ts` - [ ] **Step 1: Implement SessionManager** Create `src/services/session-manager.ts`: ```typescript import { Store, type SessionInfo } from "../state/store.js"; import { basename } from "path"; export class SessionManager { constructor(private store: Store) {} get activeSession(): string | null { return this.store.data.activeSession; } get activeSessionInfo(): SessionInfo | null { const name = this.store.data.activeSession; if (!name) return null; return this.store.data.sessions[name] ?? null; } getAllSessions(): Record { return this.store.data.sessions; } resolveProjectPath(nameOrPath: string): { name: string; path: string } | null { // Check bookmarks first const bookmarked = this.store.data.projects[nameOrPath]; if (bookmarked) { return { name: nameOrPath, path: bookmarked }; } // Treat as path const name = this.generateName(basename(nameOrPath)); return { name, path: nameOrPath }; } private generateName(base: string): string { if (!this.store.data.sessions[base]) return base; let i = 2; while (this.store.data.sessions[`${base}-${i}`]) i++; return `${base}-${i}`; } createSession(name: string, cwd: string, sessionId: string): void { this.store.data.sessions[name] = { sessionId, cwd, status: "idle" }; this.store.data.activeSession = name; this.store.save(); } stopSession(name: string): boolean { if (!this.store.data.sessions[name]) return false; delete this.store.data.sessions[name]; if (this.store.data.activeSession === name) { const remaining = Object.keys(this.store.data.sessions); this.store.data.activeSession = remaining.length > 0 ? remaining[0] : null; } this.store.save(); return true; } stopAll(): number { const count = Object.keys(this.store.data.sessions).length; this.store.data.sessions = {}; this.store.data.activeSession = null; this.store.save(); return count; } switchSession(name: string): boolean { if (!this.store.data.sessions[name]) return false; this.store.data.activeSession = name; this.store.save(); return true; } setStatus(name: string, status: "idle" | "busy"): void { const session = this.store.data.sessions[name]; if (session) { session.status = status; this.store.save(); } } // Project bookmarks saveProject(name: string, path: string): void { this.store.data.projects[name] = path; this.store.save(); } removeProject(name: string): boolean { if (!this.store.data.projects[name]) return false; delete this.store.data.projects[name]; this.store.save(); return true; } getProjects(): Record { return this.store.data.projects; } } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/services/session-manager.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/services/session-manager.ts git commit -m "feat: add session manager with CRUD, switching, bookmarks" ``` --- ### Task 4: Claude Agent SDK Wrapper **Files:** - Create: `src/services/claude.ts` - [ ] **Step 1: Implement ClaudeService** Create `src/services/claude.ts`: ```typescript import { query } from "@anthropic-ai/claude-agent-sdk"; export interface ClaudeResult { sessionId: string; result: string; } export class ClaudeService { async createSession(prompt: string, cwd: string): Promise { let sessionId = ""; let result = ""; for await (const message of query({ prompt, options: { cwd, permissionMode: "bypassPermissions", allowedTools: [ "Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebSearch", "WebFetch", "Agent", ], }, })) { if (message.type === "system" && message.subtype === "init") { sessionId = message.session_id; } if ("result" in message && typeof message.result === "string") { result = message.result; } } return { sessionId, result }; } async sendMessage(prompt: string, sessionId: string): Promise { let result = ""; for await (const message of query({ prompt, options: { resume: sessionId, permissionMode: "bypassPermissions", }, })) { if ("result" in message && typeof message.result === "string") { result = message.result; } } return result; } } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/services/claude.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/services/claude.ts git commit -m "feat: add Claude Agent SDK wrapper with session create/resume" ``` --- ### Task 5: Telegram Utilities **Files:** - Create: `src/utils/telegram.ts` - [ ] **Step 1: Implement message splitting and formatting** Create `src/utils/telegram.ts`: ```typescript const MAX_LENGTH = 4096; export function splitMessage(text: string): string[] { if (text.length <= MAX_LENGTH) return [text]; const chunks: string[] = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= MAX_LENGTH) { chunks.push(remaining); break; } // Try to split at paragraph boundary let splitAt = remaining.lastIndexOf("\n\n", MAX_LENGTH); if (splitAt === -1 || splitAt < MAX_LENGTH / 2) { // Try line boundary splitAt = remaining.lastIndexOf("\n", MAX_LENGTH); } if (splitAt === -1 || splitAt < MAX_LENGTH / 2) { // Hard split splitAt = MAX_LENGTH; } chunks.push(remaining.slice(0, splitAt)); remaining = remaining.slice(splitAt).trimStart(); } return chunks; } export function escapeMarkdown(text: string): string { // grammy supports Markdown parse mode — minimal escaping needed return text; } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/utils/telegram.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/utils/telegram.ts git commit -m "feat: add telegram message splitting utility" ``` --- ### Task 6: Command Handlers **Files:** - Create: `src/handlers/commands.ts` - [ ] **Step 1: Implement all command handlers** Create `src/handlers/commands.ts`: ```typescript import { type Context, InlineKeyboard } from "grammy"; import { SessionManager } from "../services/session-manager.js"; import { ClaudeService } from "../services/claude.js"; export function registerCommands( manager: SessionManager, claude: ClaudeService, onCommand: Record Promise>, ): void { onCommand["start"] = async (ctx) => { const arg = ctx.match as string; if (!arg) { await ctx.reply("Usage: /start "); return; } const resolved = manager.resolveProjectPath(arg); if (!resolved) { await ctx.reply("Could not resolve project path."); return; } // Check if session with this name already exists if (manager.getAllSessions()[resolved.name]) { await ctx.reply( `Session "${resolved.name}" already exists. Use /switch ${resolved.name}`, ); return; } await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`); try { const { sessionId, result } = await claude.createSession( "You are now connected via Telegram. Briefly confirm you're ready and state the project directory.", resolved.path, ); manager.createSession(resolved.name, resolved.path, sessionId); await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`); } catch (err) { await ctx.reply(`Failed to create session: ${err}`); } }; onCommand["stop"] = async (ctx) => { const arg = ctx.match as string; const name = arg || manager.activeSession; if (!name) { await ctx.reply("No active session. Nothing to stop."); return; } if (manager.stopSession(name)) { const active = manager.activeSession; let msg = `Session "${name}" stopped.`; if (active) msg += ` Switched to "${active}".`; await ctx.reply(msg); } else { await ctx.reply(`Session "${name}" not found.`); } }; onCommand["stopall"] = async (ctx) => { const count = manager.stopAll(); await ctx.reply(`Stopped ${count} session(s).`); }; onCommand["switch"] = async (ctx) => { const arg = ctx.match as string; if (!arg) { await ctx.reply("Usage: /switch "); return; } if (manager.switchSession(arg)) { await ctx.reply(`Switched to "${arg}".`); } else { await ctx.reply(`Session "${arg}" not found. Use /sessions to see active sessions.`); } }; onCommand["sessions"] = async (ctx) => { const sessions = manager.getAllSessions(); const names = Object.keys(sessions); if (names.length === 0) { await ctx.reply("No active sessions."); return; } const active = manager.activeSession; const lines = names.map((name) => { const s = sessions[name]; const marker = name === active ? "►" : " "; return `${marker} ${name} — ${s.cwd} (${s.status})`; }); const keyboard = new InlineKeyboard(); for (const name of names) { keyboard.text(name, `switch:${name}`); } await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); }; onCommand["status"] = async (ctx) => { const info = manager.activeSessionInfo; if (!info) { await ctx.reply("No active session."); return; } const name = manager.activeSession!; await ctx.reply( `Session: ${name}\nPath: ${info.cwd}\nStatus: ${info.status}`, ); }; onCommand["save"] = async (ctx) => { const arg = ctx.match as string; const parts = arg.split(/\s+/, 2); if (parts.length < 2) { await ctx.reply("Usage: /save "); return; } manager.saveProject(parts[0], parts[1]); await ctx.reply(`Project "${parts[0]}" saved → ${parts[1]}`); }; onCommand["remove"] = async (ctx) => { const arg = ctx.match as string; if (!arg) { await ctx.reply("Usage: /remove "); return; } if (manager.removeProject(arg)) { await ctx.reply(`Project "${arg}" removed.`); } else { await ctx.reply(`Project "${arg}" not found.`); } }; onCommand["projects"] = async (ctx) => { const projects = manager.getProjects(); const names = Object.keys(projects); if (names.length === 0) { await ctx.reply("No saved projects."); return; } const lines = names.map((n) => `${n} — ${projects[n]}`); const keyboard = new InlineKeyboard(); for (const name of names) { keyboard.text(`Start ${name}`, `start:${name}`); } await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); }; onCommand["help"] = async (ctx) => { await ctx.reply( [ "Session commands:", " /start — start new session", " /stop [name] — stop session", " /stopall — stop all sessions", " /switch — switch active session", " /sessions — list sessions", " /status — current session info", "", "Project bookmarks:", " /save — save bookmark", " /remove — remove bookmark", " /projects — list bookmarks", "", "Any other text is sent to the active session.", ].join("\n"), ); }; } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/handlers/commands.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/handlers/commands.ts git commit -m "feat: add all command handlers with inline keyboards" ``` --- ### Task 7: Message Handler **Files:** - Create: `src/handlers/message.ts` - [ ] **Step 1: Implement text message handler** Create `src/handlers/message.ts`: ```typescript import { type Context } from "grammy"; import { SessionManager } from "../services/session-manager.js"; import { ClaudeService } from "../services/claude.js"; import { splitMessage } from "../utils/telegram.js"; export function createMessageHandler( manager: SessionManager, claude: ClaudeService, ) { return async (ctx: Context) => { const text = ctx.message?.text; if (!text) return; const activeName = manager.activeSession; if (!activeName) { await ctx.reply("No active session. Use /start to create one."); return; } const session = manager.activeSessionInfo; if (!session) return; if (session.status === "busy") { await ctx.reply("Session is busy, wait for the current request to finish."); return; } manager.setStatus(activeName, "busy"); await ctx.replyWithChatAction("typing"); try { const result = await claude.sendMessage(text, session.sessionId); const response = result || "(empty response)"; const chunks = splitMessage(response); for (const chunk of chunks) { await ctx.reply(chunk); } } catch (err) { await ctx.reply(`Error: ${err}`); } finally { manager.setStatus(activeName, "idle"); } }; } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/handlers/message.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/handlers/message.ts git commit -m "feat: add message handler routing text to active Claude session" ``` --- ### Task 8: Bot Setup **Files:** - Create: `src/bot.ts` - [ ] **Step 1: Implement bot with middleware and handler registration** Create `src/bot.ts`: ```typescript import { Bot, type Context } from "grammy"; import { SessionManager } from "./services/session-manager.js"; import { ClaudeService } from "./services/claude.js"; import { registerCommands } from "./handlers/commands.js"; import { createMessageHandler } from "./handlers/message.js"; export function createBot(token: string, ownerId: number): Bot { const bot = new Bot(token); // Owner-only middleware bot.use(async (ctx, next) => { if (ctx.from?.id !== ownerId) return; await next(); }); const store = (await import("./state/store.js")).Store; const storeInstance = new store(); const manager = new SessionManager(storeInstance); const claude = new ClaudeService(); // Register command handlers const commands: Record Promise> = {}; registerCommands(manager, claude, commands); for (const [name, handler] of Object.entries(commands)) { bot.command(name, handler); } // Callback queries for inline buttons bot.on("callback_query:data", async (ctx) => { const data = ctx.callbackQuery.data; if (data.startsWith("switch:")) { const name = data.slice("switch:".length); if (manager.switchSession(name)) { await ctx.answerCallbackQuery({ text: `Switched to "${name}"` }); await ctx.reply(`Switched to "${name}".`); } else { await ctx.answerCallbackQuery({ text: `Session "${name}" not found` }); } } else if (data.startsWith("start:")) { const name = data.slice("start:".length); const resolved = manager.resolveProjectPath(name); if (!resolved) { await ctx.answerCallbackQuery({ text: "Project not found" }); return; } if (manager.getAllSessions()[resolved.name]) { manager.switchSession(resolved.name); await ctx.answerCallbackQuery({ text: `Switched to "${resolved.name}"` }); await ctx.reply(`Session "${resolved.name}" already exists. Switched to it.`); return; } await ctx.answerCallbackQuery({ text: `Starting "${resolved.name}"...` }); await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`); try { const { sessionId, result } = await claude.createSession( "You are now connected via Telegram. Briefly confirm you're ready and state the project directory.", resolved.path, ); manager.createSession(resolved.name, resolved.path, sessionId); await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`); } catch (err) { await ctx.reply(`Failed to create session: ${err}`); } } }); // Plain text messages bot.on("message:text", createMessageHandler(manager, claude)); return bot; } ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/bot.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/bot.ts git commit -m "feat: add bot setup with owner middleware, commands, callbacks" ``` --- ### Task 9: Entry Point **Files:** - Modify: `src/index.ts` - [ ] **Step 1: Implement entry point** Replace `src/index.ts` with: ```typescript import "dotenv/config"; import { createBot } from "./bot.js"; const token = process.env.TELEGRAM_BOT_TOKEN; const ownerId = process.env.TELEGRAM_OWNER_ID; const apiKey = process.env.ANTHROPIC_API_KEY; if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required"); if (!ownerId) throw new Error("TELEGRAM_OWNER_ID is required"); if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required"); const bot = createBot(token, Number(ownerId)); bot.start({ onStart: () => console.log("Bot started successfully"), }); // Graceful shutdown const shutdown = () => { console.log("Shutting down..."); bot.stop(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); ``` - [ ] **Step 2: Verify it compiles** ```bash bun build src/index.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/index.ts git commit -m "feat: add entry point with env validation and graceful shutdown" ``` --- ### Task 10: Fix Bot Initialization (async import) **Files:** - Modify: `src/bot.ts` The `createBot` function uses a top-level `await import()` inside a non-async function. This needs to be restructured so that `Store` is imported statically and bot creation is clean. - [ ] **Step 1: Fix bot.ts to use static imports** Replace the dynamic import in `src/bot.ts`. Change: ```typescript const store = (await import("./state/store.js")).Store; const storeInstance = new store(); ``` to use a static import at the top of the file and make `createBot` synchronous: ```typescript import { Store } from "./state/store.js"; ``` And inside `createBot`: ```typescript const storeInstance = new Store(); ``` - [ ] **Step 2: Verify full project compiles** ```bash bun build src/index.ts --outdir /tmp/check 2>&1 | head -5 ``` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/bot.ts git commit -m "fix: use static import for Store in bot.ts" ``` --- ### Task 11: Integration Test — Manual Smoke Test - [ ] **Step 1: Fill in real .env values** Edit `.env` with actual tokens: ``` TELEGRAM_BOT_TOKEN= TELEGRAM_OWNER_ID= ANTHROPIC_API_KEY= ``` - [ ] **Step 2: Start the bot** ```bash cd /d/claude_telegram_bot bun run start ``` Expected: "Bot started successfully" - [ ] **Step 3: Test in Telegram** Send these messages to the bot in order: 1. `/help` — should show command list 2. `/start /d/claude_telegram_bot` — should create session 3. `What files are in this directory?` — should get Claude response 4. `/status` — should show session info 5. `/sessions` — should show list with inline button 6. `/save mybot /d/claude_telegram_bot` — should save bookmark 7. `/projects` — should show bookmark with "Start" button 8. `/stop` — should stop session 9. Verify `/sessions` shows empty list - [ ] **Step 4: Commit any fixes from smoke test** ```bash git add -A git commit -m "fix: address issues found in smoke test" ``` (Skip this step if no fixes needed.) --- ### Task 12: Push to Git Remote - [ ] **Step 1: Create repository on Gitea** ```bash curl -X POST "http://localhost:3000/api/v1/user/repos" \ -H "Content-Type: application/json" \ -u "admin:gowtep-Wovdek-hehpu5" \ -d '{"name":"claude-telegram-bot","description":"Telegram bot for managing Claude Code sessions","private":false}' ``` - [ ] **Step 2: Add remote and push** ```bash cd /d/claude_telegram_bot git remote add origin http://admin:gowtep-Wovdek-hehpu5@localhost:3000/admin/claude-telegram-bot.git git push -u origin main ``` Expected: successful push - [ ] **Step 3: Verify in browser** Open `http://localhost:3000/admin/claude-telegram-bot` — repository should show all files and commits.