Workshop log · Fairfax, Virginia

BuiltbyRiley.

Things I built because I needed them. No products, no pitch. 18 builds that run my house and my work day, and every one lists what broke.

Runs in productionat my house

I'm Taylor Riley. By day I run federal tax development at Taxwell, the company behind TaxAct and Drake Tax, and I'm the department's AI ambassador. That story is on my resume site.

This is the other half. I like figuring out things that seem near impossible, and the place I get to do that without a meeting is a small home lab. Most of it is built with AI coding agents, and a good share of what's here is the tooling that makes agents dependable: shared memory, unattended queues, guardrails, and alarms for the failures that don't announce themselves.

The failures are the useful part, so they get top billing.

The shop

Compute
one Linux mini PC, one Mac mini, two Raspberry Pis
Hosting
Docker, systemd, launchd
Front door
tunnels behind zero-trust login, nothing port-forwarded
Memory
one Markdown vault, shared by every agent
Hours
early mornings and late nights

Darkroom

A phone camera roll is a mix of kids' artwork, paperwork, receipts, screenshots and ordinary photos. Darkroom classifies every image and files it where it belongs.

546files in the backfill, zero errors
3.5 sper image, 0.97 confidence
$2.50a month to run
What broke

A deploy stripped the execute bit from two scripts. One failed 206 times in two hours while the dashboard showed green. Health now comes from asking systemd directly, not from the app's opinion of itself.

How it's built
  • The phone pushes photos over SFTP into a locked-down drop folder. A watcher picks them up and dedupes by content hash.
  • A hosted vision model classifies each image. A local model lost because it fought other workloads for the same hardware. A free-tier model lost because of training rights on family photos.
  • Documents get a four-point perspective warp and go to a self-hosted document manager that owns OCR. Artwork goes to an archive with a nightly cloud copy that uses copy, never sync, so a local delete can't remove the backup.
  • Everything else goes to a 30-day trash with a sidecar file saying why. Nothing is hard-deleted.
  • The dashboard is a static page over a JSON API: a map of where things land, galleries, restore from trash, live settings and a dry-run test bench.
  • Python
  • OpenCV
  • systemd
  • Paperless-ngx
  • rclone
  • Claude vision

Rec Room

Turns recordings from a hardware recorder into searchable notes with real speaker names and action items, and makes the whole pipeline checkable without SSH.

477recordings back to April 2025
<100 mssearch across the archive
3.4 s → 0.5 sperson page, cold
What broke

"Retry later" is only safe when a retry is cheap. One 57-minute recording timed out at the summary step and re-ran the whole GPU pipeline every 15 minutes until it burned the day's API quota.

How it's built
  • Every 15 minutes it fetches new recordings, transcribes them locally with whisper.cpp, diarizes them and matches voices against enrolled speaker embeddings. Audio never goes to a cloud transcription service.
  • Every action item in a summary has to be backed by a verbatim quote. Dropped candidates are logged with the reason.
  • A stage cache keeps audio, transcript and diarization, so a failure at the last step doesn't redo the expensive ones. Failed notes back off from 15 minutes to 6 hours.
  • The dashboard is a stdlib-only Python server: health lamps, a run ledger, an archive reader, full-text search with no embeddings, and playable clips of unknown speakers to assign or split.
  • Python
  • whisper.cpp
  • pyannote
  • launchd
  • SwiftUI
  • Obsidian

Frigate for Apple

Frigate, the open-source camera NVR, has no native Apple app. This is a SwiftUI client for iPhone, iPad and Mac so the cameras don't live in a browser tab.

360p @ 3 fps → 4K @ 25 fpslive view
55×bandwidth cut from a one-word fix
51unit tests
What broke

One wrong query parameter (h= instead of height=) made every wall tile download about 1 MB instead of 18 KB. And the "server fps ceiling" turned out to be my own app's default request parameters.

