Building a Telegram Butler with the pi SDK
From zero to shipped: building a Telegram bot butler that handles reminders, schedules, and small talk, using the pi SDK for the core conversation and task orchestration — architecture, message flow, state, and a deployment overview.
I recently put together a small side project: a "butler" bot living on Telegram, whose job is to remind me to drink water, keep track of my schedule, and occasionally chat back. The core conversation and task-orchestration logic runs on the pi SDK. This post is a rough map of the architecture and the potholes I hit along the way.
It's not a polished tutorial — more like a route I'm leaving for future-me (or for you, if you're building something similar).
Background and goals
Before writing any code, I wanted a few questions answered:
- What problem is the butler actually solving? — Mostly a "lightweight personal assistant": reminders, logging, simple Q&A.
- Why Telegram? — The Bot API is simple, needs no app-store review, covers both group chats and DMs, and works everywhere.
- Why the pi SDK? — It packages the "understand intent → plan tasks → execute → respond" pipeline fairly cleanly, so I didn't have to build an agent loop from scratch.
The goal was modest: single-user, stable, cheap, easy to deploy.
Breaking the requirements down
I turned the fuzzy idea of "a butler" into a few concrete capabilities:
- Scheduled reminders (drink water, meetings, medication, etc.)
- Simple schedule logging and lookup
- Small talk / fallback Q&A
- Traceable messages (so I can dig through logs when something breaks)
Each item maps to a module further down in the architecture — I didn't try to do everything at once, just these four things solidly.
Overall architecture
The system is split into three layers: ingress, orchestration, and execution.
Telegram Bot API
│
▼
┌─────────────┐
│ Ingress │ webhook receives messages, auth + rate limiting
└─────────────┘
│
▼
┌─────────────┐
│ Orchestration│ pi SDK: understands intent, plans steps
└─────────────┘
│
▼
┌─────────────┐
│ Execution │ reminder jobs / schedule storage / chit-chat model
└─────────────┘The ingress layer stays as thin as possible and only does protocol translation. The real "brain" lives in the orchestration layer; the execution layer is a bunch of concrete tool functions.
Ingress: webhook or long polling
I tried both:
- Webhook: needs a publicly reachable HTTPS endpoint, good for a long-running server deployment, low latency.
- Long polling: no public endpoint needed, works fine running locally, good for development.
I ended up using long polling during development, then switched to webhook (behind a reverse proxy / tunnel) once it was deployed to the Mac mini.
Orchestration: why hand it off to the pi SDK
If I wrote this myself, I'd almost certainly reinvent a bunch of wheels: intent recognition, context management, schema validation for tool calls... The pi SDK wraps all of that up. The core concepts it exposes are roughly:
Agent: a conversational agent capable of calling toolsTool: a function registered to an agent, with typed input/outputSession: keeps context across multi-turn conversations
Integration cost is low — the core code looks roughly like this (pseudocode; check the official docs for the real API):
import { createAgent, defineTool } from "pi-sdk";
const remindMe = defineTool({
name: "remind_me",
description: "Create a scheduled reminder",
parameters: {
content: "string",
remindAt: "string", // ISO timestamp
},
handler: async ({ content, remindAt }) => {
await scheduleReminder(content, remindAt);
return { ok: true };
},
});
const agent = createAgent({
tools: [remindMe /* ...other tools */],
systemPrompt: "You are a concise, reliable personal butler.",
});Message processing flow
A message goes through roughly these steps from arrival to reply:
- Telegram pushes an update (webhook) or it's pulled via polling
- The ingress layer does basic validation: allow-listed user? rate-limited?
- The message text is handed to
agent.run(sessionId, text) - The agent decides whether it needs to call a tool, and gets the result back
- It generates the final reply and sends it back through the Bot API
Context and multi-turn conversations
The most common scenario for a butler looks like this:
Me: remind me to get my vaccine at 8am tomorrow Butler: done, reminder set Me: change it to 9am Butler: updated the reminder to 9am tomorrow
That second message — "change it to 9am" — depends entirely on the previous turn's context (which reminder are we even talking about?). The pi SDK's Session mechanism carries the last few turns of state along automatically, so I didn't have to hand-roll a sliding window to stitch prompts together.
Where to draw tool boundaries
Early on I hit a pothole: I defined a tool too "generically" — a single manage_reminder tool that handled create, update, delete, and query all at once, and the model kept calling it with the wrong combination of arguments. Splitting it into four narrower tools (create_reminder, update_reminder, delete_reminder, list_reminders) noticeably improved accuracy.
Lesson: the narrower and more single-purpose a tool's boundary is, the more accurately the model calls it — even if that means more tools overall.
State and storage
The butler doesn't need to persist much:
- The reminder list (content, time, whether it's fired)
- Simple schedule entries
- Recent conversation context (optional — fine to lose on restart)
A personal project has no business running a database cluster, so I went with the least fussy option: a SQLite file plus a lightweight ORM, with something like node-cron polling for due reminders.
Reminder reliability
The biggest risk running scheduled jobs on a single machine is a process restart silently dropping a reminder. The fix is simple:
- Reminder records are persisted to disk (SQLite), never kept only in memory
- On startup, the process scans for reminders that "should have fired but didn't" and sends them late
- launchd (macOS) or systemd (Linux) keeps the process auto-restarting if it dies
For a personal project, this level of reliability is more than enough — no need for a message queue.
Deployment and operations
The butler ended up deployed on my own Mac mini, running long-term:
- launchd manages the process lifecycle; it auto-restarts on machine reboot
- Logs go to a file so I can dig through them after the fact
- The bot token and the pi SDK's keys live in environment variables, never in the repo
Monitoring and alerting
A personal project doesn't need elaborate monitoring — two things were enough:
- In-process logging: every failed tool call gets a log line
- A "heartbeat" message: the butler sends itself an "I'm still alive" message every morning; if I don't see it for a day, I go check whether it died
Plain, but effective.
Potholes along the way
A few of the more memorable issues:
- Timezones: Telegram messages carry no timezone info, and "tomorrow at 8" defaults to being parsed in local time — I missed this initially, and every reminder ended up off by a few hours.
- Rate limiting: the Telegram Bot API rate-limits outgoing messages; broadcasting or batch reminders need to be queued and sent gradually.
- Tool argument validation: the model occasionally generates arguments that don't fully match the schema (a malformed time string, for instance), so tool handlers need their own fallback validation rather than fully trusting model output.
Wrap-up
The whole project took roughly a weekend from idea to something usable:
- The pi SDK handled the "understand intent + call tools" core pipeline, saving the time of writing an agent loop from scratch
- The architecture stuck to "thin ingress, orchestration delegated to the SDK, execution as plain functions," which made adding features later easy
- The deployment wasn't over-engineered — SQLite plus launchd was plenty
If you're building something similar, I'd suggest starting with just the "reminders" feature, getting the whole pipeline working end to end, and only then layering on schedules and small talk — get the foundation solid before going wide.