Case study: a human-in-the-loop workflow engine
The pattern I built for my own job search, generalised into a small engine where the AI proposes and a person decides.
On this page
The problem
Language models are good at rewording and bad at knowing when to stop. Asked to tailor a résumé, a model will happily fill gaps with plausible experience that never happened. In a résumé, one invented line is a real liability — and it's exactly the kind of error that's hard to spot in fluent prose.
The pattern
My own pipeline runs on three rules, and the engine makes them structural rather than a matter of discipline:
- Nothing auto-promotes. Anything the model proposes stays a candidate until a person approves it.
- Every proposal carries its reasoning. Each step stores a rationale next to its output, so a decision can always be traced to what produced it.
- The record only grows. Every action is appended to an audit log that can't be edited after the fact.
The example workflow
The demo doesn't use a stand-in domain: it runs the same four steps as the pipeline I use for my own applications, rebuilt as a web app. Visitors pick one of three fictional candidates (a nurse, a warehouse operations lead and a high school teacher), then one of two sample job descriptions for that candidate, and play the candidate. Two of the four steps stop and wait for them.
Scroll sideways to see the whole diagram.
How it fits together
Everything runs in one Next.js app, with Postgres for workflow state. Visitors never call the model: every click is a server action that applies one engine transition and saves it, and the only code that talks to the model sits behind a cache that is filled once per persona and job.
Scroll sideways to see the whole diagram.
Design decisions
Definitions are versioned; runs pin a version. Workflow definitions and their steps are stored separately from runs. A run records the definition version it started on, so changing a workflow never alters one already in flight.
Current state and history live in different tables. Each step run holds both what the model proposed and what was actually approved — they differ whenever a reviewer edits. The audit log is a separate, append-only table, so the trail can only be added to, never rewritten.
Gated steps halt the engine. An automated step runs and advances immediately. A gated step writes its proposal, marks the run as awaiting approval, and stops. Nothing downstream runs until a person acts, and an edited approval feeds the edited value forward.
Decisions are applied by code, not by the model. The résumé draft is generated once per job description against every possible addition and every piece of experience the candidate left off their CV, with each bullet tagged with the entries it relies on. A visitor's answers then filter that draft in plain code: bullets relying on anything they rejected or called a genuine gap are dropped, with no further model call. The same answers always produce the same résumé, and the model never sees them.
The engine does no I/O. It takes a run's state and a request and returns the next state with its audit entries. Saving, loading and calling the model happen around it, so every rule about what a reviewer may do is tested without a database or a model.
Nothing unchecked is cached. Before a proposal is cached, the code checks that every quote really appears in the CV or job description and every ID it cites exists. A failed check counts as a failed attempt, and three failed attempts stop that step rather than show a visitor something unsupported.
Every mutation is small and named. Starting a run, approving, editing-then-approving and stopping a run are each their own server action and their own audit entry, so the API mirrors the log.
Cost and safety, by construction
A public demo that calls a language model has two obvious risks: runaway spend, and visitors typing instructions meant to hijack the model. Rather than defend against both, the demo removes them.
- Visitors choose from a fixed set of candidates and sample job descriptions, and answer with buttons, so the server receives IDs and choices. The only text they can type (an edited bullet, or a reason for stopping) is stored but never sent to the model — so there's nothing to inject.
- The first visitor to pick a sample triggers a real model call; the result is cached. Everyone after that gets their own fresh run, their own approvals and their own audit trail, seeded from the cached proposal.
- Total model spend is therefore bounded in advance: one call per candidate's CV and one per gated step per job description, 15 calls for the whole demo. A hard monthly spend cap on the API key stays on anyway, as a backstop against a caching bug.
The honest trade-off: you can't try it on a job you actually care about, and once the cache is warm, every visitor sees the same proposals. That's the price of keeping it small and safe.
Stack
Next.js and TypeScript on Vercel, with Postgres on Neon for workflow state and the audit log. One framework for the interface and API, one hosting platform, and no running cost at portfolio scale — chosen so a single person can keep it running.
I designed the engine and built it with Claude Code as the coding agent, applying system design, scoping and critical review to every change. The product design keeps the people using it in mind throughout: every step explains its reasoning, and the visitor stays in control of every decision.
Use it yourself
The pipeline the demo is based on is a public repo you can use for your own job search: the AI skills and the folders they work in, run with Claude or another AI assistant on your own private copy of the repo. You seed it from your own CV, résumé or a printout of your LinkedIn profile, and your data stays in your own files.
You don't need to be technical. My step-by-step guide is written for people who've never used GitHub: no coding and no command line, with example prompts to copy at every step.