How it's built
  • A reusable Swift package holds the models, REST client, WebSocket and Keychain storage. Its test fixtures are real API captures with credentials redacted.
  • Live video uses no WebRTC library. The app reads the MSE WebSocket stream, demuxes fragmented MP4 by hand, builds the format description from the codec atom and feeds a hardware-decoded display layer.
  • Camera wall, three fallback modes per camera, a filterable events browser, clip playback, a recordings timeline, a Mac menu-bar tray and multiple server profiles.
  • Auth is layered: an edge-proxy service token on every request, on top of Frigate's own login with automatic re-login.
  • Swift
  • SwiftUI
  • AVFoundation
  • VideoToolbox
  • XcodeGen

Camera brain

Stock camera alerts were noise. This runs object detection locally on seven cameras and sends one useful notification with an AI description instead of five useless ones.

7cameras, all local inference
~140/sreal detection capacity
84 GBfreed by retention tuning
What broke

Notification thumbnails showed the wrong object in 30 of 45 multi-object events, because I assumed two lists shared an index. Published GPU benchmarks were also about 60% optimistic. Measure your own box.

How it's built
  • Frigate in Docker on a mini PC, pulling streams from an existing PoE recorder. Events only, because continuous 4K measured 66 GB per camera per day.
  • Detection runs on the integrated GPU. Four detector instances in parallel, because inference turned out to be latency-bound, not GPU-bound. The GPU was under 20% busy.
  • Cameras that see the same area collapse into one push with a shared cooldown. The AI description quietly replaces the instant "person detected" alert when it lands.
  • Descriptions moved from a cloud model to a local vision model. Metrics and logs feed the same observability stack as everything else.
  • Frigate
  • OpenVINO
  • YOLOv9
  • Home Assistant
  • Ollama
  • Docker

The Brief

A 7 am morning summary is stale by 9. This is a page that answers "what do I need to know right now" at any hour and never needs managing.

247tests, plus a 74-check deployed harness
9time-of-day slices
2pushes a day, hard cap
What broke

The collector stamped a fetch time on failed attempts too. The page said "last good 0 min ago" about a source that had been dead for four days.

How it's built
  • A collector gathers a dozen sources into one state file every 10 minutes. A faster 2-minute pass handles house state and weather alerts in a separate file to avoid lost updates.
  • The page renders on request and picks one of nine time-and-day slices. Each source has its own staleness tolerance, so a dead source shows as stale, not blank.
  • Deliberate non-features: no inputs, no badges, no LLM call on page load, two pushes a day at most, one phone screen per slice.
  • One action is allowed. "Fix it" lists the broken sources, builds a prompt and queues a headless coding agent with a 45-minute wall clock. Device names from the network are sanitized first, as a prompt-injection guard.
  • Python
  • nginx
  • systemd
  • launchd
  • n8n
  • PWA + web push

Night shift

AI coding agents that work a queue of chores overnight, unattended, and leave a record of what they did and what it cost.

297tests across runner and queue
1,095line runner, replacing 159 that never finished a job
$0.44a typical verified run
What broke

Every job failed for five days because the child process was missing one environment variable. Nothing alerted, because the heartbeat said the runner was alive. It never said the jobs were succeeding.

How it's built
  • The queue is a plain Markdown checklist. Top item goes first, four runs a night, between 1 and 4 am. Finished lines move to Done with cost, turn count and a link to the run note. Stuck ones move to "Needs you" with the reason.
  • One hardened runner claims jobs by rename, enforces a 45-minute wall clock, runs one job at a time and sends a heartbeat.
  • Jobs declare provider, model, output path and who to notify. n8n only schedules and enqueues, so I can open a workflow and change it myself.
  • Six scheduled jobs run on it, including drift checks that compare my notes against the machines they describe.
  • Python
  • launchd
  • n8n
  • Claude Code
  • Codex CLI
  • Ollama

Shared brain

