I'm Dan Richardson. I ship whole products. I want to build the next one with your team.
Senior software engineer, 30 years across regulated industries. Since January I've
shipped native iOS and Android apps, edge backends, and .NET and TypeScript services
to production by directing AI agents through specs, architecture, and review. That
taught me how to guide developers - human, agentic, or hybrid. Agents take the
drudgery. The architecture no one's built yet is the part I want, and the part I
want a team for.
Available now for senior software, AI / applied-AI, and forward-deployed engineering
roles. Remote, hybrid, or on-site in Portland.
The differentiator
How I work
Most people chasing AI roles have the AI. Fewer have thirty years of walking into a
domain cold and shipping.
01
Specs first, then agents
I write the architecture and the specs, direct AI agents through implementation, and review everything that ships. Not vibe-coding. Software I put my name on.
02
Cost is a design constraint
I ran a statistical study of agent performance against token cost, model, and prompt. The cheap model does the trivial work; the expensive one shows up only when it earns its keep.
03
I ramp fast
Water reservoirs, flight routing, breath analysis, logistics, clinical labs. Three decades of dropping into a domain, talking to the people with the problem, and turning it into something that works.
M.Eng., Civil Engineering - Portland State University ·
B.S., Mechanical Engineering - Rochester Institute of Technology ·
Graduate study, Industrial Engineering - Penn State
Proof, not claims
Recent projects
Everything here was built with an agentic development pipeline I designed.
Filter by what you care about, or see everything.
Mobile / Cloud / AI
Rejog Stash
Neighbors want to lend and borrow without spinning up accounts or trusting a middleman.
Built: Native iOS and Android clients plus a Cloudflare Worker, sharing one versioned wire contract rather than a codebase. Offline-first cataloging, photo, voice, and text search that matches on meaning, and a clean one-way opt-in to the web.
Agentic coding is nondeterministic and the bills are unpredictable.
Built: A deterministic .NET runner that drives AI agents through plan, build, test, review, rework, and commit against a ticketing system. Sizes each agent to the task, tracks cost per ticket, and runs any CLI agent I point it at.
Built: A hackathon project extended into a working platform: a public map scored by theft risk and visibility, plus spot management and access control. Runs entirely on Cloudflare's edge.
Built: An Astro-based CMS framework running multiple production sites, including this one. Content collections, Cloudflare deploy, and a mobile PWA editor that publishes from a phone via GitHub.
A portfolio-analysis product needed to be ready for real users.
Built: Infrastructure and UI for launch, plus the financial-correctness core: brokerage import (Schwab / SnapTrade), lot matching and realized gain/loss, a SQLite to Postgres migration, and a self-hosted deploy behind Cloudflare Tunnel and Access. Co-built with another engineer.
Community-solar customers cannot tell whether their utility bill is even correct.
Built: Time-of-use rate modeling split into volumetric and fixed costs, validated against real utility bills, plus two Home Assistant plugins shipped through HACS across 19 releases.
The other four projects on this site are products. This one is the method that built them.
TradeTrack2, Stash, Secure Bike Parking, Solarseed - every one of them leans on the same idea: a contract in the middle that keeps the system honest. Stash has a wire protocol both phones match byte for byte. Bike Parking has an OpenAPI gate that two front ends can’t drift from. TradeTrack2 has a hard interface around the accounting core. Those contracts hold at runtime. This doc is about the contracts that hold at build time - the ones I use to keep coding agents doing the right work and telling me the truth about it.
I spent a week away from screens in one of the most beautiful places in the world - Costa Rica.
We all know getting away is important, but when your coding partner never takes vacation, work/life balance has to be an active choice. After months of constant interaction with AI, I needed time to reset and prepare for what comes next.
So I hung out with a few of these guys and lots of birds. They certainly know how to live. ¡Pura Vida!
Even though I successfully disconnected, I couldn’t stop thinking about agentic automation. Seriously - I was 100% present in my waking hours, but my unconscious mind couldn’t let it go. I was writing prompts in my sleep.
How do you guide agents to work toward what you actually want - consistently and programmatically?
Getting agents to do the right amount of the right work
There’s a specific, recurring failure mode with agents that’s hard to avoid: they follow the path of least resistance, and that path is almost never aligned with what you want. Two distinct personas fall out of it - and a well-instructed agent can avoid both.
The Deferrer. Finds a hard part, decides it’s “out of scope” or “needs human input,” and bounces. The work is half-finished but the agent claims success because nothing crashed. Bare minimum.
The Cowboy. Hits a challenge, decides it must heroically resolve it, and starts writing to repos it shouldn’t touch, hardcoding values to pass tests, or quietly disabling a failing assertion. The lazy version of getting it done.
Both are easier than doing it right, so the agent chooses them.
Why agents don’t do as instructed
Before you can fix the behavior, you have to understand the cause. Agents aren’t strategic - they’re token-motivated machines wielding tools. At any fork, their decision is a function of:
What completion patterns were rewarded during training?
What did the prompt and context say success looks like?
What tools are available to use?
Training rewards completion - being helpful, making the user feel like progress happened. Nothing in that training rewards saying “I cannot finish this Prompt without a schema change in a repo I don’t own; here is a ticket draft.” That behavior is rare in training, so it’s rare in the model. You have to engineer it back in.
Agents also work under a context limit, and from their perspective raising an issue is more expensive than patching code. Raising an issue requires structured output and a clean handoff - and the work ends incomplete. But patch the problem - change a few lines, pass the tests - and they can declare victory. The agent doesn’t feel the cost of the hack. You do, later, when you get burned.
Agents 101 - what they consume
A quick grounding for vocabulary. When an agent runs, it consumes four things.
A system prompt - the governing rules. Lives in CLAUDE.md. Persists across runs.
A Prompt - capital P. Goal, inputs, outputs, acceptance, verification, notes, out_of_scope.
Context - the files, prior conversation, retrieved docs. The agent’s working memory.
Most of this document is about the second one. When I say Prompt I mean the structured schema - not the system prompt (governing rules), not a ticket (a work item in the tracker). The thing the agent reads to know what to do this run.
So what is “done”?
Done is a shape. Spec what done looks like with enough detail and an agent will pattern-match against it and produce something that fits. Like a painting: realism or abstract depends on the detail in the spec.
Agentic work is done when these questions are answered:
What was changed? Files, commits, deploys, records.
What was verified? Tests passed, commands run, outputs observed.
What was discovered? Issues, tickets, open questions.
What was deliberately not done? Out-of-scope items, deferrals with reasoning.
Most of my prompts covered 1 and 2 but were loose on 3 and 4. That was the bug. The agent treated 3 and 4 as optional and skipped them. When I started requiring those outputs, the agent produced them - because completing requirements is exactly what it was trained to do.
The trick was making “raising a flag” structurally identical to “shipping code.” Both produce required outputs. Both are equally valid endings. Neither is a failure mode.
Do that, and you don’t get burned.
Interfaces create predictability
An interface is a contract - what flows in, what flows out, what counts as done. It’s the same discipline that keeps Stash’s three native clients in sync and Secure Bike Parking’s two front ends honest, pointed at the agent instead of the network. Once I started taking the interface seriously, a lot of the Cowboy and Deferrer behavior went away. Not because the agent got smarter, but because the bad paths got harder to take.
The interfaces I started with looked like this:
“Implement the feature described in the issue. Run the tests. Report what you did.”
That’s not a contract - that’s a vibe. Three problems:
Nothing scopes which files the agent can touch.
Nothing defines done in checkable terms.
No place for the agent to say “I hit a wall” without it reading as failure.
What I moved to was closer to this:
“Implement the feature described in this Prompt. The Prompt lists explicit inputs (files you may read), outputs (files you may modify), acceptance criteria (boolean checks), verification commands (with expected outputs), notes, and out-of-scope items. If you cannot satisfy all acceptance criteria using only the listed inputs and outputs, stop and raise an issue. Raising a blocker is a successful completion. Hacking around it is not.”
This scopes what the agent can read and write. It defines done in terms the agent can check itself. It makes “I cannot do this within scope” a valid way to finish. And it labels the Cowboy path as failure - not unwise, actually wrong, the way a failing test is wrong.
I’m not asking the agent to make good calls. I’m making the good call the easy one.
Tickets, not messages
The most impactful change I made was declaring that ticket writing and code creation are equally valid paths to finishing a task.
I used to treat tickets as cleanup. If the agent couldn’t finish, it dropped a PR comment or sent me a message. That looked like raising the issue, but it wasn’t.
A ticket is a thing. Stable ID, status, owner, closure mechanism. You can query it, prioritize it, link it from a commit. It joins the system you plan from. It still exists three weeks later.
A notification or PR comment is ephemeral. No status, no owner, no closure. It scrolls past. It’s not in the backlog when you sit down to plan the next phase. Even if a human reads it and nods, nothing actionable happened.
This matters because the agent can view them as equal cost, but what you end up with is wildly different. A vague ping - “hey, the middleware ordering looks weird, fyi” - lets the agent relieve its discomfort without producing anything actionable. That’s closer to deferral than to raising the issue.
Writing a ticket requires focused attention - title, description, location, severity - and the agent knows that effort is valued because it’s been codified.
So the rule I work from: raising an issue means creating an object in the system. Communication is a separate job. The ticket is the object. The notification points at the ticket. Don’t let one stand in for the other.
I restructured my prompts so every run produces a completion report with a required surfaced_issues field. Each entry isn’t a paragraph for a human - it’s a draft ticket: title, summary, suspected location, severity, evidence. The orchestrator takes those drafts, creates tickets via the tracker’s API, and returns the IDs. The agent’s report references the IDs. The issue now exists as a row in the backlog, not a sentence in a chat log.
Two things happened. The agent stopped being shy about raising issues, because creating a ticket became the path of least resistance for any borderline case - and borderline cases are most cases. And I started accumulating a steady stream of real tickets documenting actual friction in the codebase, not just the friction I happened to notice.
Big team or solo operator, the impact is the same. A message is read once and gone. A ticket lands in the queue someone actually works from. It codifies discipline while keeping a low-friction feel.
The guardrails I ended up with
One layer isn’t enough. Agents route around any single constraint1, so I stacked them.
Tool scope is the biggest. The agent gets only the tools it needs for the Prompt. If the Prompt is about the API layer, it doesn’t get write access to database migrations.2 Most Cowboy behavior is only possible because the agent has tools nobody gated - MCP servers, file system, shell. Scope them per Prompt or per phase. This is the most powerful guardrail because it removes the option entirely. An agent can’t hack around a problem in a directory it can’t see.
Output schema is next. The final output is structured, with required fields - things done, verified, surfaced, deferred. An empty “things verified” field on a Prompt that claimed implementation work is a structural error, not a style choice. You can fail the run on it.
Verification gates sit alongside the schema. The Prompt includes runnable verification commands with expected outputs. The orchestrator runs them, not the agent. If the agent claims completion but the verifier disagrees, the claim doesn’t survive. This kills the whole family of “changed something and declared success without checking” failures.
The explicit out-of-scope clause sounds redundant - why tell the agent what not to do? - but it’s structural, because agents pattern-match on adjacency. See an obvious-looking related fix and they’ll reach for it unless the Prompt named it out of scope. List the temptations preemptively.
The escalation path is what actually happens when the agent raises an issue. A path that goes nowhere produces no behavior change. If it lands somewhere visible - a ticket, a notification, a queue you triage - issue-raising becomes a real output the schema can require. A mechanism nobody reads is worse than no mechanism.
The cost of Cowboy behavior. When the agent overreaches, what happens to that work? In good pipelines it’s caught at review and reverted. In better ones the orchestrator detects scope violations automatically - file changes outside the Prompt’s outputs list - and rejects the run before review. That makes overreach expensive, not a clever shortcut.
1Yes, they route around constraints.2Permissions are a whole other discussion; currently my constraints are at the prompt level.
In practice
Prompts with explicit shape do most of the work. A schema with goal, inputs, outputs, acceptance, verification, notes, and out_of_scope is the foundation. Acceptance criteria are the agent’s self-check. Verification commands are the orchestrator’s external check. Out-of-scope prevents creep. Inputs and outputs are the lane markers. Notes catch what doesn’t fit elsewhere.
The completion report mirrors the Prompt. Each acceptance criterion gets a checkbox state and an evidence line. Each verification command gets its actual output. There’s a surfaced_issues array (possibly empty), a deferred array (possibly empty), and a scope_violations array, which should always be empty - but the field exists so the agent has somewhere to put the ugly truth if there is one. You’d be surprised how often an agent will self-report violations it would otherwise have hidden, when there’s a literal field for it.
Tickets are deliverables, not text. When the agent raises an issue, the orchestrator routes it to the tracker as a real ticket with a real ID. The ID comes back in the report. Raising an issue becomes a hard signal, not a soft one.
Verification fails loud. Commands produce checkable output: specific strings, exit codes, row counts. In interactive Claude Code, the stdout in your terminal is the verification - the agent isn’t fabricating it. For batch pipelines, an external runner parses the output. Either way the result is captured, not self-reported.
Prompt sizing matters more than I expected. Too big and the agent compresses it - skipping the hard parts, overdoing the easy ones. Prompts sized to context map to a single agent run. Bigger than that, I split them. Find the size empirically: watch where quality drops in real runs and pull back. The agent’s self-report on its own capacity isn’t grounded.
Per-phase agent identity. Different phases, different configs. The brainstorm agent can wander; the implementation agent can’t. The implementation agent doesn’t have brainstorm tools. The brainstorm agent doesn’t have write access to the codebase. Mixing them dilutes both.
You don’t need the full orchestrator to get value. The Prompt schema alone - goal, inputs, outputs, acceptance, out-of-scope - plus a structured completion report gets you most of the way. External verification, ticket routing, and scope-violation detection are for automated pipelines. For solo work, the prompt structure plus a human review of the output is 80% of the value.
For a working implementation, see the claude-config-public repo. The prompts, completion reports, and verification flow are live there. The critique that shaped this version came from that repo’s agent reviewing this work, and the repo is being updated to align with what’s here. Two-way street.
Failure patterns I keep running into
These recur often enough that I keep a list.
“I noticed and fixed.” Agent finishes the Prompt, then mentions it also fixed three other things. Even when the fixes are correct, the pattern is corrosive - it teaches the agent that scope is suggestive. Unsanctioned changes are violations, not bonuses.
“Tests were already failing.”3 Agent reports it can’t verify because some unrelated test was broken before it started. Sometimes true, often an excuse. The fix is a baseline check at the start of the run, so any deflection has to be specific.
The disappearing assertion. Agent makes a test pass by weakening the assertion instead of fixing the code. Hard to catch with structural rules alone. Requires review, or a pre-run capture of the test file hash with diffs flagged when the agent touched test logic.
The TODO tomb. Agent litters the diff with TODO comments instead of raising structured issues. TODOs are deferral pretending to be productivity - invisible to the tracker, the team, and your future self. Forbid them in the schema: anything that would have been a TODO becomes a ticket. Add a lint or pre-commit check that fails on # TODO in non-test files. The prompt rule shifts the distribution; the lint catches what slips through.
The mock that ate the production code. Agent gets stuck on an integration and replaces the real call with a mock to pass tests. The mock survives into the merge. Catastrophic and surprisingly common. The only reliable defense is review, scoped diffs, and explicit no-mocks-in-prod-paths rules at the Prompt level.
The confident wrong report. Agent claims completion from its own internal impression rather than verified output. Self-report is not verification. Captured output is.
The Plan Mode override. Agent is told to plan only and starts coding anyway. Mode signals in the system prompt don’t reliably beat training momentum toward action. Only happens in interactive mode. No clean fix yet beyond oversight, but worth knowing so you catch it early.
3This, along with brushing off warnings as “noise,” is an ongoing problem.
A working example
Say the Prompt is “Add rate limiting to the public API endpoints.” You can do that and get results. Here’s the version that actually works:
Goal: Add per-IP rate limiting to the three public endpoints in app/routes/public.pyInputs (read-only): - app/routes/public.py - app/middleware/*.py (read all; do not write) - tests/routes/test_public.py - docs/api-reference.mdOutputs (writeable): - app/middleware/rate_limit.py (new file) - app/routes/public.py (middleware wiring only) - tests/middleware/test_rate_limit.py (new file) - [tickets] any issues surfaced (Plane tickets via tracker API, or draft in completion report)Pre-run baseline (before any changes): $ pytest tests/routes/test_public.py -v capture result; any subsequent failure must be attributed to this diffAcceptance: [ ] Middleware enforces 60 req/min per IP on all three public endpoints [ ] 429 returned with Retry-After header on limit exceeded [ ] Existing public endpoint tests still pass unchanged [ ] New middleware tests cover limit-hit, limit-not-hit, and reset casesVerification: $ pytest tests/middleware/test_rate_limit.py -v expected: exit 0, no FAILED lines $ pytest tests/routes/test_public.py -v expected: exit 0, no FAILED lines $ git diff --name-only | grep tests/routes/ expected: no output (existing test files must not appear in diff)Design decisions: - In-memory rate limiting is acceptable for v1Escalation rules (raise a ticket, do not work around): - If in-memory rate limiting proves insufficient for requirements - If the existing middleware stack has ordering issues - If endpoint logic changes are needed to apply the middleware - If existing tests need their assertions changed to passOut of scope: - Rate limiting on authenticated endpoints (different Prompt) - Distributed rate limiting via Redis (raise a ticket if in-memory is insufficient) - Updating the API docs (separate Prompt in this op)Required completion report: acceptance_criteria: [{criterion, status: pass|fail|blocked, evidence}] verification_results: [{command, actual_output, passed: true|false}] surfaced_issues: [{title, location, severity, description}] # empty list if none scope_violations: [] # files touched outside Outputs; should be empty
Now watch the agent navigate a messy middleware stack. Its options: refactor it (Cowboy, forbidden), silently work around it (Deferrer, leaves things half-done), or raise a ticket and proceed within the constraint. The third path is now the easy path because the Prompt named it.
The agent reports: “completed primary goal; raised issue: middleware ordering needs review, see ticket THR-184.” That’s a successful completion.
Compare the weak Prompt, where the agent picks between three paths with no signal about which is right. The path that looks most like progress wins by default. That’s how you get burned.
Here’s the completion report that comes back:
acceptance_criteria: - criterion: "Middleware enforces 60 req/min per IP on all three public endpoints" status: pass evidence: "rate_limit.py:42 implements per-IP token bucket; applied at app/routes/public.py:18,29,41" - criterion: "429 returned with Retry-After header on limit exceeded" status: pass evidence: "rate_limit.py:67 returns 429 with Retry-After computed from bucket refill time" - criterion: "Existing public endpoint tests still pass unchanged" status: pass evidence: "see verification_results[1]" - criterion: "New middleware tests cover limit-hit, limit-not-hit, and reset cases" status: pass evidence: "test_rate_limit.py: 12 tests, all 3 cases covered"verification_results: - command: "pytest tests/middleware/test_rate_limit.py -v" actual_output: "12 passed in 0.84s; exit 0" passed: true - command: "pytest tests/routes/test_public.py -v" actual_output: "47 passed in 2.31s; exit 0" passed: true - command: "git diff --name-only | grep tests/routes/" actual_output: "" passed: truesurfaced_issues: - title: "Middleware ordering needs review" location: "app/__init__.py:34 (middleware registration order)" severity: medium description: "Auth middleware currently runs before rate limiter; reversing would let rate limits apply to unauth'd traffic too. Worth a separate review. Plane ticket THR-184 created."scope_violations: []
That’s what “raising a ticket is structurally identical to shipping code” looks like in practice. The agent did the work, verified it, and surfaced the adjacent issue as a structured output instead of ignoring it (Deferrer) or fixing it outside scope (Cowboy). Same shape, same dignity, same completion.
Leave footprints, bring back memories
Here’s the thing I kept circling in Costa Rica.
You can’t rely on the agent’s judgment for what should and shouldn’t be done. You can rely on its competence within constraints, its ability to fill required slots, and its tendency to follow incentives.
So build the constraints. Require the slots. Nudge the incentives.
What you actually need isn’t “things look complete.” It’s “the right work was done, the wrong work was not done, and the things I need to know about are visible.” That third part is what makes the rest trustworthy. A pipeline that produces clean diffs and silent gaps is worse than one that produces messier diffs and a steady stream of legitimate tickets - because the first is lying to you and the second is telling you the truth.
Make creating tickets easy. Make duct tape exorbitant. Make deferral expensive. Make the shape of done clear enough that filling it is the only way to succeed. Make it the path of least effort.
The agent does the rest. And when it does, you get the other four projects on this site.
Rejog Stash
Point your camera at a shelf; it catalogs what it sees. Then lend to a friend with one link, no app, no account.
The Problem
You own lots of things and can’t find the one you want, and you readily lend things to friends and invariably forget who had it last. I wanted the friction gone on both ends: catalog what you own, and enable a way to share it without losing track of where it ended up.
What I Built
A personal inventory app with peer-to-peer lending. Photograph a shelf and it becomes a searchable list, each item remembering where you last saw it. Lending is a one-time link, and the borrower needs no app and no account.
Three codebases - native iOS, native Android, and a Cloudflare Worker for the web side - that share no model and no code. What they share is a wire contract: one versioned, additive-only definition of every request and response, owned by the Worker and matched byte for byte by both platforms. iOS is the authority on how a feature is implemented, and Android ports but remains a native look-and-feel. The Worker is the authority on what the network looks like. When they disagree, the contract wins.
Tech Stack
Languages
Swift
Kotlin
TypeScript
JavaScript
HTML/CSS
SQL
Python
Runtime & frameworks
iOS SwiftUI
Android Jetpack Compose/AndroidX
Cloudflare Workers
static browser frontend
CameraX
Data & storage
iOS SwiftData + CloudKit/private DB + Keychain/iCloud Keychain
Capture is camera-first. On iOS, Vision instance segmentation pulls every object out of one frame as its own item, running off the main actor under Swift 6 strict concurrency. Android does the same job with ML Kit subject segmentation off a CameraX feed. Naming routes through an owner-authenticated identify relay so no API key ever rides the wire, with an on-device model as fallback; on-device Foundation Models are where this wants to land, and that path already works against the iOS 27 beta. Search is semantic either way - embeddings plus a synonym map - so “coat” finds your windbreaker.
One Worker (D1, KV, R2) is the meeting point, serving four trust domains from a single codebase: an owner’s phone signing writes with a per-tenant HMAC key, an owner on the web console with a passkey, a borrower with no account authorized purely by holding an unguessable link, and an internal operator. The borrower is the interesting one - identity is possession of the secret, and nothing more.
Privacy is enforced in the code, not in a policy line. On Android, borrowed items live in a physically separate, device-local database that structurally cannot reach an upload path, pinned by a boundary test. Location is read only in the foreground, at the moment of capture, to stamp where an item was last seen - never in the background. Contact details cross between two people only once a loan is actually established, enforced server-side. The privacy policy is the public version of the same commitments.
What Made It Hard
Getting multi-item recognition to run on-device, fast, and off the main thread. Keeping three genuinely native clients honest against one contract with no shared codebase to lean on - the rule is additive and back-compatible only, so a field can be added but never repurposed or removed once it ships. And enforcing privacy structurally instead of as a promise, which costs more up front and is the only version that survives a refactor.
The Throughline
Take the tedium out of a real chore so the tool disappears and you just get the result. The hard engineering - the contract, the on-device vision, the structural privacy - is all in service of “point and done.”
Status
The borrower web app and backend are live at stash.rejog.net. The iOS and Android apps both run on real devices and are in pre-launch testing. Neither is in the App Store or Play Store yet.
Secure Bike Parking
Where you lock your bike in Portland decides whether it’s there when you get back. I turned that street knowledge into a map.
The Problem
Some racks are theft magnets, some are safe. That knowledge lives in riders’ heads, not anywhere you can look it up.
What I Built
A map-driven guide that scores each parking spot by theft risk, visibility, and street-level imagery, and lets riders add spots of their own. Two surfaces: a public map and an admin moderation portal. It started as a two-person, five-hour hackathon build and grew into a roughly 500-commit platform.
Tech Stack
Languages
TypeScript/TSX
SQL
JavaScript
Runtime & frameworks
React 19
Vite
React Router
Cloudflare Pages Functions
Cloudflare Workers
Data & storage
Cloudflare D1
Cloudflare KV
Cloudflare R2
Cloudflare Queues
browser localStorage
Infra & deploy
npm workspaces
Wrangler
Cloudflare Pages preview branches
Worker cron and queue consumer
AI / ML
Anthropic Claude Haiku 4.5 for NL parsing
explanation generation
submitted-photo vision judging
Testing
Vitest
Playwright
Node test runner
TypeScript project references
OpenAPI codegen drift check
Notable libraries
Leaflet
leaflet.markercluster
jose
openapi-typescript
exifr
heic2any
fflate
How It Works
The recommendation pipeline fuses several mismatched sources - Nominatim geocoding, OSM and Overpass spots, PortlandMaps crime data, Google Street View and Mapillary imagery - and scores each one. Claude vision judges the spots; a lighter model parses trip requests in plain language. User submissions run through a human-in-the-loop ingest pipeline. Shared OpenAPI contracts are enforced by a Playwright end-to-end gate on every UI change, so two separate front ends can never drift from the API.
What Made It Hard
Fusing messy, mismatched geodata into one score a rider can trust. Building a moderation pipeline a human can actually review at speed. Keeping a contract-tested API honest across two independent front ends.
The Throughline
A civic tool for my own city: take scattered, hard-won local knowledge and make it usable in ten seconds by anyone. The contract in the middle is what lets it stay honest as it grows.
Status
Live at securebikeparking.org, grown from the hackathon demo into a working platform.
Johnny Solarseed
We built a cabin by hand, then got a $50K quote to add solar. That did not sit right, so I learned to do it myself - and wrote down everything.
The Problem
A grid-tied solar quote runs $30-50K and still shuts off in an outage unless you add another $10K of battery. Meanwhile Oregon, almost by accident, has the most DIY-friendly solar rules in the country: four legal pathways that stack - a homeowner licensing exemption, a 200W safe harbor, permit-exempt detached structures, and a standalone carve-out that skips utility interconnection entirely. Nobody had put all of it in one place.
What I Built
Johnny Solarseed is the umbrella over my solar work: a free educational site, interactive tools, and two open-source Home Assistant plugins. It’s the complete pathway for Oregon homeowners who want to design and build standalone solar on their own property - legally, safely, and by hand. Not a solar company, no paywalls, no sales pitch. There’s a paid consulting option through Throughline for people who want a second set of eyes, but all the information is free.
Tech Stack
Languages
TypeScript/TSX
Astro
MDX
Python
CSS
YAML
Runtime & frameworks
Astro 5 SSR
React 19 islands
Cloudflare Workers runtime
Home Assistant custom integrations (HACS)
Data & storage
Astro Content Collections
browser localStorage/IndexedDB
Home Assistant Store
YAML/JSON rate and schedule configs
Infra & deploy
Cloudflare Pages/Wrangler (site)
HACS distribution (plugins)
GitHub Actions with hassfest/HACS validation (Peak Shaver)
AI / ML
Anthropic Messages API with Claude Sonnet 4 for utility bill and rate-schedule parsing
Testing
Vitest
pytest/pytest-asyncio
Home Assistant mocks
JSON/services.yaml validation
Notable libraries
pdfjs-dist
DOMPurify
yaml/PyYAML
voluptuous
Home Assistant DataUpdateCoordinator/RestoreEntity
How It Works
Four pieces, one loop.
The site. A structured build curriculum that runs in the order the work actually happens: mindset (insulate before you generate), load math, panels, inverter sizing, string design, battery sizing, safe wiring. Beyond design there are worked examples with real costs, sourcing, installation order, and living with solar. Separately, the Guides section gives specific build recipes with shopping lists and wiring diagrams, and the Oregon section lays out the four stacked pathways with ORS citations and the permit process.
The Rate Calculator. PGE’s Schedule 7 TOU rate isn’t three tiers - it’s three tiers plus regulatory adjustments, BPA credits, wildfire surcharges, Energy Trust funding, fixed fees, and taxes. The published rate and the effective rate are different numbers. The calculator breaks every line item apart to the all-in effective rate and exports YAML for the TOU plugin.
The Energy Lab. A virtual version of your house: toggle devices, slide usage, watch the real bill respond. It answers the question most homeowners can’t - what does running my dryer actually cost. Half-finished.
Two Home Assistant plugins, nineteen releases, refined through real Oregon weather. Peak Shaver pulls a solar forecast, simulates the day hour by hour from the battery’s current state of charge, and buys exactly enough cheap off-peak grid energy to carry the peak - not a kilowatt-hour more. TOU Metering makes the calculator’s rates live in Home Assistant as sensors and accumulators held across restarts.
They are not separate tools that share a name. The rate data feeds the energy model, the model sizes the overnight charge, and the charge runs on real batteries at two properties. Here is the whole loop:
The loop the dive asserts in prose. Rate line items become a formula, the formula becomes YAML, the YAML becomes live sensors, the sensors and the forecast set tonight's grid-charge target, the battery runs on it, and what it draws lands back in the meter.
What Made It Hard
Each piece is a different kind of problem, and they all have to feed each other cleanly: the calculator is a data problem, the Energy Lab a simulation problem, the Peak Shaver an optimization problem, the site a communication problem. The hardest is the peak-shave decision itself - locking in the right overnight buy at 3 AM, hours before the peak, with no chance to correct once the window opens.
The Throughline
Domain knowledge, systems thinking, and interactive tooling converging on one question: what does your energy actually cost, and how do you take control of it. The rate data powers the model, the model informs the strategy, the strategy runs on real hardware, and the results become the content that teaches the next person.
Status
Active on all fronts. Site content is comprehensive and growing, the Rate Calculator is live, the Energy Lab is a work-in-progress, and both plugins are actively maintained. Peak Shaver has been running continuously for a year and has resulted in reduced electricity bills of up to 50%.
TradeTrack2
Financial software has no rounding room. I own the part of a portfolio analyzer where being exactly right is the whole job.
The Problem
Brokerage statements are messy and inconsistent. Your true positions, realized gains, and performance only exist if something parses them correctly, every time. Get the accounting wrong and every number downstream is wrong too.
What I Built
TradeTrack2 is an investment-portfolio analyzer I build with one other engineer. I own the financial-correctness core: the parsing and accounting that everything else trusts. Not the whole product - the half where a mistake is unacceptable.
Tech Stack
Languages
C#
Razor
JavaScript
Bicep
PowerShell
Runtime & frameworks
.NET 10
ASP.NET Core
Razor Pages
Blazor Server interactive components
SignalR
Data & storage
EF Core
SQLite portfolio.db
PostgreSQL
Npgsql
ASP.NET Data Protection file-backed secrets
Infra & deploy
Docker/Compose
GitHub Actions
GHCR package images
Azure Container Apps + Bicep/ACR/Key Vault
Cloudflare Worker update feed
AI / ML
OpenRouter
Google GenAI (Gemma/Gemini)
AI Router service
Microsoft Agents AI Workflows
Testing
xUnit v3
FluentAssertions
WebApplicationFactory integration tests
Testcontainers PostgreSQL
coverlet
Node/Bicep deployment contract tests
Notable libraries
SnapTrade.Net
PdfPig
Chart.js
Bootstrap
ExcelFinancialFunctions
How It Works
I own the broker-statement parsers (Fidelity and Schwab, PDF and CSV), a canonical symbol-normalization engine with OCC option formatting, and lot matching with realized gain/loss and holding-period tracking. Market data lives behind a standalone QuoteService the web app can only reach over HTTP, through an interface, with pre-shared-key auth and live health probes - so market data can never bleed into the accounting by accident. I also ran the SQLite-to-Postgres cutover.
What Made It Hard
Correctness with no tolerance: lot matching and realized gains have to be exact, not close. Keeping market data decoupled so the web app never reaches into it directly. Working inside one codebase with another engineer and keeping the seams clean enough that neither of us breaks the other.
The Throughline
This is the collaboration piece. I do not need to own the whole product to do my part well. I took the half where correctness matters most, made it dependable, and drew a hard interface around it so the rest of the system can trust it without knowing how it works.
Status
Active, ongoing collaboration. My contributions concentrate in the parsing, accounting, and market-data boundary.
Latticeflow
AI coding agents are powerful and completely non-deterministic. Latticeflow wraps them in one: a single native binary that drives a ticket through a deterministic plan, implement, review, ship lifecycle - and only pays for an agent on the steps that actually need one. I use it to build almost everything else on this site.
The Problem
My earlier approach drove everything through a persistent chat session that re-read its whole prompt corpus on every action. One 7-ticket chain on TradeTrack2 took four hours and burned about 190 million cache-read tokens - call it $258 in equivalent API tokens if I hadn’t been on a Max plan. Roughly 76% of that was the chat re-reading its own 26k-token corpus on every transition. I was using a chat loop as a state machine and paying the full corpus cost on every step.
No money actually left my account, so that number isn’t a war story about spend. It’s the target: burning tokens is not the achievement, doing the same work on a fraction of them is. And when Anthropic announced they would start charging API rates for piped CLI commands, the rebuild stopped being an optimization and became preparation.
What I Built
Latticeflow, a single-binary command-line tool called build that owns the full lifecycle of a development ticket. Point it at a Plane project, give it a ticket, and it drives that ticket through a deterministic state machine: plan the work, cut a git worktree, implement the changes, run a gate of automated checks, dispatch a review, and ship the result back to the target branch. Every phase either succeeds, fails with a classified exit code, or triggers a bounded rework loop. No daemon, no server, no shared state across invocations - the binary exits at the end of each verb.
Latticeflow replaced Claude-Config, an earlier corpus of markdown slash-commands that drove ticket lifecycles through a live Claude Code chat acting as runtime, state machine, and tool gateway all at once. That corpus shipped real tickets on real projects, TradeTrack2 among them, and it’s the reference implementation behind The Shape of Done. Latticeflow is the public name, build the binary, throughline-build the repo.
C# on .NET 10, compiled to a native-AOT single executable that needs no .NET runtime on the target. Every interaction falls into one of three tiers: state transitions, gates, and Plane writes are plain deterministic code; small scoped decisions are single API calls; plan, implement, and review spawn a worker CLI in an isolated git worktree.
The worker layer is genuinely multi-vendor. Claude Code, Codex, Gemini, and Copilot each sit behind an IWorkerAgent implementation, selectable globally or per phase, each handling its own auth posture - so all LLM cost flows to the operator’s subscription and the orchestrator never touches provider credentials directly.
Between implement and review, a deterministic gate runs the configured checks and proves itself first: a vacuity prover hard-fails any gating check that can’t actually fail, and a control prober re-runs failed checks against the untouched base ref so a broken environment is never blamed on the ticket.
One ticket through Latticeflow: a deterministic spine, a gate that proves itself, and only three phases that ever spawn an agent.
The chain orchestrator drives multi-ticket trees through a post-order traversal with accumulated integration branches - children ship into a parent’s local chain branch, nested parents merge up, and only the outermost chain lands and pushes. Multi-ticket dispatch builds a graph from blocked_by relations and runs level-synchronously with bounded concurrency.
What Made It Hard
Making a non-deterministic tool behave deterministically at the boundaries. Attributing cost per action across four different vendor CLIs. Proving a passing gate actually tested something. And building an interactive transport for Claude Code after Anthropic moved --print usage onto a separate credit allowance: launching Claude under a platform PTY (ConPTY on Windows, a Unix PTY elsewhere), feeding the brief as a file reference, and detecting completion by tailing the persisted transcript once the per-turn Stop hook stopped firing in interactive sessions. Validated green on Windows 11, macOS 26.4 arm64, and Ubuntu 24.04.
The Throughline
This is the tooling underneath everything: I built the system that builds the products. It’s the productized version of the method in The Shape of Done, writing to my self-hosted Plane fork, and it’s the difference between using AI and engineering with it.
Status
Active development, and dogfooded - every ticket in Latticeflow ships through Latticeflow. The four worker agents, the full plan-through-ship pipeline, the chain orchestrator with gate and batch implement, the bootstrap verbs, and the interactive Claude Code transport are all functional and in daily use, with the AOT binary built and tested across all three platforms and roughly 2,200 xUnit tests. A vendor-neutral model client with SSE streaming is built and tested but not yet wired onto a production path. Plane is the sole ticketing backend today; GitHub and Linear remain aspirational.
Plane CE
My whole workflow runs on tickets. I forked open-source Plane, tore out Docker, deployed it fully native on bare metal, and added the features the commercial edition paywalls.
The Problem
I needed project management for my development work but didn’t want another SaaS subscription or someone else’s server holding my data. Plane’s open-source edition was about 80% of what I wanted - good enough to use, not good enough to leave alone. The commercial edition locked features I needed behind a paywall, and the Docker self-hosting path was sluggish and painful to operate.
What I Built
A self-hosted fork of Plane’s Community Edition, pinned at v1.2.3, that I own and run in production with all my real data migrated across from the commercial box. It runs faster, costs nothing per month, and does things the commercial edition charges for - bulk delete and AI-assisted ticket creation among them.
The native deployment is the core of it. The stock path runs everything in Docker under compose. I ran a four-way runtime bake-off - Podman Compose (the painful baseline), a slim native design, a Podman Quadlet hybrid, and full native - and full native won outright. The production box runs PostgreSQL 15, Redis, RabbitMQ, and nginx directly on an LXC container, with the Django backend and Next.js frontend built from source and systemd managing the services. No container runtime in the stack at all. Restart times dropped under three seconds, and moving off Docker raised API throughput by 50% - which matters, because my AI agents hit the REST API constantly and were getting rate-limited under the old setup.
Tech Stack
Languages
Python
TypeScript
Bash
Runtime & frameworks
Django
Next.js
React
Data & storage
PostgreSQL 15
Redis
RabbitMQ
Cloudflare R2
Infra & deploy
LXC / Proxmox
systemd
nginx
Cloudflare Tunnel
Testing
pytest
custom stdlib load-test harness
How It Works
Beyond the runtime swap, I added what I actually needed. Bulk delete - the original reason for forking - went back in. An AI-enhanced ticketing path lets me paste raw text (notes, ideas, bug descriptions) and get back a well-formed, repo-aware ticket, which keeps traceability and documentation consistent without hand-formatting every issue.
The commercial and community editions diverge at the database level. I mapped the gap - one nullable column and three dropped commercial-only columns - into a repeatable migration runbook with a SQL bridge script, proved it on a copy of live data, then test-loaded the real database onto the new box before cutting over. I also modularized the OpenAPI utilities (cutting decorator code roughly 80%) and rebuilt the data export system around a typed, schema-based architecture with CSV, JSON, and XLSX output. A stdlib-only load-testing harness (hammer.py) exercised fetch-all, list, single-read, and write workloads across concurrency ladders to validate the box before I trusted production data to it.
What Made It Hard
Migrating real data without losing or corrupting a row. Adding an API to someone else’s schema without a database migration. And proving the native box had the headroom to run my daily workflow, which is where the load-test harness earned its keep.
The Throughline
This is the ticketing backend the rest of the work runs on. Every project I track and every ticket my agents create flows through it - it’s the system Latticeflow writes to as it builds the other projects on this site. Owning the tool means I shape it around my workflow instead of the reverse.
Status
Live and serving as my primary project management system since the cutover on June 15, 2026, at plane.throughlinetech.net via Cloudflare Tunnel. The old commercial instance is retired and powered off. The migration is proven and repeatable, the box survives reboots cleanly, and the load-test results give me confidence in its headroom. I pull upstream changes selectively and apply fork patches on top.
RemoteDeploy
iOS deployment is broken in a specific, annoying way. RemoteDeploy is a macOS menu-bar app that builds, signs, and serves iOS apps over your Tailscale network, so any iPhone installs a fresh build from Safari in about ten seconds - from anywhere.
The Problem
Getting a fresh build onto a physical iPhone is slow. TestFlight runs 15 to 30 minutes per upload, USB means physically holding the device, and Xcode wireless debugging needs both machines on the same network. I wanted a build on my phone in seconds, from anywhere.
What I Built
An open-source (MIT) macOS menu-bar app - no dock icon, no main window - that wraps xcodebuild to archive and export with ad-hoc signing, then serves the IPA over HTTPS using SwiftNIO.
I opened the repo on March 30, 2026 not with code but with a 697-line spec written as an agent prompt: architecture decisions up front, a list of commandments that read like scar tissue from past projects. The whole app materialized against that spec in a single day across about thirty commits, and went open-source the next day with a README, an app icon, and a signed DMG.
Four days after launch, v2.0 added an iOS companion app, a 20-endpoint REST API, and a progressive web app, with QR-code pairing and Bonjour discovery. From there it grew ticket by ticket: live build-log streaming over WebSocket, Expo and React Native support behind a BuildEngineRouter abstraction, macOS self-deploy (RemoteDeploy deploys itself - quits the running binary, swaps the bundle, relaunches via a trampoline script), auto-renewing Tailscale TLS certificates, push notifications through Prowl, Pushover, or ntfy, and IPA import for pre-built binaries.
Tech Stack
Languages
Swift
TypeScript (Copilot Auto-Keep)
Runtime & frameworks
SwiftUI
SwiftNIO
Infra & deploy
Tailscale (secure tunnel + TLS)
Bonjour / mDNS
LaunchAgent auto-start
Notable libraries
NIOSSL
XcodeGen
Swift Package Manager
os.Logger
VS Code Extension API
How It Works
Every major component is a Swift protocol with a concrete implementation - BuildEngineProtocol, DeployServerProtocol, TailscaleProviderProtocol, CertificateProviding, and about eight more - so the codebase is testable and extensible without touching existing code. It ships as four targets: the menu-bar host, a headless SwiftNIO server in its own process, the iOS companion, and a shared Swift package for models and API types.
The REST API covers the full surface: project CRUD, build trigger and cancel, build and install history, settings, filesystem browsing for project discovery, device pairing and revocation, plus the WebSocket for real-time logs. Auth is bearer tokens stored in the iOS Keychain and hashed with SHA-256 server-side, so the raw token never touches disk on the Mac. A deploy.sh installer builds the release .app, installs to /Applications, and configures a LaunchAgent for auto-start and crash recovery, with no runtime dependency on Xcode or the source tree.
Also in the dev-tooling bucket: Copilot Auto-Keep, a VS Code extension that auto-accepts Copilot agent edits by watching for workspace edit bursts and firing Keep All after a debounce. Same instinct - if the machine can handle it, don’t make me click.
What Made It Hard
Signing and serving an installable IPA correctly, over the air. Streaming build logs live over WebSocket to the phone and the PWA. And keeping the design clean as the surface grew - the protocol-oriented Swift 6 architecture is what let v2.0 bolt on a companion app, a full API, and self-deploy without rewrites.
The Throughline
RemoteDeploy is what happens when I hit a friction point and build through it rather than around it. Write a spec sharp enough to build from, ship it in a day, harden it in public - the same pattern behind Rejog Stash, whose native iOS build is exactly the kind of app this exists to get onto a phone in ten seconds.
Every site in this portfolio - including the one you’re reading - runs on Loomwork. I built it after scaffolding the same Astro project one too many times.
The Problem
I kept building sites. Johnny Solarseed, FlashMAWB, OpenBaseline, this portfolio - each one started the same way: scaffold Astro, set up content collections, configure Cloudflare, build a header and footer, write the same CSS reset, wire the same dark-mode toggle.
After the fourth time the pattern was obvious. What I wanted wasn’t an abstraction exercise, it was an extraction: take the pieces identical across every site and make them a living upstream I can pull from with git merge loomwork/main, not a starter template I copy once and forget.
What I Built
An open-source Astro publishing framework. Content collections with typed schemas, ten built-in themes with dark mode, five page templates, a mobile PWA editor, and zero-config Cloudflare deployment. Fork the repo, edit one config file, push to GitHub, and the site is live in about 30 seconds.
The core decision is a clean split between framework files and site files. Framework files (layouts, components, global CSS, content schemas) are maintained upstream and merge cleanly because you never edit them. Site files (config, site.css, homepage, header, footer, content) are yours, and upstream never touches them. Pulling a framework update into a running site is one command, not an afternoon of conflict resolution. That is the difference between a framework and a starter you forked once and can never update.
Ten themes, each a standalone CSS file that sets custom properties on :root, no PostCSS and no Sass and no compile step. Switch by changing one string in the config. Five templates (Default, Landing, Guide, Tool, Longform) selected from frontmatter. A floating reader-controls panel gives visitors dark mode, font size, content width, table-of-contents style, and zen mode, all persisted in localStorage. And a PWA editor at /mobile that reads and writes MDX through the GitHub REST API from your phone, with credentials encrypted at rest via AES-GCM.
Tech Stack
Languages
TypeScript
Runtime & frameworks
Astro 5
MDX
React
Data & storage
IndexedDB (offline drafts)
localStorage (reader preferences)
Infra & deploy
Cloudflare Pages
GitHub REST API
Notable libraries
Zod (schema validation)
DOMPurify (XSS)
Web Crypto API (PBKDF2 + AES-GCM)
How It Works
The CSS cascade is deliberate. global.css sets defaults at :where(:root) (zero specificity), theme CSS loads at normal :root, and site.css loads last and wins by source order, so a site author can override anything without fighting specificity.
Theme loading uses an inline document.write() in <head>: a script reads the saved theme from localStorage, resolves the file from a registry baked into the page, and emits the <link> synchronously. The browser treats that output as render-blocking, so the page never paints with the wrong theme, not even briefly, with a <noscript> fallback for JS-disabled users.
Content is Zod-validated. The build fails if frontmatter doesn’t match the schema, so broken metadata never ships to production.
Loomwork's fork model: upstream owns the framework half, you own the site half.
What Made It Hard
Getting the framework/site split clean enough that upstream merges never conflict. Guaranteeing zero flash of the wrong theme without a build step. And passing a full security audit on a tool that stores GitHub credentials in the browser and loads user-supplied theme and font URLs - sixteen of eighteen findings resolved, zero critical or high remaining.
The Throughline
Loomwork is the layer underneath everything else here. Every site I build exercises it, and every pain point feeds back in - the theme-switching bug I hit on this very portfolio became a framework fix. It is the clearest example of a solo engineer shipping and maintaining a real open-source tool by building it for actual use, not hypothetical users.
Status
Active and in production. Loomwork 2.1 powers every site in this portfolio plus external sites on the framework. The mobile editor is temporarily disabled while security hardening wraps up; all code-level fixes are complete and re-enabling is the next step. Near-term roadmap: Playwright E2E tests, GitHub Actions CI, visual regression testing. The repo, a demo site, and a verification site are all live.
The best ideas arrive at the worst times. Driving, walking, halfway through a conversation. By the time you’re at a keyboard, the specificity is gone. You remember you had an insight. You don’t remember the insight.
At the same time, we were drowning in context on the other end. Nine active projects, each with its own state, its own next steps, its own pile of half-formed ideas. Paper didn’t scale. Project management tools added friction. Every morning started with twenty minutes of “where were we?”
Two related problems: capturing a thought before it’s gone, and turning a pile of captured thoughts into something organized enough to act on.
Rejog: Voice-First Personal Context
Rejog is a personal context engine. You speak. It captures. Later, you search - not for exact words, but for meaning. “What was that thing I said about the energy stuff?” actually works.
It spans multiple platforms, multiple languages, and more repositories than it probably should. The capture surface is fast enough that there’s no friction. The retrieval surface is smart enough that you find things you forgot you said.
Text capture has a floor: the speed of typing plus the overhead of opening an app, finding the right note, positioning the cursor. Voice removes all of that. The fastest way to capture a thought is to say it. The hard part was never recording. The hard part was retrieval. Making voice-captured content as findable as typed content is the actual problem worth solving.
The Process: A Personal Operating System
The Process is a daily system that runs on what Rejog captures. Every morning, everything gets dumped into a raw input: what happened yesterday, what’s blocked, what’s exciting. An AI pipeline processes it, extracts the actionable pieces, organizes them by project, and produces a structured daily brief.
The output isn’t a todo list. It’s a context restoration. When we sit down to work on any project, the brief tells us exactly where we left off, what the next move is, and what we were worried about.
The key insight: we don’t need to be organized when we write. We just need to write. The system handles the organizing. That removes the friction that kills every productivity system we’ve ever tried. When we type “peak shaver is throwing an error on the coordinator test and I think it’s because the mock isn’t returning the right forecast data, also I need to reply to that contractor about the inverter spec sheet,” the system knows to file the first part under Johnny Solarseed and the second under a separate follow-up. We don’t have to think about categorization.
The Throughline
These two are the invisible infrastructure behind everything else on this site. Rejog is the capture layer; The Process is the organizing layer that runs on what Rejog captures. Every deep dive here was informed by Process output, and every piece of context The Process organizes started as something said out loud and caught by Rejog. Project decisions get made because a passing thought got indexed instead of forgotten.
Current Status
Private, active, and something we use every day. Both are the kind of project where the value compounds over time: the longer you use them, the more context they have, the more useful they get. Neither is something we plan to release, but together they shape how we work on everything else.
The Image Depot
The Problem
Everyone’s photo library is a disaster. Ours certainly is. Twenty-five years of digital cameras, phone upgrades, cloud syncs, kids, friends sharing albums, a DSLR that dumps raw files into a folder called DCIM, and iCloud doing whatever iCloud decides to do. At some point we stopped being able to answer basic questions like “which kid’s birthday party was that?” and “are those even my kids?”
Our library currently lives on Zenfolio, which worked great - until the volume and chaos overwhelmed us. Photos arrive from too many directions: shared albums from friends, iCloud sync, local imports from the camera, screenshots, school photos emailed as PDFs for some reason. No single platform handles all of that well, and the organizational work we’ve done is locked inside whichever service we happened to use that year.
The Idea
The Image Depot is the system we want to build: self-hosted photo management focused on a single principle - the organizational work should belong to you, not to a platform.
Import from anywhere. Tag, curate, organize into collections. The metadata stays with the images in standard formats. If you move to a different system five years from now, you take everything with you - not just the pixels, but the context.
The Hard Problem
The real challenge in photo management isn’t storage or display - it’s the pipeline. Photos arrive from different sources in different formats with inconsistent metadata. Some have GPS coordinates. Some have EXIF dates that are wrong because the camera clock wasn’t set. Some are duplicates from three different backup strategies. Some are screenshots that shouldn’t be in the library at all.
A proper import pipeline normalizes all of this: deduplication, metadata extraction, date correction, format normalization. By the time a photo lands in the library, it’s clean. That upfront investment in data quality pays off every time you search.
Why Self-Hosted
Control and longevity. We’ve already been through cloud photo service shutdowns and terms-of-service changes that made us nervous. A self-hosted system on hardware we own means the only thing that can deprecate our photo library is us.
The trade-off is real: no AI-powered face recognition out of the box, no automatic “memories” features, no seamless phone sync. But those are features we can add on our own terms, with our own data, without a service agreement that changes annually.
Why Now (Almost)
Our hind brain has been chewing on this problem for a very long time. The difference now is that AI is finally at a place where we can implement some of our most ambitious ideas - smart tagging, natural-language search across decades of photos, automatic event grouping - without spending years learning every API and JS framework first. The technical barrier between “we know exactly what this should do” and “it actually does it” has collapsed.
The Throughline
This is one of the oldest projects in the portfolio - the problem statement hasn’t changed since we got our first digital camera. The constant is the conviction that photo organization is too personal and too long-lived to delegate to a platform.
Current Status
In the backlog, but actively designed. This is a project I want to do right - not rush. The architecture is thought through, the problem space is deeply understood, and AI has made the implementation timeline realistic. It’s next in line when I have the time to give it the attention it deserves.
Let's talk
Get in touch
Open to senior software, applied-AI, and forward-deployed engineering roles.
Email is the fastest way to reach me.