Self-Hosted Personal Agents Basics
8 concept-first examples for a tiny self-hosted personal agent - 5 basic and 3 intermediate.
You will sketch a host process, one chat adapter, a bounded agent loop, durable prefs, and a pause switch. Code stays sketch-level so the runtime shape stays clear.
Treat Clawdbot/OpenClaw-style systems as pattern references for always-on personal hosts, not as required vendors.
Prerequisites
- Comfort with the agent loop idea (model decides, tools run, observations return)
- Optional: Python 3.11+ if you want to type the sketches locally
- One chat platform account you can create a bot on (Telegram is a common first choice)
- Treat bot tokens and API keys as production secrets even in demos
# Optional local env for later experiments (not required to learn the patterns)
python -m venv .venv && source .venv/bin/activate
pip install pydantic openaiBasic Examples
1. Describe the Host, Not the Brand
Write the system as checkable pieces.
Host: long-running process on my laptop
Wake: Telegram DM from allowlisted user id only
Tools day one: none beyond post_reply
Model: OpenAI-compatible endpoint (direct or gateway)
Stop: final answer or max_turns=6
Success: I get a useful reply in Telegram without opening a web UI- Success is observable without "vibes."
- No product name is required to scope the work.
- Shell, mail, and multi-platform come later.
2. Normalize Chat Into One Event Shape
Adapters should hide platform quirks.
from pydantic import BaseModel
class ChatEvent(BaseModel):
platform: str # "telegram"
sender_id: str
text: str
thread_id: str | None = None
message_id: str
def from_telegram_update(update: dict) -> ChatEvent:
msg = update["message"]
return ChatEvent(
platform="telegram",
sender_id=str(msg["from"]["id"]),
text=msg.get("text") or "",
thread_id=str(msg["chat"]["id"]),
message_id=str(msg["message_id"]),
)- Downstream code should only see
ChatEvent. - Duplicate
message_idvalues should be ignored (idempotency). - Keep raw platform payloads out of the model prompt when possible.
3. Allowlist Before Any Model Call
Default deny is the first security control.
ALLOWED = {"123456789"} # your Telegram user id
def authorize(event: ChatEvent) -> bool:
return event.sender_id in ALLOWED
def handle(event: ChatEvent) -> str | None:
if not authorize(event):
return None # silent ignore or static "unauthorized"
return run_agent(event.text)- Open DMs turn a personal agent into a public RCE surface once tools exist.
- Log rejects at low volume; do not feed strangers into the model.
- Revisit the list when friends or devices change.
Related: Connecting Discord, Slack, Telegram, and WhatsApp Integrations
4. Bound Every Wake With max_turns
Always-on hosts still need finite sessions.
def run_agent(goal: str, tools: dict, max_turns: int = 6) -> str:
state = {"goal": goal, "trace": []}
for turn in range(max_turns):
decision = decide(state, tools) # model call
if decision.done:
return decision.answer
obs = tools[decision.tool](**decision.args)
state["trace"].append({"turn": turn, "tool": decision.tool, "obs": obs})
return "stopped: max turns - partial result"- Six turns is a reasonable first default for simple chat goals.
- Controlled stop beats silent hang or runaway spend.
- Persist the trace for debugging; redact secrets first.
5. Post Replies Through a Thin Egress Tool
Chat is I/O, not the whole agent.
def post_reply(thread_id: str, text: str) -> dict:
# Platform Bot API or SDK; return ok + message id
return {"ok": True, "thread_id": thread_id, "chars": len(text)}
# Day-one tool registry:
# tools = {"post_reply": post_reply} # or host posts after the loop- Keep messages short; link out for long digests later.
- Rate-limit proactive posts once you add cron.
- Never log the full bot token alongside reply text.
Intermediate Examples
6. Load Prefs From Disk on Every Wake
Continuity needs files, not only RAM.
import json
from pathlib import Path
PREFS_PATH = Path.home() / ".personal-agent" / "prefs.json"
DEFAULT_PREFS = {
"timezone": "America/Los_Angeles",
"tone": "concise, no emojis",
"quiet_hours": ["22:00", "07:00"],
}
def load_prefs() -> dict:
if not PREFS_PATH.exists():
PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
PREFS_PATH.write_text(json.dumps(DEFAULT_PREFS, indent=2))
return json.loads(PREFS_PATH.read_text())
# Inject a short prefs block into system context each wake.- Preferences are product requirements for a personal agent.
- Do not let the model rewrite security-relevant keys without review.
- Back up
prefs.jsonwith the rest of your host config.
Related: Persistent Memory Across Restarts for a Personal Agent
7. Add a Pause Kill Switch That Survives Restart
You need a stop that does not require redeploying code.
from pathlib import Path
FLAG = Path.home() / ".personal-agent" / "PAUSED"
def is_paused() -> bool:
return FLAG.exists()
def handle(event: ChatEvent) -> str | None:
if event.text.strip().upper() == "/PAUSE":
FLAG.parent.mkdir(parents=True, exist_ok=True)
FLAG.write_text("paused\n")
return "Agent paused. Send /RESUME to continue."
if event.text.strip().upper() == "/RESUME":
FLAG.unlink(missing_ok=True)
return "Agent resumed."
if is_paused():
return "Agent is paused. Send /RESUME."
if not authorize(event):
return None
return run_agent(event.text)- File flags work when the process restarts mid-incident.
- Keep pause logic before tool execution.
- Pair with a host-level stop (
systemd stop, Docker stop) as backup.
8. Point the Model Client at a Swappable Base URL
Backend flexibility starts on day one.
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ.get("MODEL_BASE_URL", "https://openrouter.ai/api/v1"),
api_key=os.environ["MODEL_API_KEY"],
)
def complete(messages: list[dict]) -> str:
resp = client.chat.completions.create(
model=os.environ.get("MODEL_ID", "openai/gpt-4o-mini"), # verify at build
messages=messages,
)
return resp.choices[0].message.content or ""- Same code path can hit a gateway, a provider, or a local OpenAI-compatible server.
- Keep model id and base URL in env, not hardcoded in tools.
- Log usage when the API returns it; always-on hosts need spend visibility.
Related: Backend Flexibility: Routing a Personal Agent Through OpenRouter
Related
- The Self-Hosted Personal Agent Mental Model - host + adapters + tools picture
- Setting Up Clawdbot/OpenClaw on a VPS or Local Machine - install and process management
- Connecting Discord, Slack, Telegram, and WhatsApp Integrations - multi-platform adapters
- Persistent Memory Across Restarts for a Personal Agent - durable state
- Self-Hosted Personal Agents Best Practices - long-term security and ops
- Personal & Consumer Use Cases Basics - mail digest use-case sketches
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.