Every AI agent I use, on every machine and on my phone, reads and writes the same durable memory instead of forgetting everything when the chat ends.

4,600tokens of memory per session, about 2% of context
15 KB → 3.3 KBrouter index
13MCP tools
What broke

Six MCP tools sat committed but undeployed for weeks. The deploy recipe copied sources to the wrong directory, and the build still succeeded. A script that succeeds and wires up nothing is the worst kind.

How it's built
  • An Obsidian vault is the single source of truth and its sync is the only transport. No rsync, no daemon, no vector store. An earlier vector store was decommissioned.
  • Agents load a thin router index that points at domain indexes, so work context and personal context stay out of each other's way. Note descriptions are the retrieval key.
  • A bootstrap script wires up any new machine with symlinks: bash on macOS, PowerShell on Windows. Code and skills live in git, not in the vault.
  • A TypeScript MCP server behind OAuth gives phone and web assistants 13 tools over the same notes.
  • Obsidian
  • TypeScript
  • MCP
  • bash
  • PowerShell
  • Docker

Folio

Drop a PDF, Office file or Markdown file on a page and get a clean, tagged, linked note in your vault's inbox.

2.3 sfor a 73-page text PDF
~6 minfor a heavy OCR scan
8tag cap, after one note got 14
What broke

When a request chains through several services, each layer's timeout has to be strictly longer the further it is from the work. Otherwise every fix just moves the failure one hop outward.

How it's built
  • Upload, convert, LLM enrichment for title and tags, link grounding, an editable preview, then save.
  • Link grounding is the point. A wikilink only gets created when a candidate exactly matches a real note. The model's own linking is never trusted.
  • Two converters on purpose: one does layout, OCR and tables, the other does fast in-process text extraction. An empty extraction raises an error instead of saving a blank note.
  • A proxy in front was killing long requests, so the UI moved to an async job-and-poll API.
  • Python
  • FastAPI
  • Docling
  • MarkItDown
  • Gemini Flash
  • Docker

Press

Gotenberg is a great self-hosted PDF engine with no interface. Press is the front end: convert, merge, split, archive-grade PDF and watermark.

5tools, one page
0downtime on the rename
1undocumented API quirk found
What broke

The watermark route only accepts numeric options as JSON strings, contrary to its docs, and fails with a silent 400 otherwise. Found by reading requests, not documentation.

How it's built
  • A small backend translates browser uploads into the exact multipart shapes the engine expects.
  • Markdown conversion needs a generated HTML wrapper with a template action in it. The backend hides that.
  • Merge prefixes filenames with a zero-padded index, because the engine merges alphabetically.
  • The engine has no auth and one route fetches arbitrary URLs, so it never faces the internet directly. Scope stayed small on purpose.
  • Python
  • FastAPI
  • Gotenberg 8
  • Docker
Source on GitHub →

PDF diff

Compares two versions of a dense form or legal PDF without the unreadable pileup that overlay diffs produce the moment a line shifts.

4artifacts per comparison
3resolution settings
2interfaces, CLI and web
What broke

An audit told me the repo had two divergent histories. The commit hashes showed one linear history and a single uncommitted change. Check the hashes before trusting the summary.

How it's built
  • Works as a CLI and as a self-hosted web app.
  • Every comparison produces four artifacts: a vector side-by-side PDF with color-coded changes, a classic overlay, a coordinate audit file with bounding boxes and extracted text, and a Markdown diff.
  • Line or block granularity, skip identical pages, and tolerances for anti-aliasing and scanner noise.
  • A sanitizer strips "DRAFT"-style watermarks before comparing, so a watermark change doesn't light up every page.
  • Python
  • PyMuPDF
  • diff-pdf
  • FastAPI
  • Docker

Home base

A private start page that actually knows things (weather, calendar, packages, renewals, news) plus a notes app with search that can answer questions without my notes leaving the house.

457unit and browser tests
22,506chunks indexed, rebuilt in 18 s
26news feeds clustered
What broke

