Building a Telegram AI Agent for Personal Use A software engineer built a personal Telegram AI agent that runs on a $5 VPS with zero infrastructure, using Bun, Telegraf, OpenAI SDK, and Composio for integrations. The bot uses polling instead of webhooks to avoid needing a public server, and handles scheduling via Bun Web Workers and SQLite. As a software engineer who spends most of the day in terminals and chat apps, I was heavily fatigued by bloated SaaS setups, complex cloud architectures, and runaway subscription bills. I wanted a personal AI assistant that lived exactly where I already work: Telegram. Not yet another web dashboard, not a Slack bot, not a CLI tool I'd forget to open. Just me, a chat thread, and an AI that can actually do things . I use Telegram for work, for personal chats, for notifications, for everything. It's the one app I never close. So it made sense: instead of building a standalone AI tool that I'd have to remember to visit, I'd build one that shows up in my existing message flow. A bot I can ask to check my email, remind me to call someone at 6 PM, remember that I prefer async communication in the mornings, or summarize my unread messages - all without leaving my chat list. Here is how I built it, the architecture under the hood, the security model, the scheduling pipeline, and the trade-offs I made along the way to keep this entirely zero-infrastructure. The tech choices were driven by one strict constraint: zero infrastructure . No servers to deploy, no Docker containers, no cloud bills. This had to run on a $5 VPS or my laptop. The entire stack had to fit in a single process, use a file-based database, and have no external dependencies aside from the APIs it calls. Runtime: Bun. Fast startup, TypeScript-native, built-in SQLite, and Web Workers for background tasks. It’s a single binary with no node modules at runtime. Bun's bun:sqlite is faster than better-sqlite3 and requires zero native compilation. Bot Framework: Telegraf v4. Mature, polling mode no webhook server needed , and a lean middleware model. It handles getUpdates loops, auto-retries on network errors, and provides a clean ctx object for every message. AI SDK: OpenAI SDK v6. Chat Completions API with streaming. I manage the tool-call loop manually - the model responds with text or function calls, I execute them, feed the results back, and loop up to N steps. No framework wrapper, no magic. Tool Platform: Composio. 200+ pre-built integrations Gmail, Slack, Calendar, GitHub, Jira, Notion . It handles OAuth flows, session management, API retries, and rate limiting. I wrap it in a custom tool that exposes search find tool slugs and execute run a tool by slug . Database: SQLite via bun:sqlite . No ORM, raw queries, WAL mode for concurrent reads. Six tables, zero config, no migrations framework - just a version-tracked array of CREATE TABLE statements that run in order. Scheduling: Bun Web Workers for the polling loop and AI task execution. No cron, no Redis, no BullMQ, no external scheduler. Two workers share the same SQLite file. Most Telegram bots run as HTTP servers behind a webhook. Telegram sends events to your public endpoint, you process them, and you respond. This requires a public URL, an SSL certificate, and a server that is reachable from the broader internet. I chose polling - getUpdates loops. The bot calls Telegram's API every few hundred milliseconds, asks "anything new?", and processes whatever comes back. The trade-off: slightly higher latency the poll interval adds ~200ms , but zero infrastructure requirements. The reason this works for a personal bot: I don't need 99.99% uptime or sub-100ms response times. I need the bot to exist, respond within a few seconds, and cost nothing to run. A polling bot running on a $5 VPS meets all three. The architecture is a single process with three concurrent paths: Plaintext ┌──────────────────────────────────────────────────┐ │ Master Process │ │ ┌──────────────────────┐ ┌──────────────────┐ │ │ │ Telegraf Polling │ │ Scheduler │ │ │ │ main thread │ │ main thread │ │ │ │ on 'text' → loop │ │ spawns workers │ │ │ └──────────┬───────────┘ └────────┬─────────┘ │ │ │ │ │ │ ┌─────▼──────┐ ┌───────▼────────┐ │ │ │ AI Agent │ │ Scheduler Wkr │ │ │ │ loop │ │ 30s poll loop │ │ │ └─────┬──────┘ └───────┬────────┘ │ │ │ │ │ │ │ ┌───────▼────────┐ │ │ │ │ AI Worker │ │ │ │ │ task exec │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ └──────────┬────────────┘ │ │ ▼ │ │ SQLite WAL │ └──────────────────────────────────────────────────┘ The Telegraf polling loop runs in the main thread - user messages trigger the AI agent loop directly in-process. The scheduler also main-thread spawns two Web Workers: a scheduler worker that polls for due jobs every 30s, and an AI worker that executes those tasks. All three paths seamlessly share the same SQLite database via WAL Write-Ahead Logging mode. Configuration is handled by a single Zod schema that parses process.env . Bun auto-loads .env.local , so there's no dotenv dependency. The schema defines every environment variable with validation and defaults: TELEGRAM BOT TOKEN , TELEGRAM ALLOWED USERS - required, validated as non-empty strings. COMPOSIO API KEY , AI API KEY - required. AI BASE URL - optional, defaults to OpenAI's endpoint. This is powerful: I can swap models simply by changing a URL. MODEL - defaults to gpt-4o-mini , which costs pennies per day for personal use. AGENT MAX STEPS - defaults to 10, capping the tool-call loop to prevent runaway execution. The ALLOWED USER IDS is a comma-separated string that gets split into an array at startup. Only these Telegram user IDs can interact with the bot. This is the primary access control - no authentication, no login flow, just a static whitelist. When a whitelisted message arrives, the bot runs through five stages: 1. Session check. Composio sessions are keyed by Telegram user ID. Each session represents an OAuth-connected tool runtime - the user's Gmail, Calendar, Slack, etc. are all wired through Composio. Sessions expire after 10 minutes of inactivity. When a session expires or doesn't exist e.g., a new user , the entire conversation history for that user is dropped to prevent stale contexts. 2. Context assembly. The AI needs three things to respond: conversation history, user memory, and the system prompt. Conversation history comes from SQLite. User memory is a key-value store injected as a User Memory section at the bottom of the system prompt. 3. Tool loading. Custom tools are auto-discovered from src/tools/ and injected as OpenAI function tools: composio - single entry point for all 200+ integrations with search and execute actions. compute - IST time math. The LLM shouldn't have to guess the current time. memory - per-user key-value store with get , set , delete , list . createScheduledJob - manages the custom scheduling system. 4. Agent execution. A manual tool-call loop using OpenAI's streaming Chat Completions. On each step, the model responds with either text stop or function calls. I execute them, append the results as role: 'tool' , and loop. TypeScript js for let step = 1; step <= maxSteps; step++ { const response = await client.chat.completions.create { model: MODEL, messages: apiMessages, tools: customTools.length 0 ? customTools : undefined, stream: true, } ; for await const chunk of response { const choice = chunk.choices?. 0 ; if choice.delta?.content responseContent += choice.delta.content; if choice.delta?.tool calls { / accumulate tool calls / } if choice.finish reason finishReason = choice.finish reason; } if responseToolCalls.length 0 { // execute each tool, push results to messages array, and continue loop } else { // text response complete - break and return to user break; } } I also support text-based tool calls TOOL: tool name {"arg":"val"} parsed directly from the response content for models that struggle with structured function calling. The entire loop is wrapped in a 3-minute timeout via AbortController . If the model hangs or a tool call stalls, the bot sends a timeout message instead of leaving the user waiting indefinitely. 5. Persistence. After the agent loop completes, the user's message and the AI's response messages are appended to the SQLite conversation store. Instead of dumping raw "🔧 Calling toolName..." messages for every backend action, the bot uses a context-aware toolUxMessage function that maps tool names to human-readable status updates: composio search with "gmail" → "🔍 Checking your Gmail..." composio execute with GMAIL → "📬 Fetching your emails..." composio execute with CALENDAR → "📅 Checking your calendar..." memory and compute calls - suppressed entirely to prevent noise. A typing indicator also runs every 4 seconds while the AI processes, keeping the Telegram UI feeling highly responsive. This was one of the trickier design problems. The naive approach - keeping everything - means the token count balloons over time. The alternative - simply deleting the oldest messages - loses context entirely. My approach: compact in place . When the message count exceeds 20, I fold the excess oldest rows into a single summary pair: Take the oldest messages beyond the 20-message limit. Send them to a summarizer LLM call: "summarize this conversation in 150 words" . Replace the first two excess rows with a synthetic pair: user: " Earlier conversation context: