I wanted a personal AI assistant that lived where I already communicate: Telegram. Not another dashboard to remember, not a browser tab that disappears into the pile, and not a demo that can write clever text but cannot actually help me do things.
The result is a Telegram bot that can answer questions, remember useful context, schedule reminders, retrieve information, and use connected services through tightly controlled tools. It is intentionally practical rather than magical. The interesting work was not making a model produce text; it was building the systems around it so that tool use, scheduling, failures, and external side effects behave predictably.
This post explains the architecture, the trade-offs I made, and the safeguards that make a personal assistant useful without turning it into an unattended automation machine.
What I Optimized For
Telegram-first interaction: send a message, receive a useful response, and avoid a separate product surface.
Tool use with boundaries: it can retrieve data and invoke integrations, but it cannot freely perform side effects.
Durable personal state: reminders, notes, job history, and operational records must survive restarts.
Simple operations: one deployable service, observable logs, backups, and understandable failure modes.
Honest scaling limits: start with SQLite and one active bot consumer; change the architecture only when the workload requires it.
I did not optimize for a fully autonomous agent. For a personal assistant, reliability and control are more valuable than letting a model take unlimited actions.
Architecture Overview
At a high level, the system has five layers:
Telegram ingress: Telegraf receives updates and normalizes messages into an application request.
Conversation orchestration: the application loads relevant context, calls OpenAI, and runs a bounded tool-execution loop.
Tool layer: local capabilities such as notes, reminders, weather, and database reads sit behind explicit schemas and policies. Connected third-party services are accessed through Composio.
Durable state: SQLite stores sessions, scheduled jobs, execution attempts, idempotency keys, and operational data.
Background worker: a scheduler claims due jobs, executes them, records the result, and retries safely where appropriate.
Telegram
│
▼
Telegraf handler ──► auth + rate limits ──► assistant orchestrator
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
OpenAI API local tools Composio
│ │ │
└──────────────► SQLite ◄─────────────────┘
▲
│
scheduler worker
The application is deliberately not a collection of unconstrained agents talking to each other. A single orchestrator owns the request lifecycle. That makes it easier to trace what happened, apply policy consistently, and keep failures from becoming confusing.
Why Bun, Telegraf, OpenAI, Composio, and SQLite
I chose Bun because it gives me a fast TypeScript runtime, package management, and a straightforward deployment target. It keeps the service compact without requiring a complicated build pipeline for a small application. Bun is not the reason the assistant is reliable, though; explicit application boundaries and durable state are.
Telegraf is a mature, ergonomic Telegram framework. It handles the Telegram update format well while leaving routing, middleware, and error handling under my control. The bot layer should be boring: validate the sender, acknowledge the message lifecycle, and hand work to the application layer.
OpenAI provides the language model and structured tool-calling interface. Tool definitions help the model select an operation and produce arguments in an expected shape. They do not replace runtime validation. The model can still select an inappropriate tool, provide malformed data, or request an action the current user should not be allowed to take.
Composio is useful for OAuth-backed integrations. Instead of implementing every third-party OAuth flow, token lifecycle, and API wrapper myself, I can use a consistent connection layer for supported external services. That convenience does not eliminate security work: every integration still needs an allowlist, narrow scopes, and separate treatment for read operations versus side effects.
SQLite is the right database while the assistant is a single-user or low-volume system. It is portable, inexpensive to operate, and excellent for transactional local state. I use it for data that must be durable: reminders, job execution records, sessions, and idempotency keys. It is not a distributed queue, and it is not the long-term answer for multiple independently writing application instances.
Polling Instead of Webhooks
I use Telegram long polling rather than webhooks. For a personal deployment, polling avoids exposing a public HTTPS endpoint, certificate management, reverse-proxy setup, and webhook routing. The process asks Telegram for updates, processes them, and advances through the update stream.
The important caveat is that polling needs exactly one active consumer for a bot token. Running two polling instances at once can create conflicts and unpredictable update handling. If I deploy a replacement instance, I make sure the previous consumer is stopped before the new one begins polling.
Offset handling matters too. Telegram updates have monotonically increasing identifiers, and the consumer must advance its offset only after it has safely recorded or processed an update. In practice, I also keep a durable update or message idempotency record. That protects against duplicate handling after a process crash, a network timeout, or a restart near the boundary between receiving and committing an update.
const seen = db.query(
"SELECT 1 FROM processed_updates WHERE update_id = ?"
);
async function handleUpdate(update: TelegramUpdate) {
if (seen.get(update.update_id)) return;
db.run("BEGIN IMMEDIATE");
try {
db.run(
"INSERT INTO processed_updates (update_id, processed_at) VALUES (?, ?)",
[update.update_id, new Date().toISOString()]
);
db.run("COMMIT");
} catch (error) {
db.run("ROLLBACK");
throw error;
}
await processMessage(update);
}
The exact transaction design depends on what is being processed, but the principle is consistent: duplicate delivery is normal in distributed systems, so handlers should be safe to run more than once.
From Message to Tool Call to Reply
When a Telegram message arrives, the bot does not immediately hand raw text to a model and execute whatever comes back. The request follows a controlled pipeline:
Verify that the chat and user are permitted to use the assistant.
Apply per-user and global rate limits.
Load the minimum relevant conversation context and persistent memory.
Send the model a system policy, the user message, and a small allowlisted tool catalog.
Validate every requested tool call against a runtime schema and authorization policy.
Execute approved tools, append structured results, and continue the model loop within a strict step limit.
Persist useful state and send the final answer back through Telegram.
The model loop is intentionally bounded. A tool-capable model can ask for another tool result after receiving the previous one, so a useful assistant needs multiple steps. But it also needs a ceiling to prevent accidental loops, excessive API cost, or an unexpected chain of actions.
const MAX_TOOL_STEPS = 5;
for (let step = 0; step < MAX_TOOL_STEPS; step++) {
const response = await openai.responses.create({
model: MODEL,
input,
tools: allowedToolsFor(user),
});
const calls = extractToolCalls(response);
if (calls.length === 0) {
return extractText(response);
}
for (const call of calls) {
const result = await runApprovedTool({
userId: user.id,
chatId: chat.id,
call,
});
input.push(toolResultMessage(call, result));
}
}
throw new Error("Tool loop exceeded its configured limit");
I also set timeouts at the boundaries: Telegram delivery, model requests, database operations, and external integrations. One slow provider should not hold a message handler forever.
A Safe Tool Policy
Tool calling is where an assistant becomes useful and where it can become unsafe. My policy is based on capability rather than prompt wording.
First, tools are allowlisted. The model sees only tools appropriate for the current user and context. A read-only stock quote tool, for example, is fundamentally different from a tool that sends an email or creates a calendar event. I do not expose administrative or infrastructure operations to a general chat flow just because the model could describe them.
Second, every tool has runtime argument validation. TypeScript types are useful during development, but they disappear at runtime. Tool arguments from a model or an external API are untrusted input. I validate them with a schema library or explicit checks before calling application code.
const createReminderSchema = z.object({
task: z.string().min(1).max(500),
time: z.string().regex(/^\d{2}:\d{2}$/),
scheduleType: z.enum(["once", "daily", "weekdays", "weekly"]),
dayOfWeek: z.string().optional(),
});
function validateReminder(args: unknown) {
return createReminderSchema.parse(args);
}
Third, external side effects require explicit confirmation. If the assistant is about to send a message, create an event, modify a document, or perform another consequential action, it prepares a preview and asks the user to confirm. A confirmation is tied to the intended action, expires quickly, and is consumed once. The system should not interpret “yes” from an unrelated later conversation as approval to send something.
Fourth, side-effecting operations receive an idempotency key. A network failure after a provider accepts a request is ambiguous: retrying without a key can create duplicate events, emails, or tasks. Where a provider supports idempotency, I pass a stable key. Where it does not, I persist an operation record and use provider-specific lookup or reconciliation where possible.
Finally, OAuth connections are scoped and isolated. I request the narrowest permissions needed, avoid broad account access by default, store connection references rather than casually exposing raw tokens, and make disconnecting an integration straightforward. Secrets belong in the deployment environment or a secret manager, never in source control, logs, prompts, or tool output.
SQLite, WAL, and Scheduling Correctness
I run SQLite in write-ahead logging mode:
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
WAL improves concurrency for this workload because readers can continue while a writer is committing. It does not turn SQLite into a multi-writer database. There is still one writer at a time, so transactions should be short, indexes should support the scheduler’s queries, and write-heavy background work should not be mixed carelessly with long interactive transactions.
The scheduler stores jobs in SQLite rather than trusting in-memory timers. A worker periodically finds due jobs, atomically claims one, runs it, and records the outcome. The claim prevents two worker loops from executing the same job simultaneously in the same database.
UPDATE scheduled_jobs
SET status = 'running',
locked_at = :now,
lock_token = :token
WHERE id = (
SELECT id
FROM scheduled_jobs
WHERE status = 'pending'
AND run_at <= :now
ORDER BY run_at
LIMIT 1
)
AND status = 'pending';
A worker is not a durable queue merely because it runs in a loop. Durability comes from the database records: pending jobs, attempts, lock timestamps, completion state, and idempotency keys. If the process dies after claiming a job, recovery logic detects stale locks and returns eligible work to the pending state. If the process dies after an external side effect but before recording completion, idempotency and reconciliation logic determine whether it is safe to retry.
For recurring jobs, I calculate the next run after a successful execution and store times consistently, typically in UTC with the user’s timezone retained for display and recurrence rules. Timezones and daylight-saving transitions deserve dedicated tests; “every day at 9” is more complicated than adding 24 hours.
Error Handling, Rate Limits, and Operations
Every integration can fail. Telegram can time out, an OAuth token can be revoked, OpenAI can rate-limit a request, and a third-party API can return malformed data. The assistant should explain failures plainly without leaking secrets or internal stack traces.
I categorize errors into retryable and non-retryable classes. Network timeouts, temporary 429 responses, and many 5xx errors can be retried with exponential backoff and jitter. Invalid arguments, revoked permissions, and user-denied confirmations should not be blindly retried. Retries have caps, deadlines, and structured logs so a bad provider does not create an infinite background loop.
Rate limits exist at multiple layers: Telegram message handling, model calls, tool calls, and external APIs. For interactive chat, a per-user token bucket or short rolling window is usually sufficient. I also limit tool-loop depth, tool-call count, payload size, and concurrent outbound requests. These controls protect both cost and availability.
Deployment is intentionally simple: one application instance, persistent storage mounted outside ephemeral container layers, environment-based configuration, and a process supervisor or platform health checks. Before each deploy, I run migrations in a controlled step and ensure the prior polling consumer has stopped. Health checks verify that the process is alive; readiness checks should also verify that configuration and the database are usable.
Backups are not optional because SQLite is the system of record. I take regular backups from a consistent SQLite snapshot, retain multiple recovery points, encrypt backups where appropriate, and periodically test restoration. A backup that has never been restored is only a theory.
Tests, Observability, and Scaling Boundaries
The highest-value tests are not model snapshot tests. They cover authorization decisions, runtime validation, confirmation expiry, idempotency, scheduler claims, stale-lock recovery, timezone behavior, and duplicate Telegram updates. I use mocked provider clients for deterministic unit tests, then run a small number of integration tests against isolated credentials or test resources.
For observability, each incoming Telegram update receives a correlation id. Logs include the update id, user or chat identifier where safe, request duration, model request id when available, tool name, retry count, and job id. I record metrics for error rates, latency, tool failures, queue age, worker recovery, and rate-limit rejections. I log metadata, not secrets or private message content by default.
This architecture has clear scaling boundaries. SQLite with one active polling process is excellent for a personal assistant and modest traffic. It becomes a constraint when multiple application instances need concurrent writes, background work grows significantly, or webhook-based horizontal ingress becomes necessary. At that point, I would move durable state to a server database such as Postgres and use a real queue for independently scalable workers. I would not pretend that adding more containers around one SQLite file solves distributed coordination.
Build Checklist
Create a Telegram bot and restrict initial access to known user or chat ids.
Start with Telegraf long polling and ensure only one consumer runs at a time.
Build a small OpenAI orchestration loop with a maximum number of tool steps.
Expose only an explicit, per-user allowlist of tools.
Validate every tool argument at runtime before execution.
Split read-only tools from side-effecting tools; require preview and confirmation for the latter.
Use scoped OAuth connections and keep credentials out of code, prompts, and logs.
Persist jobs, attempts, locks, and idempotency keys in SQLite.
Enable WAL, keep write transactions short, and plan around SQLite’s single-writer model.
Implement stale-lock recovery and bounded retry behavior for workers.
Add structured logs, metrics, alerts, and restoration-tested backups.
Test duplicates, retries, revocations, crashes, and timezones before relying on automation.
Repository and Implementation Notes
The implementation evolves, but the core idea remains stable: keep the chat interface simple and put the engineering effort into policy, persistence, and recoverability.
View the project source on GitHub.
Conclusion
A useful personal AI assistant is less about giving a model unlimited access and more about designing reliable boundaries around it. Telegram provides the interface, OpenAI provides reasoning and language, Composio can provide controlled access to connected services, and SQLite provides a durable foundation for a small deployment.
The hard parts are familiar engineering problems: authorization, input validation, duplicate delivery, idempotency, retries, recovery, backups, and observability. Solving those deliberately turns an impressive chatbot demo into an assistant I can trust to use every day.
Top comments (2)
"Build it where you already live instead of a tool you'll forget to open" is the part people underrate — the best interface is usually the one that costs zero context-switch, and for you that's Telegram. Bun with bun:sqlite in a single process is a great fit for that constraint too. The one thing I'd be curious about is the security model: once the bot can read your email and act on your behalf, the Telegram chat effectively becomes a privileged session. How are you scoping the tokens and guarding against a misfired command doing something you can't undo on a $5 VPS with no staging?
This is the kind of AI assistant I actually want to use. Keeping it in Telegram and avoiding a huge stack makes a lot of sense. I’ve built small agent tools too, and simple setups usually win.