A deploy from a synced working copy nearly regressed production. Deploys are now pull-based and verify the running commit. Also: a mutation test survived because a disabled button, not the guard, was holding the test up.

How it's built
  • The backend opens a personal SQLite database read-only, and I verified that writes through it fail.
  • News comes from a story-clustering engine with local embeddings. The reader endpoint has SSRF guards that are re-checked on every redirect hop.
  • Note saves use ETag and If-Match with atomic writes. Frontmatter edits are byte-preserving, and the visual editor is gated per note by a round-trip probe.
  • Search fuses full-text and local embeddings. The answer layer gives cited answers from a local model and refuses to boot against a non-private endpoint.
  • React
  • TypeScript
  • Fastify
  • SQLite FTS5
  • Ollama
  • Docker

Automation shop

A small automation idea used to cost a repo, a cron entry, a monitor and hand-rolled OAuth, so most never got built. Now it costs a workflow I can open and edit.

16workflows exported nightly
~400 MBidle memory
4guardrails
What broke

An email trigger crash-looped 45 times in a day while the workflow looked healthy. "Fail loudly" covered a workflow being down. It did not cover a trigger quietly looping. I have also already broken my own nothing-load-bearing rule.

How it's built
  • Self-hosted n8n with its own Postgres, with four guardrails learned from an earlier platform that failed: nothing load-bearing, fail loudly, nightly git export behind a secret scan, and don't migrate what works.
  • Shared sub-workflows for notify, write-note and append-todo. The todo one has a read-then-write guard because four things write that file.
  • Built so far: an email switchboard, a package board with deterministic parsers, a tee-time watcher, a school lunch menu to calendar sync that diffs instead of recreating, and receipt staging.
  • n8n
  • Postgres
  • Docker
  • Playwright
  • IMAP

Rain-proof roof

A motorized louvred patio roof that closes itself when it rains and always knows its true position, even though the motor has no open-limit switch.

1trigger, down from five
5 daysbattery life on the original sensors
10+ daysa sensor sat frozen without going offline
What broke

New limit sensors killed a fresh battery in five days. I ruled out bad cells, RF, brownout and firmware. Availability history had the answer: the sensors were dropping and rejoining the mesh ten times as often as an identical control sensor. Swapped radio protocols and the problem ended.

How it's built
  • A Home Assistant cover driven by two toggles, with contact sensors acting as limit switches and a tilt sensor giving mid-travel position with self-calibrated end angles.
  • The rain rule was rewritten down to one trigger: rain rate above zero at my own weather station. Forecast gating, a second station, a leak sensor pretending to be a rain gauge and a retry loop all got deleted.
  • Retries are bounded: three attempts, a minute apart, stopping the moment the roof reports closed.
  • Home Assistant
  • Zigbee2MQTT
  • Thread
  • Jinja
  • Raspberry Pi 5

Freezer watch

Know a fridge or freezer is warming before the food spoils, using cheap probe thermometers that are inaccurate and confused about units.

5probes
−0.4 to 20.5 °Fnormal freezer air swing
59 °Fwhen the old alert would have fired
What broke

A live "alert at 15 °F" rule was quietly comparing a Celsius value. It could only fire at 59 °F, which means it was dead. Also: probes read air and reference thermometers read thermal mass, so calibrate against the long-run mean, never a spot reading.

How it's built
  • Five probes with "too warm for 30 minutes" alerts and door-left-open alerts. The 30-minute guard is what lets alerts survive normal compressor cycling.
  • The Zigbee bridge left off a device class, so temperatures never converted. Hand edits got wiped on the next pairing. The durable fix goes through the bridge's own API.
  • The on-device calibration offset is firmware-capped, so every probe is maxed out and a template sensor carries the rest. Every consumer was repointed to it.
  • Onboarding a new probe is packaged as a repeatable agent skill.
  • Home Assistant
  • Zigbee2MQTT
  • MQTT
  • Jinja

