Field manual · v1.0 · July 2026 · zero prior server experience assumed

Agent HQ
your own AI staff, on your own server, run from your phone

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.

Total cost€0–4 / mo
Build time~2 hours
Skill neededCopy · paste
Ports to openZero*

* 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.

What you are wiring together

Your phoneTelegram app (or voice notes) — anywhere in the world
Telegram's servers relay the message — free, encrypted in transit
Your server — the HQ a €4 VPS, a free Oracle box, or an old laptop — running one Python file
agent.py — the loop memory/ — its brain, in .md docs projects/ — everything it builds
for each message, the agent thinks with…
A free LLM APIGroq / Google AI Studio / OpenRouter — or a local model via Ollama. Swappable in one line.
and when you ask for a website…
The public webCloudflare Pages (free, global) or your own domain via Caddy on the same server
00

The plan — read this once

5 min readno computer needed

Every "AI agent" — including the fancy commercial ones — is the same four ingredients. You are about to assemble your own from parts you control:

IngredientWhat it doesYour versionCost
A brainThe LLM that thinks and writes codeFree API (Groq / Google) — swappable€0
A bodyA computer that runs 24/7 with a real shellFree Oracle VM, €4 Hetzner VPS, or an old laptop€0–4/mo
HandsTools: run commands, read/write files~200 lines of Python you will paste, not write€0
MemoryPersistent knowledge across conversationsMarkdown 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.

The one rule of this manual

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.

Checkpoint zero

You understand the four ingredients and you know the rule. That's the whole theory portion. Everything from here is copy, paste, verify.

01

Get the brain — a free LLM API key

10 min€0works from any browser

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.

S1

Create a Groq account

Go to console.groq.com → sign up with Google or email. No card, no verification theatre.

S2

Create an API key

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).

S3

Test the key right now

On any machine with a terminal (your laptop is fine — Mac/Linux terminal, or Windows PowerShell with curl):

any terminal
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"}]}'
Checkpoint

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).

