Files
claude-telegram-bot/docs/superpowers/plans/2026-04-10-telegram-bot-plan.md

25 KiB

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

cd /d/claude_telegram_bot
git init
  • Step 2: Create package.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
{
  "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:

console.log("Claude Telegram Bot starting...");
  • Step 7: Install dependencies
cd /d/claude_telegram_bot
bun install
  • Step 8: Verify it runs
bun run src/index.ts

Expected: prints "Claude Telegram Bot starting..."

  • Step 9: Commit
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:

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<string, string>;
  sessions: Record<string, SessionInfo>;
  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
bun build src/state/store.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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<string, SessionInfo> {
    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<string, string> {
    return this.store.data.projects;
  }
}
  • Step 2: Verify it compiles
bun build src/services/session-manager.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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<ClaudeResult> {
    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<string> {
    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
bun build src/services/claude.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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
bun build src/utils/telegram.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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<string, (ctx: Context) => Promise<void>>,
): void {
  onCommand["start"] = async (ctx) => {
    const arg = ctx.match as string;
    if (!arg) {
      await ctx.reply("Usage: /start <path or project name>");
      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 <session name>");
      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 <name> <path>");
      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 <name>");
      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 <path|name> — start new session",
        "  /stop [name] — stop session",
        "  /stopall — stop all sessions",
        "  /switch <name> — switch active session",
        "  /sessions — list sessions",
        "  /status — current session info",
        "",
        "Project bookmarks:",
        "  /save <name> <path> — save bookmark",
        "  /remove <name> — remove bookmark",
        "  /projects — list bookmarks",
        "",
        "Any other text is sent to the active session.",
      ].join("\n"),
    );
  };
}
  • Step 2: Verify it compiles
bun build src/handlers/commands.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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 <path or name> 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
bun build src/handlers/message.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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<string, (ctx: Context) => Promise<void>> = {};
  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
bun build src/bot.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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
bun build src/index.ts --outdir /tmp/check --no-bundle 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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:

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:

import { Store } from "./state/store.js";

And inside createBot:

const storeInstance = new Store();
  • Step 2: Verify full project compiles
bun build src/index.ts --outdir /tmp/check 2>&1 | head -5

Expected: no errors

  • Step 3: Commit
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=<real token from BotFather>
TELEGRAM_OWNER_ID=<your Telegram user ID>
ANTHROPIC_API_KEY=<your Anthropic API key>
  • Step 2: Start the bot
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
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
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
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.