Log alarms

An API key broke and threw errors for twelve hours with no alert, because the service was "up" by every uptime check. Now the logs themselves raise the alarm.

61 → 22containers, trimmed first
9alert rules
1 hwindow, because 10 min would have missed it
What broke

The original incident ran at about eight errors an hour, too slow for a short window to notice. And setting rules to report OK on no data is what separates a useful alert system from one you mute in a week.

How it's built
  • Grafana, Loki, VictoriaMetrics and collectors across a mini PC and a Raspberry Pi, later extended to cron logs, journald and logs from two Macs.
  • Nine provisioned alert rules, routed to my phone by category.
  • VictoriaMetrics over Prometheus for the lighter footprint. Synthetic probing stays with the uptime monitor, so nothing alerts twice.
  • Grafana
  • Loki
  • VictoriaMetrics
  • Alloy
  • cAdvisor

Config history

Nightly git history for every Compose file and hand-written config across the lab, so "what changed?" has an answer.

82files tracked
35stacks across two hosts
3literal secrets moved out before the first push
What broke

It silently stopped for 12 days. The scanner binary wasn't on cron's default PATH, so every run aborted before committing. Anything whose only symptom is missing output needs a staleness check.

How it's built
  • Allowlisted snapshots from two hosts, committed and pushed only on change.
  • Blank .env.example files are generated from variable references, a secret scanner gates every commit, and uncaptured stacks raise a warning.
  • Per-repo deploy keys, so one compromised host can't write every repo. A shared lock stops concurrent export jobs from committing each other's changes.
  • git
  • Gitleaks
  • cron
  • bash
  • Docker Compose

Filament ledger

Know exactly how much of every 3D printer filament is left, without weighing spools.

46spools tracked
42filaments from 7 vendors
200 glow-stock line
What broke

The vendor API returned 403 to Python's default user agent and 200 to curl's. Separately, a sync tool's config generator silently dropped four of five trays.

How it's built
  • Inventory was seeded from purchase receipts, then usage was back-deducted from print history: finished prints at 100%, failed prints at 50%.
  • NFC tags on spools handle multi-material tray assignment.
  • A nightly job mirrors remaining weights into the printer vendor's cloud through an undocumented API, and a low-stock view flags anything under 200 g.
  • A new filament order in my email becomes inventory automatically.
  • Spoolman
  • Python
  • NFC
  • Home Assistant
  • Docker

Network census

A running record of every device that has ever touched the home network, with names I can actually correct.

~115devices tracked
26-41 sfull scan
12new tests
What broke

The first schema keyed rows by IP address, which would have duplicated a device every time its address changed. Re-keyed by device before any history piled up.

How it's built
  • A fork of an open-source Go scanner, adding editable hostname and device-type overrides that survive rescans and follow a device to a new address by MAC.
  • Twelve new Go tests, built natively on a Raspberry Pi.
  • A nightly job exports to a SQLite ledger of every device ever seen and regenerates a notes page from it.
  • Go
  • nmap
  • SQLite
  • Raspberry Pi

Shop rules

  1. Up is not the same as working. A heartbeat proves the runner is alive. It says nothing about whether the jobs succeed. Alert on the output.
  2. Missing output is a symptom. Anything whose only failure mode is silence needs a staleness check.
  3. Never trust the model's links. Let it propose. Ground every reference against something real before it's written down.
  4. Retry only what's cheap. Cache the expensive stages, back off the rest, and cap the attempts.
  5. Timeouts grow outward. Each layer waits strictly longer than the one closer to the work.
  6. Nothing is hard-deleted. Trash with a reason and a clock beats a confident classifier.
  7. Measure your own box. Published benchmarks were 60% optimistic. Re-measure before relitigating a decision.
  8. Build it where I can edit it. If changing an automation needs an agent, it's in the wrong tool.

Index