Your provider menu (for later — don't set these up now)

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."

ProviderFree tier feelConfig values you'd useNotes
Groq
start here
~30 req/min, ~1,000 req/day per model — fine for one humanhttps://api.groq.com/openai/v1
llama-3.3-70b-versatile
Fastest responses you'll ever see. Also free Whisper for voice (Part 10).
Google AI StudioBigger daily volume than Groq on Flash modelshttps://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.
OpenRouterRotating cast of :free modelshttps://openrouter.ai/api/v1
model varies
One key, many models. Careful: many :free models don't support tool-calling — your agent will go limp. Filter for "tools" support.
DeepSeekNot free — but pennieshttps://api.deepseek.com
deepseek-chat
When you outgrow free tiers: strong model, a heavy month costs about a coffee.
Ollama (local)Truly free & private, foreverhttp://localhost:11434/v1
qwen2.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.
Reality check on "free"

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%.

02

Get the server

20 min€0–4/mo

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:

A — Hetzner VPS

~€4 / month

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.

Verdict: the calm option. Pick this if €4 is acceptable.

B — Oracle "Always Free"

€0 / month

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.

Verdict: real €0, budget patience. Card required for ID check only.

C — Old laptop at home

€0 + electricity

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.

Verdict: great for testing; fine forever if it stays plugged in.

Route A — Hetzner, step by step

  1. Go to hetzner.com/cloud → create account (needs ID or card verification once).
  2. New Project → Add Server. Location: Falkenstein or Helsinki. Image: Ubuntu 24.04. Type: Shared vCPU → the cheapest CX/CAX plan (either x86 or Arm works — everything in this manual is architecture-agnostic).
  3. Under SSH keys: if you have one, paste your public key. If you have no idea what that is, skip it — Hetzner will email you a root password (we'll switch to keys later; the checkpoint still passes).
  4. Create. In ~30 seconds you'll see a green dot and an IP address like 65.108.x.x. Write it down — that IP is "your server" for the rest of this manual.

Route B — Oracle Always Free, step by step

  1. Sign up at oracle.com/cloud/free. Use your real name/address; the card is for identity, not billing. Pick your home region carefully (e.g. Frankfurt or Madrid) — free VMs can only live there, and busy regions run out of ARM capacity.
  2. Console → Compute → Instances → Create Instance.
  3. Image: Ubuntu 24.04. Shape: click Change shape → Ampere → VM.Standard.A1.Flex → set 2 OCPUs / 12 GB (the current free ceiling — don't provision more or it becomes billable).
  4. Add SSH keys: choose "Generate a key pair for me", download both files, keep them like passwords.
  5. Create. If you get "Out of capacity": try a different Availability Domain, try at odd hours, or fall back to the tiny always-free AMD "Micro" shape (slower but works). Many people also report capacity appears after upgrading the account to Pay-As-You-Go — you still pay €0 while inside free limits, but set a budget alert so a mis-click can't bill you.
  6. Copy the instance's Public IP.
S1

Log in for the first time

From your laptop's terminal (Mac/Linux terminal, or Windows: PowerShell — ssh is built in):

your laptop
# 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.key

Type yes when asked about the fingerprint (first connection only).

Checkpoint

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".

03

Prep the server

10 minone-time

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.

S1

Update everything

your server
sudo apt update && sudo apt upgrade -y

On 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.

S2

Create the agent user and become it

your server
sudo adduser --gecos "" agent      # invent a password, remember it
sudo usermod -aG sudo agent
sudo su - agent                    # prompt now says agent@…
S3

Firewall: allow SSH, block the rest

your server
sudo ufw allow OpenSSH
sudo ufw enable                    # answer y — your current session survives
sudo ufw status

This 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.)

S4

Install Python, Git & friends — plus a swap safety-net

your server
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/fstab
Checkpoint

python3 --version prints 3.10 or newer, and free -h shows a Swap: line with 2.0Gi. Your prompt starts with agent@.

04

Create the Telegram bot

5 min€0done on your phone

Telegram bots are created by talking to a bot. On your phone:

  1. Open Telegram → search @BotFather → pick the one with the blue verified tick.
  2. Send /newbot.
  3. It asks for a display name — anything, e.g. HQ.
  4. It asks for a username — must be unique and end in bot, e.g. jelena_hq_bot.
  5. BotFather replies with a token shaped like 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.
  6. Tap the link to your new bot and press Start once (bots can't message you until you've messaged them).
Checkpoint

You have a token containing a colon, and your new bot sits silently in your chat list. Silent is correct — nothing is listening yet.

05

Install the agent

25 minthe big one

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.

S1

Make the home & the Python sandbox

your server
mkdir -p ~/agent && cd ~/agent
python3 -m venv venv
source venv/bin/activate           # prompt gains a (venv) prefix
S2

File 1 of 3 — requirements.txt

nano requirements.txt
python-telegram-bot>=21,<23
openai>=1.40
python-dotenv>=1.0
your server — then install
pip install -r requirements.txt
S3

File 2 of 3 — .env (your secrets)

nano .env
TELEGRAM_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-versatile
your server — lock it down
chmod 600 .env

ALLOWED_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.

S4

File 3 of 3 — agent.py (the whole agent, ~190 lines)

Paste it all. A map of what you're pasting follows below — read it once so this never feels like a magic spell.

nano agent.py
#!/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()

Map of what you just pasted

S5

First boot — claim your agent

your server
python agent.py
  1. Terminal prints Agent is polling Telegram... and sits there. Good — it's alive.
  2. On your phone, message the bot: /start. It replies with your Telegram ID (a number).
  3. Back in the terminal: Ctrl+C to stop, then nano .env and set ALLOWED_USER_ID= to that number. Save, exit.
  4. Run python agent.py again → /start on your phone → "Agent online. Give me a job."
S6

First real job

Send it this from your phone:

Send via Telegram

"Create projects/hello/notes.md with three bullet points describing what you can do for me, then read the file back to me."

Checkpoint — the big one

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.

Straight talk on safety

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.

06

Give it memory — living documentation

15 minthe secret sauce

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.

S1

Seed the four core docs

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.

your server — cd ~/agent first
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/
S2

Verify the brain is loading

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.

S3

Put the brain under version control

your server
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.

Checkpoint

/memory shows four docs, the profile question gets answered from memory, and git log shows one commit.

The three rituals that keep memory sharp

Why this beats fancy memory systems

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.

07

Make it build apps & websites

20 minwhere it earns its keep

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.

S1

Install Node.js (for real web tooling)

your server
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version
S2

Commission your first website — by text message

Send via Telegram

"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".

Checkpoint

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.

S3

Hire a specialist: Aider, the coding sub-agent

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:

your server
python -m pip install aider-install && aider-install
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
aider --version

Aider needs the Groq key under its expected name — add one line to .env (same key you already have):

nano .env — add this line
GROQ_API_KEY=same_gsk_key_as_LLM_API_KEY

Now teach your agent the recipe — by texting it, so the recipe lands in memory where it belongs:

Send via Telegram

"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."

Levelling up the talent (optional, later)

08

Put its websites online

15 min€0route B optional

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.

Route A — Cloudflare Pages: one command per deploy, free forever tier

S1

Install the deploy tool

your server
sudo npm install -g wrangler
S2

Get two Cloudflare values (free account)

  1. Sign up at dash.cloudflare.com. On the dashboard, copy your Account ID (right-hand column of any zone page, or in the URL).
  2. My Profile → API Tokens → Create Token → Create Custom Token. Permissions: Account · Cloudflare Pages · Edit. Create, copy the token.
S3

Give the keys to the agent (via .env)

Because .env is loaded into the agent's environment, every command it runs inherits these — meaning the agent itself can deploy:

nano .env — add two lines
CLOUDFLARE_API_TOKEN=paste_token
CLOUDFLARE_ACCOUNT_ID=paste_account_id
S4

First deploy — by hand once, so you trust it

your server
cd ~/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-site

Wrangler prints a URL like https://ministack-site.pages.dev.

Checkpoint

That URL opens on your phone, with a padlock, from anywhere. A site your agent built is on the public internet for €0.

S5

Teach the agent the deploy recipe

Send via Telegram

"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.

Route B — your own domain, served from your own server (Caddy)

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.

your server — open web ports
sudo ufw allow 80,443/tcp
Oracle users only

Oracle 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.

your server — install Caddy (official repo)
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 caddy
sudo nano /etc/caddy/Caddyfile — replace contents
preview.yourdomain.com {
    root * /home/agent/agent/projects
    file_server browse
}
your server
sudo systemctl reload caddy

Caddy 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.

Checkpoint (route B)

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.

09

Keep it alive 24/7

10 minone-time

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).

S1

Create the service file

sudo nano /etc/systemd/system/agent.service
[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.target

No secrets in this file — agent.py reads .env itself on startup.

S2

Switch it on — permanently

your server
sudo systemctl daemon-reload
sudo systemctl enable --now agent
systemctl status agent          # want: active (running), in green
S3

Learn the two commands you'll actually use

your server
journalctl -u agent -f          # live logs (Ctrl+C to stop watching)
sudo systemctl restart agent    # after any edit to agent.py or .env
Checkpoint — graduation

Status 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.

10

Voice notes, WhatsApp & extras

optional15 min

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.

S1

Voice notes → the agent's ears

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():

agent.py — voice add-on
# ---------------- 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:

agent.py — inside main()
    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.

your server
sudo systemctl restart agent
Checkpoint

Send 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.

S2

Let a second person in (optional)

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:

agent.py — multi-user
ALLOWED = {123456789, 987654321}          # your ID, their ID (from /start)
def authorized(update: Update) -> bool:
    return update.effective_user.id in ALLOWED

They 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.

S3

WhatsApp — the honest version

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:

RequirementWhat it means for you
Meta developer account + Business appRegistration, app review dashboard, token juggling
A webhookMeta 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 verificationNeeded to message beyond a tiny test sandbox — documents, review delays
Per-conversation pricingSince 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.
About the "unofficial" shortcuts

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.

11

The bill, and when to upgrade

2 min read
PieceThe €0 pathThe comfort path
BrainGroq or Google AI Studio free tierDeepSeek API — pennies per day of heavy use
ServerOracle Always Free (if you win the capacity lottery) or an old laptopHetzner VPS — ~€4/mo, zero drama
InterfaceTelegram — freeTelegram — still free
PublishingCloudflare Pages — free, *.pages.dev URLs+ your own domain, ~€10/yr
Total€0/month~€4–5/month

The wall you'll eventually hit — and the two-line fix

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:

.env — swap the brain in 20 seconds
# 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-chat

The 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.

Checkpoint — the whole point

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.

!!

When something breaks

the classicskeep calm, read logs

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:

SymptomWhat it meansFix
Conflict: terminated by other getUpdatesTwo copies of the agent are polling Telegram at onceOnly one may run. sudo systemctl stop agent while testing manually, or Ctrl+C the manual one before starting the service.
401 Unauthorized from the LLMKey doesn't match the base URL, or a stray space/quote in .envnano .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 reachedWait a minute — or swap brains with the two-line .env edit in Part 11.
Bot chats fine but never does anything, or prints raw JSONThe 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 commonRetry 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 activeIt's running but crashing per-message, or the token is from a different botWatch journalctl -u agent -f while sending a message — the traceback names the culprit. Usually a .env typo.
error: externally-managed-environmentYou ran pip outside the venvcd ~/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 jobKeep files under ~/agent. For anything outside, tell it to use run_command — deliberately.
Route B domain never loadsDNS still propagating, or (Oracle) the cloud firewallGive 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 nanoEveryone's first dayCtrl+X, then Y, then Enter saves and exits.
The universal debugger

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.)

▸▸

Cheat sheet — the daily six

bookmark this
from any terminal — server ops
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
from your phone — Telegram
/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.