By the end of this manual you will have a private AI agent living on a server you control, that you message from Telegram on your phone, that builds apps and websites on command, and that keeps its own constantly-updated memory in markdown docs — for €0–4/month.
* The agent talks outward to Telegram. Nothing needs to reach your server from the internet — which is why even an old laptop in a closet works.
projects/ministack-site/index.html — hero, 6-piece gallery grid, contact strip. Deployed to your preview URL. Logged it in projects.md and added a line to the changelog.14:04Every "AI agent" — including the fancy commercial ones — is the same four ingredients. You are about to assemble your own from parts you control:
| Ingredient | What it does | Your version | Cost |
|---|---|---|---|
| A brain | The LLM that thinks and writes code | Free API (Groq / Google) — swappable | €0 |
| A body | A computer that runs 24/7 with a real shell | Free Oracle VM, €4 Hetzner VPS, or an old laptop | €0–4/mo |
| Hands | Tools: run commands, read/write files | ~200 lines of Python you will paste, not write | €0 |
| Memory | Persistent knowledge across conversations | Markdown docs the agent must update itself | €0 |
The interface is Telegram, because a Telegram bot takes five minutes, costs nothing, needs no company verification, and — the underrated part — works by polling: your server calls Telegram, never the other way around. No open ports, no domain, no fixed IP needed. WhatsApp is the opposite of all of that; it gets an honest section in Part 10, and you can bolt it on later without changing anything else.
After the important steps you'll find a yellow-striped checkpoint box telling you exactly what you should see if it worked. Never continue past a failed checkpoint. Fix it first (the troubleshooting section covers the classics). This is how you build server things without server experience: verify every rung of the ladder.
You understand the four ingredients and you know the rule. That's the whole theory portion. Everything from here is copy, paste, verify.
Your agent doesn't run the AI model itself — it sends each message to an API and gets thinking back. Several providers give this away free (rate-limited, but plenty for one person). The agent you'll install speaks the standard OpenAI-compatible dialect, so switching providers later means editing two lines in a config file. Start with Groq: no credit card, instant signup, very fast, and its models handle tool-calling (the thing that lets the agent actually do stuff) reliably.
Go to console.groq.com → sign up with Google or email. No card, no verification theatre.
In the console: API Keys → Create API Key → name it agent-hq → copy the key that starts with gsk_. It's shown once — paste it somewhere safe for the next 30 minutes (you'll put it on the server in Part 5).
On any machine with a terminal (your laptop is fine — Mac/Linux terminal, or Windows PowerShell with curl):
curl https://api.groq.com/openai/v1/chat/completions \
-H "Authorization: Bearer PASTE_YOUR_gsk_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b-versatile","messages":[{"role":"user","content":"Say: brain online"}]}'You get back a blob of JSON containing "brain online". If you get 401 instead, the key was pasted wrong (a stray space at the end is the usual culprit).
All of these plug into the exact same two config lines. Free tiers change, so treat the limits column as "roughly, as of mid-2026 — check the provider's console for today's numbers."
| Provider | Free tier feel | Config values you'd use | Notes |
|---|---|---|---|
| Groq start here | ~30 req/min, ~1,000 req/day per model — fine for one human | https://api.groq.com/openai/v1llama-3.3-70b-versatile | Fastest responses you'll ever see. Also free Whisper for voice (Part 10). |
| Google AI Studio | Bigger daily volume than Groq on Flash models | https://generativelanguage.googleapis.com/v1beta/openai/gemini-2.5-flash | Key from aistudio.google.com. Great fallback when Groq rate-limits you. Check current model names in the studio. |
| OpenRouter | Rotating cast of :free models | https://openrouter.ai/api/v1model varies | One key, many models. Careful: many :free models don't support tool-calling — your agent will go limp. Filter for "tools" support. |
| DeepSeek | Not free — but pennies | https://api.deepseek.comdeepseek-chat | When you outgrow free tiers: strong model, a heavy month costs about a coffee. |
| Ollama (local) | Truly free & private, forever | http://localhost:11434/v1qwen2.5-coder:14b | Runs the model on your own box. Needs ~12GB+ RAM; slower; weaker at tool-calling. The "off-grid" option — see Part 11. |
Free tiers are rate-limited, not metered — you can't wake up to a bill, the agent just gets a "429 slow down" and tells you. For a single-person agent that's a non-issue 95% of days; Part 11 shows the 60-second fix for the other 5%.
You need one always-on Linux box. It barely works up a sweat — the heavy thinking happens at the LLM provider — so the smallest cheapest thing is enough. Three honest options:
2 vCPU, 4GB RAM, in Germany or Finland (close to Lisbon, latency-wise irrelevant anyway). Signup takes minutes, capacity always available, nothing weird ever happens.
Genuinely free VM — currently up to 2 ARM cores / 12GB RAM (Oracle quietly halved it from 4/24 in June 2026). Catches: signup rejects some cards for sport, popular regions show "out of capacity", and free tiers can be nerfed again without email.
Because the bot polls outward, a laptop behind your home router works with zero network config. Install Ubuntu Server, close the lid (disable lid-sleep), done. Downside: your uptime = your electricity and toddler-proximity.
65.108.x.x. Write it down — that IP is "your server" for the rest of this manual.From your laptop's terminal (Mac/Linux terminal, or Windows: PowerShell — ssh is built in):
# Hetzner (password emailed to you):
ssh root@YOUR_SERVER_IP
# Oracle (with the key file you downloaded):
ssh -i ~/Downloads/your-key-file.key ubuntu@YOUR_SERVER_IP
# if it complains about permissions first run: chmod 600 ~/Downloads/your-key-file.keyType yes when asked about the fingerprint (first connection only).
Your prompt now looks like root@server-name:~# or ubuntu@instance:~$. You are inside your server. Everything from here on is typed there, unless a code block's caption says "your laptop".
Four chores: update the system, create a dedicated agent user (so the bot never runs as root), lock the firewall, install Python + Git. Type y/Enter whenever it asks.
sudo apt update && sudo apt upgrade -yOn Hetzner you're logged in as root, so sudo is redundant but harmless — keep the commands identical either way. If it mentions a reboot is required: sudo reboot, wait 60 seconds, ssh back in.
sudo adduser --gecos "" agent # invent a password, remember it
sudo usermod -aG sudo agent
sudo su - agent # prompt now says agent@…sudo ufw allow OpenSSH
sudo ufw enable # answer y — your current session survives
sudo ufw statusThis is safe precisely because of the polling trick from Part 0: the agent never needs an inbound port. (We'll open 80/443 in Part 8 only if you choose to host websites on this box.)
sudo apt install -y python3-venv python3-pip git curl nano
# 2GB swap so a small VPS never chokes during installs (skip if you have 8GB+ RAM)
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabpython3 --version prints 3.10 or newer, and free -h shows a Swap: line with 2.0Gi. Your prompt starts with agent@.
Telegram bots are created by talking to a bot. On your phone:
bot, e.g. jelena_hq_bot.8123456789:AAH8s…. Copy it. This token is the keys to your agent — anyone holding it can impersonate the bot. Never paste it anywhere but your server's config file.You have a token containing a colon, and your new bot sits silently in your chat list. Silent is correct — nothing is listening yet.
Three small files. You'll create each with nano, the beginner-proof editor: run the nano filename command, paste the block (right-click or Cmd/Ctrl+V in most terminals), then save with Ctrl+O Enter and exit with Ctrl+X. That's the entire nano skill tree.
mkdir -p ~/agent && cd ~/agent
python3 -m venv venv
source venv/bin/activate # prompt gains a (venv) prefixpython-telegram-bot>=21,<23
openai>=1.40
python-dotenv>=1.0pip install -r requirements.txtTELEGRAM_TOKEN=paste_the_BotFather_token
ALLOWED_USER_ID=0
LLM_API_KEY=paste_your_gsk_key_from_Part_1
LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_MODEL=llama-3.3-70b-versatilechmod 600 .envALLOWED_USER_ID=0 is temporary — the bot will tell you your real ID in a minute. Those last two lines are the "swap the brain" lines from Part 1.
Paste it all. A map of what you're pasting follows below — read it once so this never feels like a magic spell.
#!/usr/bin/env python3
"""Personal AI agent: Telegram in, tools + memory in the middle, answers out."""
import asyncio
import datetime
import json
import os
import subprocess
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import (Application, CommandHandler, ContextTypes,
MessageHandler, filters)
load_dotenv()
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
ALLOWED_USER_ID = int(os.getenv("ALLOWED_USER_ID", "0"))
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.groq.com/openai/v1")
LLM_API_KEY = os.environ["LLM_API_KEY"]
LLM_MODEL = os.getenv("LLM_MODEL", "llama-3.3-70b-versatile")
ROOT = Path(__file__).resolve().parent # ~/agent
MEMORY = ROOT / "memory" # the brain (Part 6)
PROJECTS = ROOT / "projects" # everything it builds
MEMORY.mkdir(exist_ok=True)
PROJECTS.mkdir(exist_ok=True)
llm = OpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY)
# ---------------- memory: loaded fresh into every single message ----------
def load_memory() -> str:
parts = []
for f in sorted(MEMORY.glob("*.md")):
parts.append(f"--- {f.name} ---\n{f.read_text(encoding='utf-8')}")
return "\n\n".join(parts) or "(memory is empty - first conversation)"
def system_prompt() -> str:
return (
"You are a personal AI agent running on your boss's own server. "
"You are capable, direct and honest.\n"
f"Today is {datetime.date.today():%A %d %B %Y}.\n"
f"Projects live in {PROJECTS}. Memory docs live in {MEMORY}.\n\n"
"TOOLS: run shell commands, read/write files, save memory docs.\n"
"RULES:\n"
"1. Be concise. Do the work, then report what you did.\n"
"2. Build complete, working things - never placeholders or TODOs.\n"
"3. Each new app or site gets its own folder inside projects/.\n"
"4. MEMORY DISCIPLINE: after any task that changes a project, or teaches "
"you something about your boss or this server, update the right memory doc "
"(projects.md / profile.md / decisions.md) with save_memory, and append one "
"dated line to changelog.md using save_memory with append=true.\n"
"5. Before any destructive command (delete, overwrite, kill), state the "
"exact command and ask for a yes first.\n\n"
"YOUR MEMORY (docs from memory/):\n" + load_memory()
)
# ---------------- the agent's hands -----------------------------------------
def run_command(command: str, cwd: str = "") -> str:
"""Run a shell command; default working dir is projects/."""
try:
r = subprocess.run(command, shell=True, capture_output=True, text=True,
timeout=600, cwd=cwd or str(PROJECTS))
out = (r.stdout + r.stderr).strip()
return out[-8000:] if out else f"(no output, exit code {r.returncode})"
except subprocess.TimeoutExpired:
return "ERROR: command timed out after 10 minutes"
def _safe(path: str) -> Path:
"""File tools stay inside ~/agent so a typo can't eat the system."""
p = Path(path)
p = p.resolve() if p.is_absolute() else (ROOT / p).resolve()
if not str(p).startswith(str(ROOT)):
raise ValueError(f"{p} is outside {ROOT}; use run_command for system files")
return p
def read_file(path: str) -> str:
return _safe(path).read_text(encoding="utf-8")[:16000]
def write_file(path: str, content: str) -> str:
p = _safe(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} chars to {p}"
def save_memory(filename: str, content: str, append: bool = False) -> str:
if not filename.endswith(".md"):
filename += ".md"
p = MEMORY / Path(filename).name
if append and p.exists():
p.write_text(p.read_text(encoding="utf-8").rstrip() + "\n" + content + "\n",
encoding="utf-8")
else:
p.write_text(content + "\n", encoding="utf-8")
return f"Memory saved: {p.name}"
TOOL_IMPL = {"run_command": run_command, "read_file": read_file,
"write_file": write_file, "save_memory": save_memory}
TOOLS = [
{"type": "function", "function": {
"name": "run_command",
"description": "Run a bash command on the server. Default cwd is the projects folder.",
"parameters": {"type": "object", "properties": {
"command": {"type": "string"},
"cwd": {"type": "string", "description": "optional working directory"}},
"required": ["command"]}}},
{"type": "function", "function": {
"name": "read_file",
"description": "Read a text file. Relative paths resolve inside the agent folder.",
"parameters": {"type": "object", "properties": {
"path": {"type": "string"}}, "required": ["path"]}}},
{"type": "function", "function": {
"name": "write_file",
"description": "Create or overwrite a text file; parent folders auto-created.",
"parameters": {"type": "object", "properties": {
"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"]}}},
{"type": "function", "function": {
"name": "save_memory",
"description": "Save a doc in memory/, e.g. projects.md. append=true adds to the end.",
"parameters": {"type": "object", "properties": {
"filename": {"type": "string"}, "content": {"type": "string"},
"append": {"type": "boolean"}}, "required": ["filename", "content"]}}},
]
# ---------------- the loop: think, act, repeat, answer ----------------------
MAX_STEPS = 20
def run_agent(history: list) -> str:
messages = [{"role": "system", "content": system_prompt()}] + history
for _ in range(MAX_STEPS):
resp = llm.chat.completions.create(
model=LLM_MODEL, messages=messages,
tools=TOOLS, tool_choice="auto", temperature=0.4)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content or "(done)"
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
for tc in msg.tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
result = TOOL_IMPL[tc.function.name](**args)
except Exception as e:
result = f"ERROR: {e}"
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": str(result)[:8000]})
return "Hit my 20-step limit for one message. Say 'continue' and I'll keep going."
# ---------------- telegram plumbing -----------------------------------------
HISTORY: dict[int, list] = {}
HISTORY_LIMIT = 30
def chunks(text: str, n: int = 3800) -> list:
return [text[i:i + n] for i in range(0, len(text), n)] or ["(empty)"]
def authorized(update: Update) -> bool:
return ALLOWED_USER_ID != 0 and update.effective_user.id == ALLOWED_USER_ID
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
uid = update.effective_user.id
if authorized(update):
await update.message.reply_text("Agent online. Give me a job.")
else:
await update.message.reply_text(
f"Your Telegram ID is {uid}.\n"
"Put it in .env as ALLOWED_USER_ID, restart me, and I'm yours.")
async def cmd_reset(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not authorized(update):
return
HISTORY.pop(update.effective_chat.id, None)
await update.message.reply_text("Fresh conversation. (Memory docs untouched.)")
async def cmd_memory(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not authorized(update):
return
for c in chunks(load_memory()):
await update.message.reply_text(c)
async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not authorized(update):
await update.message.reply_text("Not authorized. Send /start to see your ID.")
return
chat_id = update.effective_chat.id
hist = HISTORY.setdefault(chat_id, [])
hist.append({"role": "user", "content": update.message.text})
await context.bot.send_chat_action(chat_id, ChatAction.TYPING)
try:
answer = await asyncio.to_thread(run_agent, hist[-HISTORY_LIMIT:])
except Exception as e:
answer = f"Something broke on my side: {e}"
hist.append({"role": "assistant", "content": answer})
del hist[:-HISTORY_LIMIT]
for c in chunks(answer):
await update.message.reply_text(c)
def main():
app = Application.builder().token(TELEGRAM_TOKEN).build()
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("reset", cmd_reset))
app.add_handler(CommandHandler("memory", cmd_memory))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
print("Agent is polling Telegram...")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()memory/*.md docs are injected into the agent's head, and Rule 4 orders it to keep them updated. That's the entire "memory in living documentation" trick — Part 6 seeds it.~/agent; the shell is not (it's your server, your call — Rule 5 makes it ask before destructive moves).python agent.pynano .env and set ALLOWED_USER_ID= to that number. Save, exit.python agent.py again → /start on your phone → "Agent online. Give me a job."Send it this from your phone:
"Create projects/hello/notes.md with three bullet points describing what you can do for me, then read the file back to me."
The bot replies with the three bullets, and on the server ls ~/agent/projects/hello/ shows notes.md. An AI just did real work on your machine because you texted it. Take the moment.
This agent runs shell commands as the agent user because that's precisely what makes it useful. Three locks are already on: only your Telegram ID is accepted, file tools can't leave ~/agent, and Rule 5 makes it confirm destructive commands. Keep it honest: don't store anything irreplaceable on this box without a backup, and never paste other people's prompts straight into it.
LLMs have amnesia: every API call starts from zero. The fix is almost insultingly simple — and it's exactly what you asked for: the agent's memory IS documentation. Plain markdown files in memory/, injected into its head on every message, which Rule 4 in its system prompt forces it to keep updated after every meaningful task. You can read the brain, edit the brain in nano, and version the brain in git. No vector database, no black box.
Paste this whole block in one go on the server (it writes four files). Then personalise profile.md in nano — the more honest it is about how you like to work, the better the agent behaves.
cd ~/agent
cat > memory/profile.md <<'EOF'
# Profile - the boss
- Name: Jelena. Based in Lisbon (Europe/Lisbon time).
- Style: terse imperatives. Wants complete, polished deliverables -
no clarifying-question ping-pong unless genuinely blocked.
- Fullstack dev: talk shop, skip beginner explanations.
- Prefers self-contained single-file HTML deliverables where sensible.
EOF
cat > memory/projects.md <<'EOF'
# Projects - live status board
| Project | Status | Path | Notes |
|---|---|---|---|
| (none yet) | - | - | agent: add a row when a project starts, update on change |
EOF
cat > memory/decisions.md <<'EOF'
# Decisions - technical choices and WHY
- 2026-07-02: Memory lives in markdown docs, updated by the agent after
every meaningful task. Docs stay short; changelog.md holds the history.
EOF
cat > memory/changelog.md <<'EOF'
# Changelog - one dated line per action, append-only
- 2026-07-02: Agent HQ came online.
EOF
ls memory/From your phone: send /memory — the bot recites all four docs. Then test that it actually uses them: ask "What do you know about how I like to work?" and it should answer from profile.md without being told.
git config --global user.name "Jelena"
git config --global user.email "agent@localhost"
cd ~/agent
printf 'venv/\n.env\n__pycache__/\n' > .gitignore
git init && git add . && git commit -m "day zero: agent + memory"Now "what did the agent believe last Tuesday" is a git log away — and you can even text it "commit your memory with a sensible message", since git is just another shell command to it. Note .env is git-ignored: secrets never enter history.
/memory shows four docs, the profile question gets answered from memory, and git log shows one commit.
Auditable (it's markdown), portable (copy the folder = clone the brain), editable (you're a stakeholder in your agent's beliefs), and versioned (git). Grown-up agent frameworks are converging on exactly this pattern — docs as memory — you're just doing it without the wrapper.
Your agent already has hands. This part gives it a toolbox (Node), a delivery habit, and — for heavier jobs — a specialist coding sub-agent it can hire. Think of it as staffing: the Telegram agent is your producer; Aider is the contractor it calls in.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version"New project: ministack-site. Build a complete single-file landing page at projects/ministack-site/index.html for a playful children's art brand called Mini Stack — hero, gallery grid with CSS-only placeholder art, about section, contact strip. Self-contained: inline CSS, no frameworks, no external assets. Mobile-first. When done, summarise the structure and update your memory."
Notice the anatomy of a good commission — it's a creative brief compressed to one message: where (the path), what (sections), constraints (self-contained, mobile-first), after (report + memory). The agent saves briefs like this to memory, so future requests can shrink to "another one like ministack-site, but for X".
The bot reports what it built and ls ~/agent/projects/ministack-site/ shows index.html. It stays offline until Part 8 — one thing at a time.
Aider is a free, open-source AI pair-programmer that edits code in git repos with surgical diffs — much stronger on multi-file apps than a chat model free-styling. Install it once, then your agent can shell out to it:
python -m pip install aider-install && aider-install
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
aider --versionAider needs the Groq key under its expected name — add one line to .env (same key you already have):
GROQ_API_KEY=same_gsk_key_as_LLM_API_KEYNow teach your agent the recipe — by texting it, so the recipe lands in memory where it belongs:
"Save a memory doc called playbook.md with this recipe: For heavy coding tasks (multi-file apps, refactors, bug hunts), run Aider non-interactively inside the project folder: aider --model groq/llama-3.3-70b-versatile --yes-always --message "<the task>" — then read the changed files and report. Use your own tools for small edits; use Aider when a job touches 3+ files."
npm install -g @anthropic-ai/claude-code, run claude once over SSH to sign in (uses your Anthropic account — paid). Your agent can then delegate with claude -p "task". Free-tier models build good sites; this builds good software.Two routes. Route A costs nothing, needs no domain and no open ports, and gives every project a global HTTPS URL in seconds — do this one today. Route B is for when you want yourbrand.com served off your own box.
sudo npm install -g wranglerBecause .env is loaded into the agent's environment, every command it runs inherits these — meaning the agent itself can deploy:
CLOUDFLARE_API_TOKEN=paste_token
CLOUDFLARE_ACCOUNT_ID=paste_account_idcd ~/agent
export CLOUDFLARE_API_TOKEN=paste_token
export CLOUDFLARE_ACCOUNT_ID=paste_account_id
wrangler pages project create ministack-site --production-branch=main
wrangler pages deploy projects/ministack-site --project-name=ministack-siteWrangler prints a URL like https://ministack-site.pages.dev.
That URL opens on your phone, with a padlock, from anywhere. A site your agent built is on the public internet for €0.
"Append to playbook.md: To publish a site, run wrangler pages project create <name> --production-branch=main (first time only, ignore 'already exists' errors) then wrangler pages deploy projects/<name> --project-name=<name>, and send me the URL it prints. Restart agent required: no."
From now on, "build me X and put it online" is a single text message, end to end.
Skip freely; .pages.dev covers 90% of needs. Otherwise: buy a domain (~€10/year at Cloudflare Registrar, Porkbun, Namecheap…), and in its DNS create an A record — name preview, value your server IP.
sudo ufw allow 80,443/tcpOracle has a second firewall in the cloud console: VCN → your subnet → Security List → Add Ingress Rules for TCP 80 and 443, source 0.0.0.0/0. Skip this and nothing loads, forever, mysteriously.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddypreview.yourdomain.com {
root * /home/agent/agent/projects
file_server browse
}sudo systemctl reload caddyCaddy fetches HTTPS certificates automatically. The browse directive turns preview.yourdomain.com into a live index of everything in projects/ — every site the agent ever builds is instantly clickable there. Client-ready domains later are four more lines per site in the same file.
https://preview.yourdomain.com loads with a padlock and lists your project folders. If it hangs: DNS not propagated yet (give it 15 min) or the Oracle ingress rule above.
Right now the agent dies when you close the terminal. systemd — Linux's built-in supervisor — will start it on boot, restart it on crash, and collect its logs. First, if the agent is still running from Part 5, stop it with Ctrl+C (two copies polling Telegram at once = the "Conflict" error in troubleshooting).
[Unit]
Description=Agent HQ - personal AI agent
After=network-online.target
Wants=network-online.target
[Service]
User=agent
WorkingDirectory=/home/agent/agent
ExecStart=/home/agent/agent/venv/bin/python agent.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetNo secrets in this file — agent.py reads .env itself on startup.
sudo systemctl daemon-reload
sudo systemctl enable --now agent
systemctl status agent # want: active (running), in greenjournalctl -u agent -f # live logs (Ctrl+C to stop watching)
sudo systemctl restart agent # after any edit to agent.py or .envStatus says active (running); the bot answers from your phone with the terminal closed. For full marks: sudo reboot, wait a minute, and message it — it answers without you touching anything. You now own a 24/7 AI employee.
Phone access is already done — Telegram runs on every phone, tablet and browser you own, from anywhere on earth. This part is the optional layer: talking to your agent instead of typing, letting a second person in, and the honest truth about WhatsApp.
Groq's free tier includes Whisper, a best-in-class speech-to-text model — at the same base URL you already use. Hold the Telegram mic button, ramble a job description while walking, and the agent transcribes it and gets to work. Open the file (nano ~/agent/agent.py) and paste this block just above def main():
# ---------------- voice notes (Groq Whisper, free) ---------------------------
ears = OpenAI(api_key=os.getenv("GROQ_API_KEY", os.getenv("LLM_API_KEY")),
base_url="https://api.groq.com/openai/v1")
async def on_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not authorized(update):
return
f = await update.message.voice.get_file()
ogg = ROOT / "last_voice.ogg"
await f.download_to_drive(ogg)
def transcribe():
with open(ogg, "rb") as audio:
return ears.audio.transcriptions.create(
model="whisper-large-v3", file=audio).text
try:
text = await asyncio.to_thread(transcribe)
except Exception as e:
await update.message.reply_text(f"Couldn't transcribe that: {e}")
return
await update.message.reply_text(f"🎤 heard: {text}")
chat_id = update.effective_chat.id
hist = HISTORY.setdefault(chat_id, [])
hist.append({"role": "user", "content": text})
await context.bot.send_chat_action(chat_id, ChatAction.TYPING)
try:
answer = await asyncio.to_thread(run_agent, hist[-HISTORY_LIMIT:])
except Exception as e:
answer = f"Something broke on my side: {e}"
hist.append({"role": "assistant", "content": answer})
del hist[:-HISTORY_LIMIT]
for c in chunks(answer):
await update.message.reply_text(c)Then add one line inside main(), next to the other add_handler lines:
app.add_handler(MessageHandler(filters.VOICE, on_voice))It reuses the GROQ_API_KEY you added to .env in Part 7 — and falls back to LLM_API_KEY, which works as long as your brain is Groq. If you switched brains to Google or DeepSeek, add a Groq key back just for the ears; Whisper stays free.
sudo systemctl restart agentSend a voice note: "add a line to the changelog saying voice is online". You get 🎤 heard: with your words, then the agent does the job. You now dictate work while pushing a stroller.
Want your partner or a collaborator to use the bot? Swap the single-ID check for a set. In agent.py, replace the authorized function with:
ALLOWED = {123456789, 987654321} # your ID, their ID (from /start)
def authorized(update: Update) -> bool:
return update.effective_user.id in ALLOWEDThey message the bot, /start tells them their ID, you add it, restart. Note they share the same agent, memory and projects — it's a shared employee, not separate accounts.
People ask for WhatsApp first and Telegram second. Here is why this manual does the opposite. The only legitimate route is Meta's WhatsApp Business Cloud API, and compared to BotFather's five minutes it wants:
| Requirement | What it means for you |
|---|---|
| Meta developer account + Business app | Registration, app review dashboard, token juggling |
| A webhook | Meta pushes messages to you — your server must expose a public HTTPS endpoint with a valid certificate. The exact open-ports-and-domain work Telegram's polling let us skip. |
| Business verification | Needed to message beyond a tiny test sandbox — documents, review delays |
| Per-conversation pricing | Since Meta's 2025 pricing change, business-initiated template messages are billed per message. Replies inside the 24-hour service window are the cheap/free part — but check Meta's current pricing page before building on it. |
Libraries that puppet WhatsApp Web with your personal number (Baileys-style bridges) do work — until Meta's anti-automation systems ban the number. Don't feed your real WhatsApp account to one.
Verdict: ship on Telegram today. If a client ever requires WhatsApp, you'd run a small bridge service beside agent.py that receives Meta's webhooks and calls the same run_agent() — the architecture you built today doesn't change at all.
| Piece | The €0 path | The comfort path |
|---|---|---|
| Brain | Groq or Google AI Studio free tier | DeepSeek API — pennies per day of heavy use |
| Server | Oracle Always Free (if you win the capacity lottery) or an old laptop | Hetzner VPS — ~€4/mo, zero drama |
| Interface | Telegram — free | Telegram — still free |
| Publishing | Cloudflare Pages — free, *.pages.dev URLs | + your own domain, ~€10/yr |
| Total | €0/month | ~€4–5/month |
Free tiers meter you per minute and per day. The symptom is always the same: the bot starts answering "Something broke on my side: … 429 …" late in a heavy workday. The entire "migration" to another brain is editing two lines in .env and restarting:
# hit Groq's daily cap? point at Google AI Studio:
LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
LLM_MODEL=gemini-2.5-flash
# outgrown free entirely? DeepSeek costs pennies:
# LLM_BASE_URL=https://api.deepseek.com
# LLM_MODEL=deepseek-chatThe upgrade ladder, in order: ① second free provider (Google) as overflow · ② DeepSeek when you want no caps for almost no money · ③ a frontier model via Claude Code (Part 7) for the builds that make cheap models sweat. Your agent, memory and workflow never change — only the brain gets swapped.
You own every layer: the server, the code, the memory, the projects. No subscription can be revoked, no platform can deprecate you, and your agent's entire brain is a folder of markdown you can read in bed.
First move, always: journalctl -u agent -f in one window, message the bot from your phone, and read the line that appears when it fails. Then find your symptom:
| Symptom | What it means | Fix |
|---|---|---|
Conflict: terminated by other getUpdates | Two copies of the agent are polling Telegram at once | Only one may run. sudo systemctl stop agent while testing manually, or Ctrl+C the manual one before starting the service. |
401 Unauthorized from the LLM | Key doesn't match the base URL, or a stray space/quote in .env | nano .env — the key must belong to the provider in LLM_BASE_URL; no quotes, no trailing spaces. Restart. |
429 / "rate limit" | Free-tier minute or daily cap reached | Wait a minute — or swap brains with the two-line .env edit in Part 11. |
| Bot chats fine but never does anything, or prints raw JSON | The model doesn't support tool-calling (common on OpenRouter :free models) | Use Groq llama-3.3-70b-versatile or Google gemini-2.5-flash — both tool-call reliably. |
| Oracle: "Out of capacity" | Free ARM servers in your region are full — famously common | Retry other availability domains / odd hours, upgrade the account to Pay-As-You-Go (free tier still applies, card as ID), or take the €4 Hetzner and never think about it again. |
Bot silent; systemctl status says active | It's running but crashing per-message, or the token is from a different bot | Watch journalctl -u agent -f while sending a message — the traceback names the culprit. Usually a .env typo. |
error: externally-managed-environment | You ran pip outside the venv | cd ~/agent && source venv/bin/activate first — the prompt must show (venv). |
| Agent says a path is "outside the allowed folder" | The safety fence on read_file/write_file doing its job | Keep files under ~/agent. For anything outside, tell it to use run_command — deliberately. |
| Route B domain never loads | DNS still propagating, or (Oracle) the cloud firewall | Give DNS up to an hour (dig +short preview.yourdomain.com should print your IP). Oracle: add VCN ingress rules for 80/443 — see the warning in Part 8. |
| Trapped in nano | Everyone's first day | Ctrl+X, then Y, then Enter saves and exits. |
Paste the error into the bot itself from your phone: "you threw this error in your logs — read agent.py and tell me what's wrong: …". The agent is surprisingly good at diagnosing its own body. (If it's fully down, that's what this table is for.)
ssh agent@YOUR_SERVER_IP # get in
sudo systemctl restart agent # after ANY edit to agent.py or .env
sudo systemctl status agent # is it alive?
journalctl -u agent -f # live logs — Ctrl+C to stop watching
nano ~/agent/agent.py # edit, save (Ctrl+X, Y, Enter), restart
git -C ~/agent add -A && git -C ~/agent commit -m "snapshot" # version the brain/start # wake check (also shows anyone's user ID)
/reset # wipe the chat thread — memory docs stay intact
/memory # dump everything it currently knows
# the habit that makes it smarter every week — end jobs with:
"...and update your memory."And the deploy one-liner, once Part 8 is done: "deploy projects/<name> and send me the URL" — the playbook doc handles the rest.