A private AI agent on a Raspberry Pi that watches the long jobs on my Windows desktop and answers questions about them from my phone, anywhere, without exposing either machine to the internet.
I kick off long jobs on my desktop all the time: Claude Code sessions, nightly backups, renders. Then I leave. The questions follow me out the door. Did the backup finish? Did the render die at frame 40? Is Claude sitting there waiting for me to approve something?
The usual answers are bad. Remote desktop means leaving a port open or trusting a third-party relay with full control of my machine. A cloud dashboard means shipping my job data somewhere else. Neither gives me a simple way to ask.
This is also the problem Tailscale's customers are now bringing to it at scale. Most of the business customers Tailscale added in 2026 are using it inside their AI infrastructure, and the company shipped Aperture, a gateway built so agents never hold raw credentials and every action is logged. The pattern underneath is the same one I need at home: an agent should reach exactly what it has been granted, nothing more, and a human should reach the agent rather than the machines behind it.
So this guide builds that pattern at the smallest useful size: three devices, two rules, one agent.
The Pi is the only thing that can see the desktop, and I only ever talk to the Pi. Every arrow below is a rule in the tailnet policy. Everything without an arrow is denied.
| Device | Identity on the tailnet | Runs | Can reach |
|---|---|---|---|
| Windows desktop | tag:desktop-jobs | watch.py, status_server.py | Nothing. It only answers. |
| Raspberry Pi | tag:agent-hub | agent.py behind tailscale serve | Desktop, port 8081 only |
| iPhone | Me (autogroup:member) | Tailscale app, Safari | Pi, port 443 only |
Machines get tags; people keep their identity. That split is Tailscale's own recommendation, and it matters later: tagged machines don't belong to a person, so the desktop and Pi keep working if my login changes, and requests from my phone arrive at the agent labeled with who sent them.
No code yet. The goal of this step is three devices that can see each other, with sensible names.
agent-hub, create a user (I use siri), enter your Wi-Fi details, and enable SSH.ssh siri@agent-hub.localsudo apt update && sudo apt full-upgrade -y
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale upThe last command prints a login URL. Open it and sign in with your Tailscale account.
Install the Tailscale client from tailscale.com/download and sign in with the same account. It appears in the system tray.
Install Tailscale from the App Store, sign in, and allow the VPN configuration. In the app's settings, turn on VPN On Demand so the phone stays on the tailnet without you thinking about it.
In the admin console, open Machines and rename the desktop to siri-desktop (Windows names like DESKTOP-7Q2K4LM are hard to type on a phone). With MagicDNS, which new tailnets have on by default, each machine is now reachable by that short name.
tailscale status # every machine on the tailnet, with its 100.x address
tailscale ping siri-desktop # reachable by name through MagicDNSA new tailnet allows every device to reach every other device. This step replaces that with two rules, then proves the rules hold.
Before you paste this: replacing the default policy removes the allow-all rule. If other devices are on your tailnet, they lose access to each other until you add rules for them.
In the admin console, open Access controls, replace the contents with the policy below, and change you@example.com to your Tailscale login.
// Desktop Babysitter: tailnet policy (Admin console -> Access controls)
// Default deny: anything not granted below is blocked.
{
"tagOwners": {
"tag:agent-hub": ["autogroup:admin"], // the Raspberry Pi
"tag:desktop-jobs": ["autogroup:admin"], // the Windows desktop
},
"acls": [
// Me, on my iPhone (any device signed in as me): the agent's web app. Nothing else.
{"action": "accept", "src": ["autogroup:member"], "dst": ["tag:agent-hub:443"]},
// The agent: the desktop's status endpoint. Nothing else.
{"action": "accept", "src": ["tag:agent-hub"], "dst": ["tag:desktop-jobs:8081"]},
],
// Checked every time the policy is saved. If a rule change would let the
// phone reach the desktop directly, the save is rejected.
"tests": [
{
"src": "you@example.com", // your Tailscale login
"accept": ["tag:agent-hub:443"],
"deny": ["tag:desktop-jobs:8081", "tag:agent-hub:22"],
},
{
"src": "tag:agent-hub",
"accept": ["tag:desktop-jobs:8081"],
"deny": ["tag:desktop-jobs:3389"], // not even Remote Desktop
},
{
"src": "tag:desktop-jobs",
"deny": ["tag:agent-hub:443"], // the desktop only ever answers
},
],
"ssh": [],
}The tests block is the part I'd point a security team to. Tailscale runs those assertions every time the policy is saved. If someone later adds a rule that lets a phone reach the desktop directly, or lets the Pi open Remote Desktop, the save is rejected with the failing test. The perimeter is written down and enforced.
In Machines, open the menu on agent-hub, choose Edit ACL tags, and add tag:agent-hub. Do the same for siri-desktop with tag:desktop-jobs. Tagged machines stop being owned by your user and, by default, their node keys don't expire, which is what you want for an always-on Pi.
The policy says the phone can't reach the desktop. Check before trusting it. Tailscale will also show you whether a connection is direct or relayed:
# Allowed: the agent's one path. After step 04 this returns job JSON.
curl -m 5 http://siri-desktop:8081/jobs
# Denied: anything else on the desktop, for example Remote Desktop
timeout 3 bash -c '</dev/tcp/siri-desktop/3389' && echo open || echo blocked
# Direct or relayed? "via DERP" means relayed through Tailscale; an IP:port means direct.
tailscale ping siri-desktopThen on the iPhone, open Safari and go to http://siri-desktop:8081. It should fail. Nothing is listening there yet, but the connection is refused by policy before it ever reaches the desktop. Once the desktop is set up in the next step, re-run this check: the Pi gets data, the phone still gets nothing.
Two small Python scripts. watch.py records each job as a JSON file in %USERPROFILE%\.babysitter\jobs. status_server.py serves those files to the tailnet. Neither one talks to the internet.
C:\babysitter and save the two scripts below into it.100.64.0.0/10):py -m pip install psutil
New-NetFirewallRule -DisplayName "Babysitter status (tailnet only)" `
-Direction Inbound -Protocol TCP -LocalPort 8081 `
-RemoteAddress 100.64.0.0/10 -Action Allow"""Desktop Babysitter: job reporter for the Windows desktop.
Every job gets a small JSON file in %USERPROFILE%\\.babysitter\\jobs. The status
server (status_server.py) serves those files to the Pi over the tailnet. This
script never talks to the network itself.
Three ways to report a job:
Wrap a command (you know its exit code):
py watch.py run --name "Nightly backup" --ok-codes 0-7 -- robocopy C:\\Work D:\\Backup /MIR
py watch.py run --name "Refactor auth" -- claude -p "refactor the auth module"
Watch something already running (a render you started from the app):
py watch.py process --name "Blender render" --image blender.exe
Mark an event (used by the Claude Code hooks):
py watch.py mark --name "Claude Code" --status waiting
"""
import argparse
import collections
import json
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
JOBS_DIR = Path(os.environ.get("BABYSITTER_DIR", Path.home() / ".babysitter")) / "jobs"
TAIL_LINES = 15 # how many lines of output the agent gets to see
FLUSH_EVERY_S = 10 # how often a running job's tail is refreshed on disk
def now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def write_job(job):
"""Write atomically, so the status server never reads half a file."""
JOBS_DIR.mkdir(parents=True, exist_ok=True)
path = JOBS_DIR / f"{job['id']}.json"
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(job, indent=2), encoding="utf-8")
os.replace(tmp, path)
def new_job(name, kind, detail, job_id=None):
return {
"id": job_id or time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"name": name,
"kind": kind, # command | process | event
"detail": detail,
"status": "running", # running | succeeded | failed | finished | waiting | lost
"pid": os.getpid(), # lets the server spot a reporter that died mid-job
"started_at": now(),
"updated_at": now(),
"finished_at": None,
"duration_s": None,
"exit_code": None,
"tail": [],
}
def parse_ok_codes(spec):
"""'0' -> {0}; '0-7' -> {0..7}; '0,1,3' -> {0,1,3}. Robocopy uses 0-7 for success."""
codes = set()
for part in spec.split(","):
if "-" in part:
lo, hi = part.split("-")
codes.update(range(int(lo), int(hi) + 1))
else:
codes.add(int(part))
return codes
def cmd_run(args):
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
sys.exit("Nothing to run. Put the command after --")
# Resolve through PATH/PATHEXT so npm shims like claude.cmd start on Windows.
exe = shutil.which(command[0])
if exe:
command = [exe] + command[1:]
ok_codes = parse_ok_codes(args.ok_codes)
job = new_job(args.name, "command", " ".join(command))
write_job(job)
t0 = time.monotonic()
tail = collections.deque(maxlen=TAIL_LINES)
try:
proc = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
)
except FileNotFoundError:
job.update(status="failed", finished_at=now(), exit_code=None,
tail=[f"Could not start: {command[0]} was not found"])
write_job(job)
sys.exit(f"Could not start {command[0]}: not found on PATH")
last_flush = time.monotonic()
for line in proc.stdout:
sys.stdout.write(line) # you still see the output as normal
tail.append(line.rstrip()[:300])
if time.monotonic() - last_flush > FLUSH_EVERY_S:
job.update(tail=list(tail), updated_at=now())
write_job(job)
last_flush = time.monotonic()
code = proc.wait()
job.update(
status="succeeded" if code in ok_codes else "failed",
exit_code=code, finished_at=now(), updated_at=now(),
duration_s=round(time.monotonic() - t0), tail=list(tail),
)
write_job(job)
sys.exit(code)
def cmd_process(args):
import psutil
image = args.image.lower()
def running():
return [p for p in psutil.process_iter(["name"])
if (p.info["name"] or "").lower() == image]
if not running():
sys.exit(f"No running process called {args.image}. Start the job first.")
job = new_job(args.name, "process", args.image)
write_job(job)
t0 = time.monotonic()
print(f"Watching {args.image}. Leave this window open; Ctrl+C to stop watching.")
while running():
time.sleep(5)
job.update(status="finished", finished_at=now(), updated_at=now(),
duration_s=round(time.monotonic() - t0),
tail=[f"{args.image} exited (exit code not visible to a watcher)"])
write_job(job)
def cmd_mark(args):
"""One-shot event. Claude Code hooks pipe a JSON payload on stdin; if one
arrives, use its session id so each session keeps a single job card."""
payload = {}
if not sys.stdin.isatty():
try:
payload = json.loads(sys.stdin.read() or "{}")
except json.JSONDecodeError:
payload = {}
session = str(payload.get("session_id", ""))[:8]
slug = re.sub(r"[^a-z0-9]+", "-", args.name.lower()).strip("-")
job_id = f"{slug}-{session}" if session else slug
path = JOBS_DIR / f"{job_id}.json"
job = json.loads(path.read_text(encoding="utf-8")) if path.exists() \
else new_job(args.name, "event", payload.get("cwd", ""), job_id=job_id)
job.update(status=args.status, updated_at=now(), pid=None)
if payload.get("message"):
job["tail"] = [str(payload["message"])[:300]]
if payload.get("cwd"):
job["detail"] = payload["cwd"]
write_job(job)
def main():
p = argparse.ArgumentParser(description="Report long-running jobs to the babysitter agent.")
sub = p.add_subparsers(dest="mode", required=True)
r = sub.add_parser("run", help="run a command and report how it went")
r.add_argument("--name", required=True)
r.add_argument("--ok-codes", default="0", help="exit codes that mean success, e.g. 0-7")
r.add_argument("command", nargs=argparse.REMAINDER)
r.set_defaults(fn=cmd_run)
w = sub.add_parser("process", help="watch an already-running program until it exits")
w.add_argument("--name", required=True)
w.add_argument("--image", required=True, help="process name, e.g. blender.exe")
w.set_defaults(fn=cmd_process)
m = sub.add_parser("mark", help="record a one-off event (Claude Code hooks)")
m.add_argument("--name", required=True)
m.add_argument("--status", required=True)
m.set_defaults(fn=cmd_mark)
args = p.parse_args()
args.fn(args)
if __name__ == "__main__":
main()"""Desktop Babysitter: status server for the Windows desktop.
Serves the job files written by watch.py as one JSON document at /jobs.
It binds to this machine's Tailscale address only, so nothing on your home
Wi-Fi (or anywhere else) can reach it; on the tailnet, the ACL decides who
can. The ACL lets exactly one machine in: the Pi tagged tag:agent-hub.
Run at logon (see the guide): pyw C:\\babysitter\\status_server.py
"""
import json
import os
import socket
import subprocess
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import psutil
PORT = int(os.environ.get("BABYSITTER_PORT", "8081"))
JOBS_DIR = Path(os.environ.get("BABYSITTER_DIR", Path.home() / ".babysitter")) / "jobs"
KEEP = 20 # most recent jobs to report
def tailscale_ip():
"""Wait for Tailscale to come up after logon, then bind to its IPv4 address."""
override = os.environ.get("BABYSITTER_BIND")
if override:
return override
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
for _ in range(60): # up to five minutes
try:
out = subprocess.check_output(["tailscale", "ip", "-4"], text=True,
timeout=10, creationflags=flags).strip()
if out:
return out.splitlines()[0]
except (OSError, subprocess.SubprocessError):
pass
time.sleep(5)
raise SystemExit("No Tailscale IP. Is Tailscale running and signed in?")
def load_jobs():
jobs = []
for f in JOBS_DIR.glob("*.json"):
try:
jobs.append(json.loads(f.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
continue # skip a file mid-write; it'll be there next poll
for j in jobs:
# A "running" job whose reporter process is gone was interrupted
# (reboot, closed window). Say so instead of claiming it's still going.
if j.get("status") == "running" and j.get("pid") and not psutil.pid_exists(j["pid"]):
j["status"] = "lost"
jobs.sort(key=lambda j: j.get("updated_at") or j.get("started_at") or "", reverse=True)
return jobs[:KEEP]
class Handler(BaseHTTPRequestHandler):
def _send(self, payload, status=200):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/jobs":
self._send({
"host": socket.gethostname(),
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"jobs": load_jobs(),
})
elif self.path == "/healthz":
self._send({"ok": True})
else:
self._send({"error": "not found"}, 404)
def log_message(self, fmt, *args):
pass # quiet under pythonw; the Pi keeps the access log
if __name__ == "__main__":
ip = tailscale_ip()
print(f"Babysitter status server on http://{ip}:{PORT}/jobs")
ThreadingHTTPServer((ip, PORT), Handler).serve_forever()The server binds to the desktop's Tailscale address, not to 0.0.0.0. Devices on your home Wi-Fi can't reach it at all; devices on the tailnet can reach it only if the policy allows, which means only the Pi.
schtasks /Create /TN "Babysitter status server" /TR "pyw C:\babysitter\status_server.py" /SC ONLOGON /F
schtasks /Run /TN "Babysitter status server"Wrap anything you run from a terminal. --ok-codes exists because robocopy reports success with exit codes 0 to 7, which would otherwise look like failures.
# Backup: robocopy uses exit codes 0-7 for success
py C:\babysitter\watch.py run --name "Nightly backup" --ok-codes 0-7 -- robocopy C:\Work D:\Backup /MIR
# A headless Claude Code run
py C:\babysitter\watch.py run --name "Refactor auth" -- claude -p "refactor the auth module and run the tests"
# A render started from the app's own window
py C:\babysitter\watch.py process --name "Blender render" --image blender.exeFor interactive Claude Code sessions, hooks do the reporting. Add these to %USERPROFILE%\.claude\settings.json (merge with any hooks you already have). A prompt marks the session as running; when Claude finishes its turn or needs a permission, the card flips to waiting. Hook event names can change between Claude Code versions, so check the hooks documentation if one doesn't fire.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "py C:\\babysitter\\watch.py mark --name \"Claude Code\" --status running" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "py C:\\babysitter\\watch.py mark --name \"Claude Code\" --status waiting" }] }
],
"Notification": [
{ "hooks": [{ "type": "command", "command": "py C:\\babysitter\\watch.py mark --name \"Claude Code\" --status waiting" }] }
]
}
}agent.py polls the desktop every 20 seconds, keeps the latest snapshot in memory, serves a small web app for the phone, and asks Claude to answer questions about the snapshot. It uses Claude Haiku 4.5 by default: fast, and a fraction of a cent per question at this size.
"""Desktop Babysitter: the agent that runs on the Raspberry Pi.
- Polls the desktop's status server over the tailnet every 20 seconds.
- Serves a small web app for the phone (phone.html) and a JSON API.
- Answers questions about the jobs with Claude. The API key lives only here:
the phone never holds it, and the desktop never sees it.
It listens on 127.0.0.1 only. `tailscale serve` publishes it to the tailnet
over HTTPS with a real certificate, and passes along who is asking.
"""
import json
import os
import threading
import time
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zoneinfo import ZoneInfo
import anthropic
DESKTOP_URL = os.environ.get("DESKTOP_URL", "http://desktop:8081") # MagicDNS name
MODEL = os.environ.get("BABYSITTER_MODEL", "claude-haiku-4-5-20251001")
LOCAL_TZ = ZoneInfo(os.environ.get("BABYSITTER_TZ", "America/Toronto"))
NTFY_URL = os.environ.get("NTFY_URL") # optional push notifications, see guide
POLL_S = 20
HERE = Path(__file__).parent
AUDIT = HERE / "audit.log"
SYSTEM = """You are the babysitter agent for the owner's Windows desktop. You answer
questions about long-running jobs (Claude Code sessions, backups, renders) using
ONLY the snapshot you are given.
Rules:
- One to three short sentences, plain text. It is read on a phone.
- Durations in human terms ("47 minutes"), times relative to now ("12 minutes ago").
- Status meanings: running; succeeded / failed (exit code known); finished (a
watched program exited, exit code unknown); waiting (Claude Code is waiting for
input); lost (the reporter stopped mid-job, usually a reboot or closed window).
- If a job failed, quote the most telling line from its output tail.
- If the desktop is unreachable, say so and give the age of the last good data.
- Never invent jobs or details that are not in the snapshot."""
state = {"jobs": [], "host": None, "fetched_at": None, "last_ok": None, "error": None}
lock = threading.Lock()
client = anthropic.Anthropic() if os.environ.get("ANTHROPIC_API_KEY") else None
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def audit(event, **fields):
line = json.dumps({"ts": utc_now(), "event": event, **fields})
with AUDIT.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def notify(title, message):
if not NTFY_URL:
return
try:
req = urllib.request.Request(NTFY_URL, data=message.encode(),
headers={"Title": title}, method="POST")
urllib.request.urlopen(req, timeout=5).close()
except OSError as e:
audit("notify_failed", error=str(e))
def poll_forever():
previous = {}
while True:
try:
with urllib.request.urlopen(f"{DESKTOP_URL}/jobs", timeout=5) as r:
data = json.load(r)
with lock:
state.update(jobs=data["jobs"], host=data.get("host"),
fetched_at=utc_now(), last_ok=utc_now(), error=None)
for job in data["jobs"]:
before = previous.get(job["id"])
if before and before != job["status"] and before == "running":
notify(f"{job['name']}: {job['status']}",
f"{job['name']} is {job['status']} on {data.get('host')}.")
audit("job_changed", job=job["name"], status=job["status"])
previous = {j["id"]: j["status"] for j in data["jobs"]}
except (OSError, ValueError, KeyError) as e:
with lock:
state.update(fetched_at=utc_now(), error=f"Can't reach the desktop: {e}")
time.sleep(POLL_S)
def ask(question):
if client is None:
return "The agent has no ANTHROPIC_API_KEY set, so it can't answer questions yet."
with lock:
snapshot = dict(state)
local_now = datetime.now(LOCAL_TZ).strftime("%A %H:%M %Z")
msg = client.messages.create(
model=MODEL,
max_tokens=300,
system=SYSTEM,
messages=[{"role": "user", "content":
f"Now: {utc_now()} UTC ({local_now} local).\n"
f"Snapshot:\n{json.dumps(snapshot, indent=1)}\n\n"
f"Question: {question}"}],
)
return "".join(b.text for b in msg.content if b.type == "text").strip()
class Handler(BaseHTTPRequestHandler):
def _send(self, body, ctype="application/json", status=200):
if not isinstance(body, bytes):
body = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def who(self):
# Added by `tailscale serve` for requests from your own (untagged) devices.
return self.headers.get("Tailscale-User-Login", "unknown")
def do_GET(self):
if self.path in ("/", "/index.html"):
self._send((HERE / "phone.html").read_bytes(), "text/html; charset=utf-8")
elif self.path == "/api/jobs":
with lock:
self._send(dict(state))
else:
self._send({"error": "not found"}, status=404)
def do_POST(self):
if self.path != "/api/ask":
return self._send({"error": "not found"}, status=404)
length = int(self.headers.get("Content-Length", 0))
if length > 2000:
return self._send({"error": "Question too long"}, status=413)
try:
question = json.loads(self.rfile.read(length) or b"{}").get("question", "").strip()
except json.JSONDecodeError:
question = ""
if not question:
return self._send({"error": "Ask a question first"}, status=400)
try:
answer = ask(question)
except anthropic.APIError as e:
audit("ask_failed", who=self.who(), error=str(e))
return self._send({"error": f"Claude API error: {e}"}, status=502)
audit("ask", who=self.who(), question=question)
self._send({"answer": answer})
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
threading.Thread(target=poll_forever, daemon=True).start()
print(f"Agent on http://127.0.0.1:8080, polling {DESKTOP_URL}")
ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Babysitter">
<title>Babysitter</title>
<style>
:root { --bg:#fff; --ink:#140f10; --muted:#6d6264; --line:#e7dfe0; --accent:#6b1420;
--ok:#1f6b3a; --bad:#a3162b; --warn:#8a5a00; --card:#faf7f7; color-scheme: light; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0e0b0c; --ink:#f4eeee; --muted:#a89c9e; --line:#2a2224; --accent:#c9545f;
--ok:#5cc184; --bad:#ff6b7c; --warn:#e3b04b; --card:#171213; color-scheme: dark; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font: 16px/1.45 -apple-system, system-ui, sans-serif;
padding: calc(env(safe-area-inset-top) + 16px) 16px calc(env(safe-area-inset-bottom) + 24px); }
header { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
h1 { font-size:22px; margin:0; }
#seen { color:var(--muted); font-size:13px; }
form { display:flex; gap:8px; margin:18px 0 8px; }
input { flex:1; font:inherit; padding:12px; border:1px solid var(--line); border-radius:10px;
background:var(--card); color:var(--ink); }
button { font:inherit; font-weight:600; padding:12px 16px; border:0; border-radius:10px;
background:var(--accent); color:#fff; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; }
.chips button { background:transparent; color:var(--accent); border:1px solid var(--line);
font-weight:500; font-size:14px; padding:6px 10px; }
#answer { min-height:1.5em; padding:14px; border-left:3px solid var(--accent);
background:var(--card); border-radius:0 10px 10px 0; margin-bottom:22px; }
#answer:empty { display:none; }
.job { padding:14px 0; border-top:1px solid var(--line); }
.row { display:flex; justify-content:space-between; gap:10px; align-items:center; }
.name { font-weight:600; }
.meta { color:var(--muted); font-size:13px; margin-top:2px; overflow-wrap:anywhere; }
.pill { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
padding:3px 8px; border-radius:999px; border:1px solid currentColor; white-space:nowrap; }
.running { color:var(--accent); } .succeeded, .finished { color:var(--ok); }
.failed, .lost { color:var(--bad); } .waiting { color:var(--warn); }
#error { color:var(--bad); font-size:14px; }
</style>
</head>
<body>
<header><h1>Desktop</h1><span id="seen">connecting…</span></header>
<form id="ask">
<input id="q" autocomplete="off" placeholder="Ask about your jobs">
<button type="submit">Ask</button>
</form>
<div class="chips">
<button type="button">Is the backup done?</button>
<button type="button">Did anything fail?</button>
<button type="button">Is Claude waiting on me?</button>
</div>
<div id="answer"></div>
<p id="error" hidden></p>
<section id="jobs"></section>
<script>
const $ = (s) => document.querySelector(s);
function ago(iso) {
if (!iso) return "";
const s = Math.max(0, (Date.now() - new Date(iso)) / 1000);
if (s < 60) return "just now";
if (s < 3600) return Math.round(s / 60) + " min ago";
if (s < 86400) return Math.round(s / 3600) + " h ago";
return Math.round(s / 86400) + " d ago";
}
function dur(sec) {
if (sec == null) return "";
const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
return h ? `${h} h ${m} min` : `${m} min`;
}
function esc(t) { const d = document.createElement("div"); d.textContent = t ?? ""; return d.innerHTML; }
async function refresh() {
try {
const r = await fetch("/api/jobs", { cache: "no-store" });
const s = await r.json();
$("#seen").textContent = s.host ? `${s.host} · ${ago(s.last_ok)}` : "no data yet";
$("#error").hidden = !s.error;
$("#error").textContent = s.error || "";
$("#jobs").innerHTML = (s.jobs || []).map((j) => `
<div class="job">
<div class="row"><span class="name">${esc(j.name)}</span>
<span class="pill ${esc(j.status)}">${esc(j.status)}</span></div>
<div class="meta">${j.status === "running" ? "started " + ago(j.started_at)
: ago(j.finished_at || j.updated_at)}${j.duration_s != null ? " · took " + dur(j.duration_s) : ""}</div>
${j.tail && j.tail.length ? `<div class="meta">${esc(j.tail[j.tail.length - 1])}</div>` : ""}
</div>`).join("") || `<p class="meta">No jobs reported yet.</p>`;
} catch (e) {
$("#seen").textContent = "agent unreachable";
}
}
async function ask(question) {
$("#answer").textContent = "Thinking…";
try {
const r = await fetch("/api/ask", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }) });
const d = await r.json();
$("#answer").textContent = d.answer || d.error;
} catch (e) {
$("#answer").textContent = "Couldn't reach the agent. Is Tailscale on for this phone?";
}
}
$("#ask").addEventListener("submit", (e) => { e.preventDefault(); const q = $("#q").value.trim(); if (q) ask(q); });
document.querySelectorAll(".chips button").forEach((b) => b.addEventListener("click", () => ask(b.textContent)));
refresh(); setInterval(refresh, 15000);
</script>
</body>
</html>Copy agent.py, phone.html and the two files below to ~/babysitter on the Pi (Windows has scp built in: scp -r C:\babysitter-pi\* siri@agent-hub.local:~/babysitter/).
ANTHROPIC_API_KEY=sk-ant-...
DESKTOP_URL=http://siri-desktop:8081
BABYSITTER_TZ=America/Toronto
# NTFY_URL=https://ntfy.sh/your-long-random-topic[Unit]
Description=Desktop Babysitter agent
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
User=siri
WorkingDirectory=/home/siri/babysitter
EnvironmentFile=/home/siri/babysitter/.env
ExecStart=/home/siri/babysitter/.venv/bin/python agent.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetcd ~/babysitter
python3 -m venv .venv
.venv/bin/pip install anthropic
nano .env # paste the .env contents above with your API key
chmod 600 .env
sudo cp babysitter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now babysitter
journalctl -u babysitter -f # watch it start polling the desktopThe agent listens on 127.0.0.1 only. tailscale serve puts it on the tailnet at an HTTPS address with a real certificate that Tailscale issues and renews. It also adds a Tailscale-User-Login header to each request from a person's device, which is how the audit log records who asked what without any login code in the agent.
sudo tailscale serve --bg 8080
tailscale serve status # shows https://agent-hub.your-tailnet.ts.netIf HTTPS certificates aren't enabled for your tailnet yet, the command prints a link to turn them on.
https://agent-hub.your-tailnet.ts.net in Safari.Job cards refresh every 15 seconds. Questions go to the Pi, the Pi adds the current snapshot and asks Claude, and the answer comes back in a sentence or two. The phone never holds an API key and never talks to the desktop.
The agent can send a push notification when a job stops running. Set NTFY_URL in .env to an ntfy topic and subscribe to it in the ntfy iOS app. The public ntfy.sh server is the quickest start, but it sees each message's text, so use a long random topic name and keep messages short. Self-hosting ntfy on the Pi keeps notification content on the tailnet; that's a good second-week extension.
Run through these once everything is up. Each one checks a specific claim this guide makes.
py C:\babysitter\watch.py run --name "Test" -- ping -n 60 1.1.1.1. Within 20 seconds the phone shows a running card. workshttp://siri-desktop:8081/jobs. blockedhttp://<desktop LAN IP>:8081/jobs. blockedautogroup:member reach tag:desktop-jobs:8081 and save. rejected by teststail ~/babysitter/audit.log shows each question with your login next to it. worksThese are the choices I'd walk an engineering or security lead through, because they're the same choices their teams make when they wire agents into real systems.
The policy lists two paths. Everything else is denied, and the tests block turns "the phone can't reach the desktop" from a belief into an assertion that runs on every change.
My phone can reach one port on one device. The agent is the only thing that can see the desktop, and what it sees is a read-only JSON summary, not a shell, not Remote Desktop, not the file system.
The Claude API key lives in a chmod 600 file on the Pi. The phone and desktop never see it, so rotating it is one edit. Aperture is Tailscale's productized version of this idea for teams: agents go through a gateway instead of carrying credentials.
The agent binds to loopback and the desktop server binds to its Tailscale address. Nothing is published on the LAN or the internet, and there are no router port forwards anywhere in this build.
If the Pi were compromised, the attacker would get job names, durations and the last 15 lines of output, over one port. The desktop can't initiate a connection to anything on the tailnet, so it can't be used as a stepping stone either.