apps / sports
sports.dancroak.com is a scoreboard and a news reader. I built it with Instinct, a personal AI assistant, using text messages.
What it shows
The front page lists games across the NFL, college football, MLB, the NHL, EPL, and Champions League. Each row shows the teams, the start time or score, and a win probability. The board hides preseason games in every league.
The win probability is the Kalshi market price. I do not run a prediction model. The market price updates when new information arrives.
A green dot marks a live game. A gray dot and "Final" mark a completed game. The whole row links to the game page for a large tap target.
A second page displays sports news. The app ingests RSS feeds into SQLite. The assistant writes article summaries every 30 minutes.
Text messages as the interface
I directed the build over iMessage while the assistant typed. I sent screenshots when a layout looked incorrect. The assistant modified the templates and deployed updates.
I made the product decisions, such as removing clutter and setting tap targets. The assistant made the implementation choices.
The loop covers operations on the same terms. I chose the backup architecture over text: the VM holds the primary repository, and R2 holds the disaster recovery copies. The assistant compared the options and implemented the choice.
A phone interface prevents micromanagement. I cannot inspect code diffs easily on a phone. I inspect the live site and report defects. The assistant identifies the cause and fixes the bug. I review the visible outcome rather than the code change.
What autonomous operation needs
Autonomous operation requires four components:
- Network access. The assistant runs in an ephemeral sandbox. It stores a reusable Tailscale auth key in its vault. It joins my tailnet after each sandbox reset and connects to the VM by hostname.
- Host permissions. The assistant has passwordless
sudoon the VM. It installs packages and restarts services without my intervention. - Persistent state. The sandbox resets every 20 hours. Git on the VM preserves the application code, systemd units, Caddyfile, and crontab. R2 holds off-box copies of the code and the database.
- External revocation. I can revoke access by deleting the Tailscale key, revoking the R2 API token, or terminating the VM. The R2 token accepts requests from the VM addresses only. These controls do not require SSH access to the host.
Who decides the access design
The assistant reaches the VM over Tailscale and authenticates by tailnet identity. No private key sits outside the VM, and no SSH port faces the internet. Each control that removes the assistant lives in a console I hold, not in a file on the host.
An assistant optimizes for the goal I state, and I stated access without me. Containment is the second goal, and it holds only if I state it too. I own the access design for that reason.
The stack
Code runs in three places. The VM serves the site and holds the data. The assistant's sandbox drives the work and keeps nothing, so its disk is scratch and its credentials come from a vault on each wake. External services supply the scores, the market lines, the news, and the off-box backups.
A scheduler wakes the sandbox on a timer. The scrapers that run there drive a remote Chrome session rather than a browser on the box, because a source that needs a real browser also needs cookies the vault holds. The summary worker writes over SSH: it calls the Go binary on the VM, which saves the summary, so the sandbox never touches the database file.

The application uses one Go binary and hml templates. SQLite stores the articles and team metadata. The application opens SQLite in WAL mode on a single connection:
db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=ON")
db.SetMaxOpenConns(1)
The web server and the ingest process write to the same database file. A
single connection prevents internal locks, and _busy_timeout handles write
contention. I explain these database options in sqlite.
systemd runs the application as a template unit on two ports, sports@8081
and sports@8082, each with Restart=always. One port serves at a time.
Caddy terminates TLS and proxies traffic to the port that serves:
sports.dancroak.com {
reverse_proxy 127.0.0.1:8081
}
A deploy flips the upstream between the two ports through Caddy's admin API, so Caddy never restarts.
cron runs the ingest command every 30 minutes. Each job pipes its output
through systemd-cat into journald, so the VM keeps no log files.
The VM
The service runs on an Ubuntu 24.04 VM on Ubicloud for $14 per month. A sandbox that resets cannot host a service that must stay up, so the host is a machine that outlives the assistant that manages it.
The firewall permits ingress on ports 80 and 443 only. SSH access requires Tailscale.
The application uses the pure-Go modernc.org/sqlite driver, so the VM compiles the binary without a C compiler. The driver includes FTS5, which the news search reads.
The VM holds the primary git repository, and no GitHub mirror copies it. A clean ingest, a saved summary, and a git commit each write a gzipped SQLite snapshot and a git bundle to Cloudflare R2, so a commit reaches the bucket in seconds. An hourly cron job is the floor. A small Go CLI signs the S3 requests, so the VM needs no AWS SDK. If the VM fails, I provision a new instance, fetch the most recent bundle and snapshot, clone the bundle, and restore the database.
Operations
The board footer reports feed freshness and backup health, so a failed ingest reads as a failure rather than as a quiet news day. journald holds the job output.
A deploy holds every connection. A blue/green pipeline builds the candidate, starts it on the idle port, checks its health, flips the proxy, and stops the old process. A failed health check leaves the old binary serving. A second Go CLI on the VM runs the pipeline from a TOML config.
The assistant runs the scheduled health checks. I want it to catch the failure I did not think to check for.
Value
The site shows the data I want, with no clutter, for the price of a text message and an inexpensive virtual machine.