Six systems plus the hard questions, drilled until you can whiteboard them from memory. Every claim is verified against your actual code — including the gaps, so you own them before an interviewer finds them. One 60-minute session a day, out loud.
Press Copy my progress here, open the trainer on the other device, press Import a code and paste. The code carries boxes, due dates and streak.
This is a human-in-the-loop support system: AI drafts every customer reply, but a human approves every send — the AI physically cannot email a customer, because the send function only exists inside the approval route.
Tickets arrive through an n8n webhook into a Next.js and Prisma app. The app retrieves the relevant support policies, then Claude — or GLM, it’s provider-routed — classifies the ticket and drafts a policy-grounded reply. I don’t trust the model: its JSON is validated with Zod, and then a deterministic keyword layer can override its classification upward, so an angry legal-threat ticket can never be silently downgraded to low priority.
A safety scan runs on the draft; a reviewer edits it in the dashboard; and at approval the safety check runs again on the final human-edited text before Resend delivers it. Every step writes an append-only audit event, with cost, latency and token telemetry recorded per AI call.
flowchart LR A["n8n webhook
+ shared secret"] --> B["POST /api/tickets/ingest
Zod validate"] B --> C[("Ticket row
+ audit #1")] C --> D["POST /:id/draft
policy retrieval"] D --> E{"Provider router
Claude / GLM"} E --> F["Trust layer
Zod + keyword override"] F --> G["Safety scan #1"] G --> H["Dashboard
human edits"] H --> I["approve: passcode
+ safety scan #2"] I --> J["Resend send"] C -.-> K[("AuditEvent
append-only + telemetry")] J -.-> K
Why an approval gate instead of just sending?
Liability and trust: this sends to real customers. It’s structural, not policy — the send function is called exclusively inside the approve route, so there is no code path where AI output reaches a customer unreviewed. A human plus a passcode is physically required.
tap to reveal
What happens when the LLM returns garbage?
Three layers. The extractor tolerates code fences and surrounding prose; if JSON parsing or the Zod schema fails, the catch returns a deterministic fallback draft with the route reason recorded — so nothing crashes, and the reviewer always has something to work with. Same path covers missing API keys and provider errors: the app runs with zero credentials.
tap to reveal
Why does the safety check run twice?
Because the human is also a risk. The first scan checks the AI draft; the second runs server-side at approval on the final, human-edited body — a reviewer can introduce unsafe language while editing. A flag at approval returns a 409, writes a safety_flagged audit event, and forces the ticket back to review. That’s genuine defense-in-depth and my favourite detail in the system.
tap to reveal
Why override the model’s classification with keywords?
The model is a suggestion, not an authority, for routing. Deterministic keyword floors can only upgrade — a ticket containing a legal threat can’t be silently classified low-priority by a bad model day. I’ll also volunteer the tradeoff: keyword lists are a maintenance burden and risk overfitting to my eval fixtures — at scale I’d move that to a second cheap classifier plus sampling.
tap to reveal
How do you know what each reply cost?
Every AI call is stamped with provider, model, latency, token counts and estimated cost in USD, stored in the audit event’s metadata. So cost and latency are queryable per ticket without an external APM — you can answer “what did this customer interaction cost us” from the database.
tap to reveal
Where does it break under retry or load? (They will ask.)
Two honest failure points I’d fix first in production. No idempotency: a retried n8n webhook creates a duplicate ticket — I log the execution ID but don’t dedupe on it yet. And approve has no already-sent guard, so a double-click could double-send. The LLM call also runs inside the request handler, blocking the response. The fix is one package: dedupe on execution ID, a status guard on approve, and moving drafting onto a job queue with retries.
tap to reveal
Is the policy retrieval RAG?
No, and I won’t call it that. It’s keyword scoring over a five-policy JSON file, with the cited policy IDs recorded so every draft shows which policy drove it. At five policies, embeddings are overkill; at five hundred I’d move to vector retrieval — the retrieval function is already isolated so that swap is contained.
tap to reveal
What’s the testing story?
Honest answer: an 11-case deterministic eval harness — six classification, five safety — that runs on the fallback rules path, not a unit test suite, and it never exercises live model output or the API routes. I’d call it an eval harness, not tests. My booking center is the opposite: 49 Vitest tests including integration tests against real Postgres. First thing I’d add here is route-level tests and evals against recorded live outputs.
tap to reveal
What’s the auth story?
Single-tenant by design: one shared approval passcode, compared timing-safe — and I’ll volunteer that it has an insecure default if the env var is unset, which I’d remove. Reviewer identity is hardcoded, so there’s no per-reviewer attribution. First production change: real user auth, per-reviewer identity in the audit trail, and RBAC on approve.
tap to reveal
Why only two database tables?
Deliberate at this scale: a Ticket that carries its evolving state, and an append-only AuditEvent — everything else, policies and safety config, is code and JSON. State transitions live in the audit trail rather than a dozen status tables. The moment this went multi-tenant or added reviewers, I’d break out users, orgs and policies into real tables.
tap to reveal
Stack: Next.js 16 · React 19 · Prisma 6 · Zod 4 · Supabase Postgres · Claude Sonnet / GLM · Resend · n8n
This is a lead-intake and booking system for service businesses, and it’s my velocity-with-rigour proof: sixty-eight commits over three days, with forty-nine tests.
Every lead — web form, dashboard, or raw API — funnels through one Zod-validated endpoint. The bare lead is persisted first, so if AI fails I’ve still captured the customer. Then a pluggable provider extracts and scores it: GPT-4o-mini when a key is present — and here’s the part I like — its output is validated against the exact same Zod schema my deterministic rule-based provider satisfies. So garbage model output fails validation and transparently degrades to rules, with the engine recorded on every row, and there’s an integration test proving that end to end.
Extraction, lead update and follow-up draft commit in one transaction. A human approves every message, bookings are transactional with double-booking blocked by a database unique constraint, and every step lands in an automation log plus a CRM pipeline-event history.
flowchart LR A["3 doors: form,
dashboard, API"] --> B["one endpoint
Zod validate"] B --> C[("bare Lead saved
before AI")] C --> D{"Provider factory
gpt-4o-mini / rules"} D --> E["same-schema Zod
check on LLM JSON"] E -->|fail| F["rule-based fallback
engine recorded"] E --> G["merge contacts
form beats AI"] G --> H[["one $transaction:
extraction + lead + draft"]] H --> I["human approves
draft — idempotent"] I --> J["book slot
unique constraint"] H -.-> K[("AutomationLog +
PipelineEvent")]
Why persist the lead before running AI?
Because the customer matters more than the enrichment. If OpenAI is down or returns garbage, I still have their name in the funnel and a lead_received log row. It’s deliberately outside the later transaction — an early write that survives any downstream failure.
tap to reveal
Walk me through the fallback design.
One interface, two providers. The clever bit: the LLM’s JSON output is validated by the exact same Zod schema the rule-based provider is built to satisfy. So a malformed response fails the parse, the catch swaps in the deterministic extractor, and the row records which engine produced it. There’s a unit test feeding it lead_score “not-a-number” and an integration test proving the whole degradation end to end. It also means the app runs with zero AI budget.
tap to reveal
How do you prevent double-booking — really?
Two layers, and I’ll be precise about which one is the guarantee. In the transaction I check the slot is still open — but under true concurrency that check is racy. The actual guarantee is the schema: slot ID is unique on Appointment and start time is unique on slots, so the loser of a race gets a constraint violation. Honest gap: that loser sees a raw Prisma error rather than a friendly “slot taken” message, and booking isn’t idempotent — both on my fix list.
tap to reveal
What was the hardest bug?
Timezones and DST in slot generation. “Today, 9 to 5” has to mean the business’s wall clock, not the server’s — so slots are generated with date-fns-tz, resolving today in the business timezone and converting properly across DST transitions. And there’s a documented performance decision in the code: batched createMany with skipDuplicates keyed on the unique start time, instead of an upsert loop that took 24 to 50 seconds against the Supabase pooler.
tap to reveal
Does it actually send the SMS/email?
No — and I say that before anyone asks. Notifications are staged, never dispatched; the status enum literally has one value. The system is AI drafting plus human approval plus staged messages; Twilio and Resend are the designed next step. I built the send leg in my support desk project, so wiring it is understood work, not unknown work.
tap to reveal
What would a senior engineer criticize?
The CRM state machine isn’t enforced — any stage can move to any stage, so a lead can reach “booked” with no appointment row. The intake endpoint is public with no rate limiting. There’s no auth — the actor is literally “dashboard user”. And my integration tests run against a real shared database, so they can’t run in CI. I know the fixes for all four; they’re scoped, not mysterious.
tap to reveal
68 commits in 3 days — how, honestly?
Claude Code as the pair, me as the architect and reviewer. Small vertical slices, each commit a working increment, tests written alongside — that’s why 49 of them exist. The AI writes fast; the discipline — what to build, what to validate, what to test, what to refuse to ship — is mine. I’m happy to open any commit and explain it.
tap to reveal
Why one endpoint and server actions instead of a REST API?
One write path per concern. Intake is the single API route because external systems post to it; dashboard mutations are three server actions — approve, book, move stage — so business rules live server-side next to the transaction, not scattered across fetch handlers. Ten Prisma models underneath: Lead, LeadExtraction, FollowUpDraft, AvailabilitySlot, Appointment, PipelineEvent, Notification, AutomationLog, plus Business and BusinessHours.
tap to reveal
Stack: Next.js 15 · TypeScript · Prisma 5 · Supabase Postgres · GPT-4o-mini · date-fns-tz · Vitest
This is a self-hosted operations agent, and it’s my MCP story. A Python FastMCP server exposes twenty-four typed tools over stdio to any MCP-capable model. The design principle: the model decides, but the tools own all state and safety — every tool returns a structured ok-or-error result, never an exception, so the agent loop can’t crash on bad input.
Memory is deliberately split: SQLite for structured state — tasks, moods, activity — and an Obsidian-compatible markdown vault for narrative memory. Semantic search stores OpenAI embeddings as float-32 blobs in SQLite and computes cosine similarity in pure Python — full local retrieval with zero vector-database infrastructure.
My favourite piece is bidirectional task sync: every task renders into a markdown file with a block-reference ID, and a ten-minute cron parses ticked checkboxes back into the database. Tick a box in your notes app, and the task closes in SQLite. Cron handles indexing, sweeps and a daily digest; a FastAPI dashboard watches it all, read-only.
flowchart LR M["Any MCP client
model runtime"] <-->|stdio| S["FastMCP server
24 typed tools"] S --> DB[("SQLite
structured state")] S --> V["Markdown vault
narrative memory"] S --> X["Providers via urllib
OpenAI / Exa / Firecrawl"] DB --> T["tasks.md render
with block-ref IDs"] T -->|tick a box| C["Cron plane:
index 15m, sweep 10m,
digest 21:30"] C --> DB C --> V D["FastAPI dashboard
read-only"] -.-> DB D -.-> C
Why MCP instead of a REST API or function calling?
Decoupling. MCP makes the tool layer a standard protocol surface, so any capable model — Claude, GPT, a local model — can drive the same tools without me touching tool code. The runtime decides; the tools do. Schemas come free from typed signatures, and swapping the brain is configuration, not a rewrite.
tap to reveal
Why SQLite plus markdown instead of one database?
Different memory types. Tasks, moods, activity are structured and queryable — SQL. Reflections and long-form notes are narrative — human-editable markdown that opens in Obsidian. A single-file database gives zero-ops portability and backup by copying. The seam between them is the interesting part: the task mirror keeps both worlds in sync.
tap to reveal
How does semantic search work, exactly?
Indexing: a cron diffs the vault by mtime, chunks changed markdown by heading, batch-embeds with text-embedding-3-small, and stores each vector as a packed float-32 blob in a SQLite table. Query: embed the question, full-scan the chunks, unpack each blob, cosine in pure Python, return top-k with an optional path filter. Brute force is the right call for a single-user vault — thousands of chunks, milliseconds. At millions I’d reach for a real index. No numpy, no vector database, nothing to operate.
tap to reveal
Walk me through the task sync.
SQLite is the source of truth. Every task write re-renders an Obsidian-compatible tasks.md where each line ends in a caret-tID block reference — that ID is the join key between the file and the database. A ten-minute cron parses the file, finds ticked checkboxes, closes those task IDs in SQLite, and re-renders. So the same task can be closed by the agent, by SQL, or by ticking a box on your phone’s notes app — and they converge.
tap to reveal
The model chooses file paths — what stops it escaping the vault?
Every vault operation resolves the path and requires it to stay under the vault root — and there’s a test for traversal and absolute paths. Then I’ll volunteer the flaw before they find it: the check is a string-prefix comparison, so a sibling directory sharing the prefix — vault versus vault-secret — would pass. The fix is Path.is_relative_to, one line. Knowing exactly where my own guard is weak is the point of owning the code.
tap to reveal
What breaks on a fresh clone? (Know this cold.)
Three tools shell out to private helper scripts I didn’t open-source — calendar, voice transcription, and the database CLI — so on a bare clone those return structured errors. That’s deployment glue to my personal setup, and the README currently presents them as working capabilities, which I’d correct. What is fully real and tested in the repo: the tool layer, the database layer, the semantic indexer, the digest builder and the dashboard.
tap to reveal
Is the free-form SQL tool actually read-only?
Honestly: it’s a startswith-select check in the MCP wrapper, not database-level enforcement — no read-only pragma on the connection, and it wrongly rejects legitimate WITH queries. It stops accidents, not adversaries. Production fix: open the connection in read-only mode and enforce at the database. I flag this myself because “safety in the wrapper, not the store” is exactly the kind of gap I’d hunt for in review.
tap to reveal
What happens when a scheduled job fails?
Each cron script is independent and idempotent — the indexer diffs by mtime, the sweeps use flags and duplicate checks, the voicenote sweep even skips files under five minutes old to avoid racing a live recording. A failure logs to stderr and exits non-zero without corrupting state; the embedding indexer retries batches three times with backoff. What’s missing is central alerting — right now a silently failing cron is only visible on the dashboard, and I’d add a heartbeat check first.
tap to reveal
Why errors-returned instead of exceptions?
Because the caller is a model mid-loop. A raised exception kills the tool call and can derail the whole agent turn; a structured ok-false with an error message is something the model can read, reason about, and route around — retry, ask the user, pick another tool. The failure mode becomes data instead of a crash.
tap to reveal
The dashboard binds 0.0.0.0 with no auth — defend that.
I won’t fully defend it — it’s a home-lab assumption: trusted LAN, single user. It exposes metrics and logs read-only, but that includes config and cost data, so shipped anywhere real it needs to bind localhost behind a reverse proxy with auth. It’s also Linux-specific — systemd and journalctl — and degrades to blanks elsewhere. Right scope for a personal agent, wrong scope for production, and I know which changes cross that line.
tap to reveal
Stack: Python 3.11 · FastMCP (mcp≥1.2) · FastAPI · SQLite · OpenAI · Exa · Firecrawl · cron/systemd
This is a human-in-the-loop content engine that runs a real football fan page on Instagram — live in production on a VPS under systemd, with an operations runbook and real incidents behind it.
A plain-Python conductor — deliberately no agent framework — polls about thirteen RSS feeds and Reddit every two hours, ranks stories, and pitches me a numbered menu over Telegram. Nothing drafts until I pick; nothing publishes until I tap Post. Six narrow LLM calls do all the language — a premium model owns the brand voice, a cheap one does utility — and everything else is deterministic: ranking, image gates, Pillow compositing, publishing.
Images are never AI-generated — real photos only, verified by a vision model that fails closed, segmented for compositing. And my favourite piece is the publish path: an atomic SQL claim plus a three-way outcome model, so a double-tapped Post or a lost API response can physically never double-post. Two hundred and forty-four offline tests in the production system.
flowchart LR A["Scheduler loop
every 2h, 08:00-23:00"] --> B["~13 RSS + r/soccer
stdlib parse, sha256 dedupe"] B --> C[("SQLite WAL
news + draft state")] C --> D["Rank + diversify
menu of 10 to Telegram"] D --> E{"Human picks
or /post topic"} E --> F["Caption: GLM-5.2 voice
fallback Gemini"] F --> G["Image ladder: real photos
vision gates fail closed"] G --> H{"Gate 1: approve /
edit / skip"} H --> I["Deterministic Pillow
hero compose"] I --> J{"Gate 2:
Post / Cancel"} J --> K["Atomic claim ->
R2 -> Instagram"] K --> L["LIVE permalink
status: posted"]
Why no agent framework?
Deliberate. An earlier version was agent-driven and I replaced it: plain Python owns the workflow, and the LLM is confined to six narrow calls — captions, editorial metadata, edits, banter, reel lines. Language is AI; control flow, ranking, images, layout and publishing are deterministic code. That's what makes it testable offline, cheap to run, and predictable at 2am when it's posting to a real audience.
tap to reveal
How exactly can it never double-post?
Two mechanisms. First, an atomic claim: publish runs UPDATE … SET status='posting' WHERE the row is claimable AND has no Instagram media ID — and only proceeds if exactly one row changed, so concurrent taps lose cleanly. Second, a three-way outcome model: a clean API failure is retryable; but a timeout after submission — where the post might be live — becomes post_unknown and is never auto-retried; a human checks the page and reconciles. Lost responses are the classic double-post cause, and this treats them as their own state. It's all pinned by tests.
tap to reveal
Why real photos only, and how do the image gates work?
Identity safety: this posts about real footballers, and I decided the system must never misrepresent a real person — so generation is off by default and photos are sourced, then verified. The gate stack rejects low resolution, wrong player, stock watermarks, hidden faces, and too-small subjects — and the identity check fails closed: anything but a confident yes is a rejection. Passing photos get segmented for compositing, and Creative-Commons images can't publish without their attribution re-resolved from source.
tap to reveal
What does production actually look like?
A Lightsail VPS, a systemd user unit with Restart=always, secrets in an environment file, and an operations runbook with real traced incidents. The scheduler is in-process — one flock-locked process, no cron daemon — with one honest exception: the Instagram token refresh is an external cron on roughly a 50-day cycle, and it lives outside the repo, which I'd name as my biggest operational single point of failure. Deployment is manual scp plus restart — no containers, no CI/CD delivery. It works, and I know exactly what I'd harden first.
tap to reveal
How do you keep the brand voice consistent?
Model slots and shared constraints. The premium model owns the voice — captions and reel lines — because the voice is the product; a cheap model does utility work like editorial metadata and edits. Both run through one shared persona prompt and one shared banned-word list, so captions and reels can't drift apart — the list bans character-assassination words because the editorial rule is “banter the moment, not the man.”
tap to reveal
Tell me about a real production bug.
Silent caption truncation. The voice model would intermittently return empty or cut-off output, and it traced to a provider quirk: unless you explicitly disable the model's thinking mode, it burns the budget reasoning and returns nothing. Same class of issue on the Gemini side with reasoning effort. The fix is pinned in code and — the important part — pinned by tests, so a provider default change can't silently reintroduce it. Three separate production truncation bugs came down to this.
tap to reveal
What's the weakest part of the system?
Three things I'd name unprompted. The ranking is hand-tuned keyword heuristics — it works but it will age, and it's my thinnest-tested area: four tests, versus twenty-eight on video fetching. A row can strand in 'posting' if the process dies mid-publish after the claim — the design prevents double-posting but there's no automatic reaper, a human resolves it. And production runs on the OS Python while CI tests newer versions — an interpreter mismatch I'd fix with a container. None are mysteries; all are on the list.
tap to reveal
What's the test philosophy?
Offline by construction: 244 pytest tests where every network layer is monkeypatched — the suite needs zero API keys and runs in seconds, so it actually gets run. Live-world checks live separately in a smoke script that validates real keys, live feeds, and the media toolchain before a deploy. Unit determinism and runtime verification are different jobs, so they're different tools.
tap to reveal
Is the public repo the whole system?
No, and I say that first: the public repo is a curated slice showing the architecture — the production system is larger: about twelve thousand lines, 244 tests, plus a Reels subsystem that fetches video, rewrites lines in the brand voice, and composes 9:16 output with ffmpeg. I keep the full system private because it runs a live page with real credentials and a real audience. Happy to walk through any part of it on a call.
tap to reveal
Stack: Python · SQLite WAL · Telegram Bot API · Instagram API · GLM-5.2 + Gemini · BiRefNet (fal.ai) · Pillow · yt-dlp + ffmpeg · Cloudflare R2 · systemd on Lightsail
This is a product-photography engine I built and used for my own store's shots — local-first TypeScript, with a CLI and a React studio driving the exact same functions.
A brief becomes scene plans, and then the part I'm proudest of: a deterministic prompt-lint gate that refuses to spend a cent unless the prompt has a focal length, an f-stop, a light direction and quality, and a photographer citation — and it bans generic AI filler like “cinematic lighting.” Craft the operator might forget, turned into code that always runs, before any money leaves.
Generation is tiered — cheap drafts, premium finals — with scene one anchoring the campaign and fed as a reference into every other scene for visual consistency. Every take is cached by a content hash over model, prompt, size, references and take number — so re-running an unchanged scene is provably zero dollars. A human picks winners; picks append to a taste file, and tagged winners become brand-library references that bias future runs. One hundred and thirty-three tests, with honest per-step cost tracking.
flowchart LR A["Brief / config
validated"] --> B["Look + refs +
library winners"] B --> C["Scene plans
model, size, takes, cost"] C --> D{"LINT GATE
refuses to spend"} D -->|pass| E["Anchor scene 1
fresh / winner / pinned"] E --> F["Other scenes, conc. 3
anchor fed as ref"] F --> G{"Content-hash
cache?"} G -->|hit| H["$0 copy"] G -->|miss| I["Gemini generate
retry, backoff"] I --> J["Advisory critic
never rejects"] H --> K["Atomic run.json vN
gallery + memory"] J --> K K --> L["Human picks winners
taste.md + library"] L -.->|feeds next run| B
Why lint prompts before generating?
Because image generation bills per attempt, and a structurally weak prompt is a guaranteed wasted spend. The linter encodes photography craft as rules — focal length, f-stop, light direction and quality, a named photographer's style — and bans the generic-AI vocabulary that produces generic-AI images. It runs before any API call and throws on errors. Same gate closes the AI-enrichment loop: draft, lint, feed errors back, retry up to three times. Money only moves when the prompt has passed.
tap to reveal
How is a re-run “provably zero dollars”?
The cache key is the content: a SHA-256 over model, image size, prompt, aspect ratio, the hashes of every reference image, the anchor hash, and the take number — and the filename is the key, so there's no index to corrupt. If nothing changed, the hash matches, the bytes copy from cache, and the cost tracker logs it at zero. Change one word of one scene's prompt and only that scene re-bills. It turns iteration — the actual workflow of creative work — into the cheap path.
tap to reveal
How do you keep a whole campaign visually consistent?
Anchoring. Scene one is the campaign anchor — generated fresh, inherited from the parent version's human-picked winner, or pinned explicitly — and its output image is fed as a reference into every other scene's generation. Versions form an inheritance chain, so the working rule is “change one variable, not the campaign.” Plus up to three past tagged winners from the brand library ride along as style references.
tap to reveal
What is the “director”, exactly?
Not code — a two-thousand-line structured knowledge base: photography craft, scene grammars, failure modes, catalogs of photographers and grades. A human or an AI assistant reads it to write briefs; the only automated path is enrichment, where the skill is distilled into a system prompt for a chat model whose draft then has to survive the same lint gate. And I'm straight about the boundary: the public repo ships the skill's structure, while the full research teardowns and fix recipes are proprietary — that's the IP layer.
tap to reveal
Where does it fail badly? (Own this.)
Crash-mid-run. The run manifest is written once at the end, so a process death mid-run leaves a version directory of images with no manifest — invisible to the version resolver, no resume, only the cache softening the re-run. The studio's run registry is in-memory, so a server restart orphans in-flight status. And the filesystem store has no locking — two concurrent runs could compute the same version number. Single-user tool, so these are accepted risks — but I know each fix: write the manifest incrementally, persist the registry, lock the version counter.
tap to reveal
How honest is the cost tracking, really?
Honest mechanics, honest caveat. Every step logs against a per-model, per-size price table, dry runs price a campaign before spending, and cached scenes log zero. The caveat I volunteer: the table is hardcoded — verified against the provider's published pricing on a specific date — and it counts image output only, not input tokens. So it's a faithful tracker of a table that can drift, not a billing API. For a personal tool that's the right trade; for production I'd reconcile against actual invoices.
tap to reveal
Did clients use this? (The truth line.)
Exact framing, never crossed: “I built and used this for my own store's product shots. The demo brands are concept work, not client engagements — the README says so explicitly. I have not run measured client campaigns.” Then pivot to what's real: the engine, the gate, the cache math, the live product site. One overclaim here would poison every verified number in the portfolio — this is the discipline question wearing a sales costume.
tap to reveal
What do the 133 tests actually prove?
Pipeline wiring, not live-API success — and I make that distinction myself. The Gemini SDK is mocked, so the suite proves planning, linting, caching, versioning, selection and memory behave correctly, offline, on Node 20 and 22 in CI. Live generation is verified by use — I bring real output samples. Knowing exactly what your test suite does and doesn't prove is the difference between a number and an argument.
tap to reveal
Stack: TypeScript (strict) · Node · Gemini image models (tiered) · GLM/DeepSeek/OpenAI-compat enrichment · Express + SSE · Vite/React 19 · filesystem store, no DB · CI on Node 20/22
This is a document-to-action workbench: paste text or upload a PDF, DOCX or transcript, and it extracts decisions, risks, questions and action items — with every action item carrying a required supporting quote from the source document.
The contract is typed end to end: the model is pinned to JSON output at temperature 0.1, revalidated with Zod at every boundary, and anything missing an owner or a deadline — or that looks like a duplicate — is flagged needs-review and cannot be approved until a human clears the reasons. Approved tasks export to Notion or Slack with encrypted credentials, a simulator mode when credentials are absent, and an append-only audit trail plus per-run cost and ROI metering.
A nine-case eval harness asserts every quote is an exact substring of the source and that no owners or deadlines are invented — nine of nine passing against DeepSeek. And I'll volunteer the top production to-do myself: that grounding check and the approved-only filter currently live in the eval suite and the client — hardening both into the server path is the first thing I'd ship.
flowchart LR A["Paste / upload
PDF, DOCX, transcript"] --> B["Parse route
12MB cap, per-type parsers"] B --> C["Clean text
one shot, no chunking"] C --> D{"Provider adapter
mock regex / DeepSeek"} D --> E["Zod validation
quote required per item"] E --> F["Tasks + review flags
owner, deadline, duplicate"] F --> G["Evidence panel
quote beside task"] G --> H{"Approval queue
reasons must clear"} H --> I["Export approved
Notion / Slack / simulator"] I --> K[("Audit + ROI
events, cost, minutes saved")] E -.-> L["9-case eval harness
substring grounding"]
How does the evidence system work — really?
Layered, and I present it precisely. The schema makes a supporting quote structurally required on every action item — Zod rejects extractions without one. The prompt demands an exact contiguous substring and forbids invented owners and deadlines. The eval harness asserts the substring property against real sources. What runtime does not do yet is re-verify the quote against the document — a fabricated quote from the model would flow through. That check exists in the evals; promoting it into the live path is my stated top production to-do.
tap to reveal
Where does the approval gate actually live?
Honest answer before anyone finds it: in the client. The UI computes review reasons — missing owner, missing deadline, probable duplicate — and only clears approval when they're resolved, and the export payload filters to approved tasks. But the server export routes accept any schema-valid payload; they don't re-check task status. RBAC still controls who can export, but the approved-only guarantee needs a server-side status check — a small change, and the first one I'd ship.
tap to reveal
Why is the default provider a regex mock?
Deliberate: the whole app runs offline at zero cost — demos need no keys, evals are reproducible, and the provider interface keeps API keys server-only. The real path is DeepSeek in JSON mode at temperature 0.1 with a versioned prompt. And I'm upfront that three other providers named in the database enum aren't implemented — the enum is forward-looking, the adapter supports two.
tap to reveal
Describe the RBAC design.
A permission matrix: five roles — owner, admin, approver, member, viewer — against six permissions like process documents, export tasks, manage integrations, manage members. Every API route calls requirePermission. The caveat I volunteer: in demo mode auth is disabled and everyone is effectively owner — RBAC is only real under Clerk. That's the standard demo-versus-production posture, and I say which mode is which.
tap to reveal
What do the evals cover, and what don't they?
Nine cases across seven categories — core extraction, classification, duplicate detection, long context, messy input, missing context, and safety — run by a custom Node harness against the live server with a real model key. The committed run is nine of nine against DeepSeek, with token counts recorded. What they're not: unit tests. There's one Playwright smoke script and zero unit tests — my rigour went into behavioural evals of the AI layer, and I'd add route-level unit tests first.
tap to reveal
Walk me through the data model.
Twenty-two models, nine enums. The spine: Document with its SourceFile, an ExtractionRun recording provider, tokens, pricing and the raw JSON, then Tasks with confidence scores and a self-relation for duplicates, TaskEvidence holding the quote, ApprovalEvent as append-only audit with before-and-after state, ExportDestination with encrypted config, ExportAttempt with full request-and-response payloads, and UsageMeter plus CostEstimate for ROI. Even the eval cases and results have persisted schema. It's designed as an auditable system of record, not a demo table.
tap to reveal
What happens with a 100-page document?
Today: risk. There's no chunking — the cleaned document goes up in one call capped at four thousand output tokens, so a very long input can truncate or lose items. There's a long-context eval case, but the architecture is single-shot. The fix is chunk-extract-merge with the existing Jaccard dedupe doing the merge — the dedupe machinery is already built, which is why I scoped chunking as a next step rather than a rewrite.
tap to reveal
What would a senior engineer criticize first?
Four things, and I'd name them before they do. The approval gate isn't enforced server-side. Persistence is browser-authoritative — localStorage owns state and syncs wholesale, so the server trusts client timestamps and multi-device use would clobber. The main workbench component is three thousand lines — it needs splitting into a state machine and API hooks. And testing is evals-plus-smoke, no unit layer. All four are scoped fixes, not mysteries — and honestly, knowing the refactor plan for your own monolith is a better interview answer than pretending you don't have one.
tap to reveal
Stack: Next.js 16 · React 19 · Prisma 7 · Postgres · Zod · Clerk · DeepSeek (JSON mode) · Notion API · Slack webhooks · AES-256-GCM secrets · Playwright
“Tell me about yourself.” (60 seconds, know it cold)
Six years running business operations — a regulated appeals desk handling 250 cases a month, e-commerce ops, then IT support at Datto doing 200-plus tickets a month and a 500-user Microsoft 365 migration. In 2023 I founded a short-let property business in London and ran it for three years — 4.8 stars, 77 percent occupancy — and that’s where this started: I began automating my own operations with n8n and Claude, cut guest communication by five-plus hours a week, and realised the automation was the part I loved. So in 2026 I went full-time building production-grade AI systems — approval-gated support automation, lead-response and booking, an MCP agent platform. Now I want to do that inside a team, shipping to real users at real scale.
tap to reveal
“Who were your freelance clients?”
Straight answer, no flinch: “This year I chose to build a portfolio of production-grade systems full-time rather than take client work — depth over billable hours. Before that, my client was my own business for three years; I built and operated its automation with real money on the line. What I don’t have is experience in a team codebase — that’s precisely what I’m applying for.” Never imply clients that don’t exist. One probing follow-up would end the interview.
tap to reveal
“Did AI write this code?”
“Yes — I build with Claude Code, and I’m fluent in that workflow; in 2026 that’s how fast teams ship. What I own is the architecture and the judgment. Ask me why the safety check runs twice in the support desk, or why my LLM output shares a Zod schema with its deterministic fallback, or where the path guard in my agent is weak — I can tell you the flaws in my own systems unprompted, which is the real test of whether someone owns their code.” Then actually name a flaw. Confidence plus honesty beats defensiveness every time.
tap to reveal
“No CS degree, no employed engineering experience — why you?”
“Three reasons. I’ve been the user: six years in operations means I build for the person drowning in tickets, not for the demo. I ship: sixty-eight commits and forty-nine tests in three days is on GitHub. And I already think about what most junior engineers haven’t met yet — approval gates, audit trails, cost telemetry, failure modes — because I built systems where my own money was on the line. What I lack is team process — code review, production on-call — which is fast to learn inside a team and impossible to fake outside one. That trade is in your favour.”
tap to reveal
“Why is the support desk one squashed commit?”
“I iterated privately and squashed for the public release — the repo is the product, not the diary. If you want to see how I actually work, the booking center has the full history: sixty-eight commits over three days, each one a working increment. And I’ll walk through any file in the support desk right now — pick one.” Offering to open code live is the power move; use it.
tap to reveal
“What’s your biggest technical weakness?”
Pick the true one and pair it with evidence of self-awareness: “I’ve never operated under real production traffic — my systems have single-user scale, and my testing is uneven: forty-nine tests on one project, an eleven-case eval harness on another. I know what good looks like; I haven’t lived on-call with it. That’s a large part of why I want an employed role rather than another year solo.” Never answer “I work too hard.”
tap to reveal
“What would you do in your first 30 days?”
“Ship something small end-to-end in the first two weeks — the fastest way to learn a codebase is to change it. Read the system by fixing real tickets. Sit with the people who use what the team builds — that’s the operations instinct. By day thirty I want to own one workflow completely, with an observability or approval story attached, because that’s the shape of value I already know how to deliver.”
tap to reveal
“Salary expectations?”
Contract: “£250 to £350 a day inside IR35, depending on scope.” Perm: “Mid-forties to high-fifties depending on the role and learning curve — at this stage the team and the production experience matter more to me than the last five thousand pounds.” Then stop talking. Don’t negotiate against yourself, don’t apologise for the number, and never give a number lower than the range you stated.
tap to reveal
Progress saves on this device — and syncs across devices once a sync key is set on the web app. Cards go green at box 4+; drill daily until every tab reads full. Sources: verified architecture briefings against the actual repos, 22 July 2026.