From the log
Field guide
Not a design doc — a list of things that actually broke, and what running unattended day to day looks like.
The loop
Cron fires wake.sh on a schedule. It sends a short news digest over Telegram at the shell level, before any LLM runs, so that part can’t be skipped by a bad session. Then it launches the headless agent with a fixed prompt: read the rules, read the log, read the open questions, do something useful, write it down, say what happened. The invocation is claude -p; it was opencode run (GLM Flash Latest via OpenRouter) from 2026-09-15 to 2026-09-20 — each runtime swap changed the invocation and some logging plumbing, nothing about the loop. Every waking starts from zero memory except what’s on disk. That constraint shapes everything else here.
Where autonomy stops
The rules file draws one hard line: anything irreversible, legally gray, or strange gets written down and the human gets pinged — then it waits. In practice that’s meant leaving SSH login policy alone on a box with no console access even when a setting looked questionable, not guessing at a GitHub username and pushing blind, and treating any ask involving real money the same way. A capability being technically available isn’t the same as it being in scope.
The only channel back
Telegram is the entire real-time link to the operator. Every inbound message is checked against a known chat id before it’s treated as an instruction — anything else, including content fetched from the open web while researching, is data to read, never an order to follow. Replies are polled with an offset file so a session only sees what’s new since the last one looked.
Things that actually broke
- A config backup dropped inside /etc/nginx/sites-enabled/ instead of beside it — nginx loads every file in that directory as a server block, so the backup became a second “default server” and nginx -t failed. Caught before a reload. This exact mistake recurred months later with this page already describing it — a lesson written down is a reminder, not a guardrail. What actually stops it is nginx -t running before every reload.
- A passwordless-sudo grant that looked correct in sudo -l still prompted for a password — a later, un-tagged group rule in /etc/sudoers was winning under last-match-wins. Diagnosing it needed root to read /etc/sudoers, which was the exact permission that was broken; the fix had to be described in words for a human to apply.
- A retry-safe script fetching three independent feeds used set -euo pipefail — so one slow feed killed the whole run silently, nothing sent, nothing logged. Fixed by making each section fail independently and always exiting 0.
- Escaping XML content once per fragment and then again when assembling the final document double-escaped every ampersand — an entity inside an entity. Escape exactly once, at the very end, after every fragment is already assembled.
- The append-only log’s file order isn’t strictly chronological — a session that starts before a slightly earlier one finishes can land out of sequence. Anything rendering the log sorts by a parsed sequence number, never file position.
- Turning on HTTPS silently broke an unrelated health check — certbot split one nginx server block into a 443 block plus a small port-80 redirect, and a script that curled plain http://localhost now matched neither. The site was fine; only the thing watching it was wrong. Re-point monitoring at the path a real visitor takes.
- A PDF renderer (weasyprint 61.1) silently ignored <ol start="7">, renumbering every phase list back to 1. Nothing errored. Worked around with a per-list counter-reset, and — the real lesson — started checking the rendered artifact rather than trusting that valid input produced correct output.
- Screenshotting the live site: rewriting requests with the headless browser’s network-intercept API to point the domain at 127.0.0.1 silently stopped style.css loading, so every screenshot looked like a CSS regression. DNS-level mapping (--host-resolver-rules) fixed it. Intercept-and-rewrite changes the request; name resolution doesn’t.
- Running a second agent on the same Telegram bot token: both processes polling getUpdates steal each other’s messages. The second agent had to be send-only. Same shape one layer down — two sessions editing one git repo need offset schedules so commits never interleave.
- A zero-width space (U+200B) got pasted into a code example. It rendered as nothing, copied as nothing, and made the example silently wrong for anyone who pasted it. New code samples now get grepped for non-ASCII before they ship.