Turning field photos into human-reviewed structured records, without an app store or an ERP integration
A small pattern for a common problem: someone doing physical work — receiving a delivery, inspecting a site, logging inventory — needs to turn what they just saw into a record in some system of truth. They have a phone. They do not have a keyboard, a free hand, or the patience for a form.
This describes a working setup built for exactly that kind of team: a couple of people, one phone, no IT department. It is not a product. It is the shape of a solution, written down so the shape is easy to steal.
- A physical event happens (goods arrive, a shelf gets checked, a job gets finished) and someone needs to capture evidence of it — usually photos.
- That evidence eventually needs to become a structured record somewhere: a spreadsheet row, an invoice, a database entry.
- The person capturing the evidence is standing up, hands often full, and is not going to type that structure in by hand while it's happening.
- The team is too small to justify a real integration project, and too close to the real consequences of the data to hand the whole pipeline to an AI and hope for the best.
Most write-ups about "AI automating data entry" skip straight to step 4 and assume the AI is trusted to finish the job. That assumption is the expensive part. The rest of this pattern is what changes if you don't make it.
phone capture server draft worker a human
(installed PWA) (flat files, no DB) (vision-capable LLM) (in the same app)
─────────────── ┌───────────────────────┐ ┌───────────────────┐ ┌───────────────┐
take photos ───► │ POST /batches │ │ picks up new │ │ reads the │
add a note │ writes photos + a │───►│ batches, looks │───►│ draft next to │
submit │ small JSON record, │ │ at the photos, │ │ the photos, │
│ status: "submitted" │ │ writes a DRAFT │ │ approves or │
◄─────────────────── │ │◄───│ description, never│ │ corrects it │
sees status change └───────────────────────┘ │ touches the real │ └───────┬───────┘
+ the draft text │ system of record │ │
└───────────────────┘ ▼
(your system of
record — outside
this pattern,
on purpose)
Four pieces, deliberately kept separate:
- Capture — a phone-installable web app whose only job is "take photos, add a short note, send it." No accounts to create, no fields to fill in.
- A flat, boring backend — a single-file HTTP server writing plain JSON and image files to disk. No database, no framework, no build step.
- A draft worker — a separate, slower process that looks at new batches on its own schedule and asks a vision-capable model to describe what it sees, honestly, including what it isn't sure about.
- A human, in the loop, in the same app — the draft shows up next to the photos that produced it. Nothing downstream happens until someone looks at both and agrees.
None of this is novel technology. The value is in where the seams are.
The worker's entire system prompt is built around one instruction: say what you're not sure about, out loud, instead of guessing. A blurry photo should produce "count unclear, please verify" — not a confident, wrong number. The output is stored as a draft, attached to a pending review status, and nothing else in the system treats that draft as ground truth.
This sounds like restraint. It's actually what makes the rest of the system trustworthy enough to leave running unattended — the failure mode of a bad draft is "a person has to look twice," not "the ledger is wrong."
The capture tool is a PWA: a manifest, a service worker, a handful of generated icons. "Add to Home Screen" gives it an icon, a launch screen, and an offline-capable shell — everything a two-person team actually needs from "an app." A native build buys app-store distribution and push notifications, neither of which this problem has a use for.
The one sharp edge: a service worker's install step usually wants to pre-cache the whole app shell with cache.addAll(), and that fetch runs from the service worker's own context, not the page's. If your auth model gates the shell too, that fetch can silently 401 and the install never completes — the app just quietly never becomes installable, with no error a user would ever see. The fix is a boundary, not a workaround:
// Protect the data, not the shell. The shell is just markup and CSS;
// it was never the secret. The data behind it is.
function needsAuth(pathname) {
return pathname.startsWith('/api/') || pathname.startsWith('/photos/');
}Once the shell is unconditionally public, cache.addAll() has nothing to fail on, and the install path stops depending on how reliably a given browser engine forwards cached credentials into a service-worker-initiated fetch — which, it turns out, is not reliably at all.
The capture server's whole job is "accept a photo fast." The draft worker's job involves a network round-trip to a model API that can take several seconds per batch. Those two things do not belong in the same request handler. Splitting them into two processes — one synchronous and fast, one polling and slow — means a flaky AI call never makes the camera feel broken.
Photos are the expensive thing on disk; the text describing them is nearly free. So the retention policy only ever deletes photo bytes, never the record, and only after that record has actually been through the draft step:
// Free space by deleting the oldest photos that have ALREADY been
// reviewed by the draft step. A record's text (comment, draft, timestamps)
// is kept forever — only the heavy image bytes are ever removed, and only
// once they've served their purpose.
const eligible = records
.filter(r => !r.photosDeleted && isAlreadyDrafted(r.status))
.sort(byOldestFirst);A record that hasn't been looked at yet is exempt from cleanup no matter how old it is. The alternative — a pure "oldest wins" ring buffer — will eventually delete evidence nobody has reviewed yet, purely because it arrived early. That's not a storage policy, that's data loss with a schedule attached.
The whole system runs on plain HTTP inside a private network. For access away from that network, the answer isn't a public server with a login page — it's a private mesh network (Tailscale, Nebula, WireGuard-based tools all fit) between the specific devices that need it. The phone sees the server as if it were local, from anywhere, and nothing about this system is ever reachable from the open internet. For a two-person team this is strictly less infrastructure than standing up a public host, not more.
- It does not write to your real system of record. That step exists, on paper, as "a human approves the draft" — automating that specific step is a separate decision with a much higher bar, made deliberately later and only with the earlier steps already proven trustworthy in practice.
- It is not multi-tenant, not horizontally scaled, and does not pretend to be. Flat JSON files are the right database for a team this size; they stop being the right database well before this pattern stops being useful, and that's fine — outgrow it when you actually do.
- It does not try to replace a real accounting/inventory system. It sits in front of one, producing clean inputs for a human to enter faster.
Anywhere a small team turns physical observation into a record, and the record matters enough that a wrong entry is worse than a slow one:
- goods-received / intake logging for a small warehouse
- condition or inspection photos for a rental, a job site, a fleet vehicle
- receipt or expense capture ahead of manual bookkeeping
- shelf or stock checks for a shop too small for a WMS
The common thread isn't the industry. It's "one or two people, a phone, and a record on the other end that a human should still sign off on."
Plain Node.js (no framework, no npm dependencies in the capture server — built-in http/fs/https only), a vanilla-JS PWA front end, any vision-capable LLM API for the draft step, HTTP Basic Auth scoped to the data routes, and a private mesh network for remote access. Nothing here requires a specific cloud provider or a specific model vendor — the pattern is the point, not the stack.
Built for internal use by a two-person team; shared here as a pattern, not a product. Strip what doesn't fit, keep the seams that do.
MIT — see LICENSE. Steal the pattern, adapt the code snippets freely.