A conversational, FSM-driven candidate screening bot built for the Workfully technical
challenge. Chat with the bot, get a structured fit verdict, share it as a public link or
PDF. The whole thing lives behind a workspace shell β sidebar with recent screenings,
dashboard at /, permanent URLs per verdict, βK search.
- Idle β get a greeting and a list of commands.
- Screening β paste / upload a Job Description and a CV, watch the verdict stream in.
- Job Builder β mocked, returns to idle.
Verdicts get a permanent page at /screening/[id], an unguessable public share at
/s/[slug], an Open Graph card image, and a server-rendered Chromium PDF.
A standalone architecture walkthrough lives at /walkthrough β single-scroll, build-time stats, inline verdict gallery using the same share components, sticky scroll-spy TOC. Reachable from the topbar's "About this project" link.
The original challenge brief lives in docs/CHALLENGE.md. The
visual contract lives in DESIGN.md.
pnpm install
pnpm db:up # boots Postgres in Docker
pnpm db:migrate # applies the schema
cp .env.example .env # set OPENROUTER_API_KEY
pnpm dev # http://localhost:3000Fast path β try it in under a minute, no API key, only Docker:
pnpm install && pnpm db:up && pnpm db:migrate
cp .env.example .env # OPENROUTER_API_KEY can stay as-is for fake-AI mode
WORKFULLY_FAKE_AI=1 pnpm devThe fake AI returns deterministic verdicts driven by markers in the CV
([TEST_VERDICT_WEAK], [TEST_VERDICT_WRONG_ROLE], default = strong). Same
codepath that CI runs for E2E.
- FSM as the source of truth. The state machine (
src/lib/fsm/machine.ts) is the only place that decides what's allowed. Server actions translate user input into events; nothing else mutates state. This makes the chat resumable, the transitions exhaustively testable, and/cancelwork the same way from any sub-state. - Pure FSM, request-scoped actor. The screening AI call is modelled as an XState actor that's provided per request, so the machine itself stays pure (no AI imports). Tests use a fake actor that returns a fixture; production wires up the real LLM call (Claude via OpenRouter). Same machine, two contexts.
- Structured output, not string parsing. The screening verdict is generated via
generateObject(orstreamObjectfor streaming) against a Zod schema (src/lib/domain/screening.ts). If the model can't produce schema-valid JSON, the SDK retries automatically and the actor either succeeds with a typed object or throws to the FSM's error path. We never parse free-text. - Streaming verdicts via SSE.
screenStreaming()wrapsstreamObjectand emits partial verdicts through anonPartialcallback. The orchestrator'sdispatchStreaming()threads it through, the route handler at/api/screening/streamreturnstext/event-stream, and<ChatStream>consumes it client-side. Fake-AI mode emits 11 timed partials over ~2.5s so demos show the same shape without burning OpenRouter credits. - Persisted snapshots = resumable conversations. Every transition writes the
XState
PersistedSnapshotto Postgres. A page reload rehydrates the actor at exactly the state it left off in. - Public share by design, private by default. A verdict gets a permanent
/screening/[id]page (private) and an opt-in/s/[slug]page (public). The privacy boundary is enforced by TypeScript: the public path usesgetScreeningForSharewhich returns a narrower type that omits JD, CV, and the conversation log β no accidental leak through a careless render. - One FSM-shaped E2E test, the AI is faked.
WORKFULLY_FAKE_AI=1swaps the realscreen()call for a deterministic stub. CI runs Playwright against this. Real model behavior is covered by the schema-validated unit tests on the screening service boundary. - Persistent sidebar history across "+ New screening". The button used to clear
the conversation cookie, which made the proxy mint a fresh
conversationsrow and hide every prior verdict from the sidebar query. Fix: keep the cookie, delete the current chat transcript, dispatch FSMRESET. The parent conversation row and itsscreeningsrows survive, so the sidebar shows every verdict produced from this browser. A<NewScreeningButton/>client wrapper readsbody.dataset.streaming(published by<ChatStream>) and pops awindow.confirm()if you click mid-evaluation, thenuseFormStatus().pendingcollapses fast double-clicks to one round-trip. A one-shot<HistoryToast/>(gated bylocalStorage) explains the new behavior on first reset, then strips?reset=1viarouter.replace. - Runtime AI knobs without redeploying. A topbar gear opens a settings modal β
pick from a server-side allowlist of OpenRouter models, tune the FSM evaluation
timeout, max retries, and temperature. The model dropdown live-fetches OpenRouter's
/api/v1/modelsand intersects the response with a curated allowlist (so every option is one we trust for structured output and OpenRouter is currently serving), with a 1h in-process cache and a hardcoded fallback. Settings are persisted in a singletonapp_settingsPostgres row. Precedence at request time isOPENROUTER_MODELenv var β DB β hardcoded default β env still wins, preserving the ops-driven swap promise from ADR 0004. The FSM evaluation timeout is now a per-actordelays.evalTimeoutreading from context, so settings changes apply on the next dispatch.
| Layer | Choice | ADR |
|---|---|---|
| Runtime | Next.js 16 (App Router) + React 19 | 0002 |
| FSM | XState v5 | 0001 |
| Database | Postgres 17 + Drizzle ORM | 0003 |
| Concurrency | Optimistic CAS (per-conversation version column) |
0006 |
| AI | Vercel AI SDK v6 + Claude Sonnet 4.6 via OpenRouter | 0004 |
| Streaming | streamObject + SSE route handler (/api/screening/stream) |
0004 |
| Validation | Zod v4 (single source of truth: schema β types β JSON schema for the LLM) | β |
| Testing | Vitest (unit) + Testcontainers (integration) + Playwright (E2E, fake AI) | 0005 |
| Style | Tailwind CSS 4 (@theme inline tokens, utility-class refactor) |
DESIGN.md |
| PDF export | puppeteer-core + @sparticuz/chromium (server-side render) |
β |
| OG image | next/og at /s/[slug]/opengraph-image |
β |
| Lang | TypeScript 6 strict + noUncheckedIndexedAccess |
β |
All versions are the latest stable as of April 2026.
ββββββββββββββββββββββββββββββββ
β idle β βββββ /cancel, /reset
β "I'm here to help." β
ββββ¬ββββββββββββββββββββ¬ββββββββ
/screen ββββββββββ βββββββββββ /newjob
βΌ βΌ
βββββββββββββββββββββββββββ ββββββββββββββββββββββ
β screening β β jobBuilder β
β βββββββββββββββββββββββββ β (mocked, /cancel β
β β gathering ββ β returns to idle)β
β β fills JD/CV slots, ββ ββββββββββββββββββββββ
β β always β evaluating ββ
β β once both filled ββ
β ββββββββββββ¬βββββββββββββ
β βΌ β ββinvokes screen() actorβββΆ OpenRouter
β βββββββββββββββββββββββββ βββββ result | error | abort ββ
β β evaluating ββ
β β β after 60s ββββ ββ
β ββββββ¬ββββββββ¬βββββββββββ
β βΌ βΌ βΌ β
β presenting error timedOut β idle (with typed error)
β Result β idle β
βββββββββββββββββββββββββββ
The full hierarchy and transition table lives in
src/lib/fsm/machine.ts and the E2E behavior in
src/lib/fsm/machine.test.ts.
src/
βββ app/ # Next.js App Router
β βββ actions.ts # Server Actions β the only mutation surface
β βββ layout.tsx # Root: theme bootstrap, font loading
β βββ globals.css # Tailwind 4 @theme tokens, design vars
β βββ (workspace)/ # Workspace shell route group (sidebar + topbar)
β β βββ layout.tsx # Shell β Sidebar + Topbar + CmdKPalette
β β βββ page.tsx # Dashboard (`/`) β screening cards + filter tabs
β β βββ screening/
β β βββ new/page.tsx # Active chat (`/screening/new`)
β β βββ [id]/page.tsx # Permanent verdict page (`/screening/[id]`)
β βββ s/ # Public share (no shell)
β β βββ layout.tsx # Bare layout β no sidebar leak
β β βββ [slug]/
β β βββ page.tsx # Public verdict (`/s/[slug]`)
β β βββ opengraph-image.tsx # 1200Γ630 OG card via next/og
β β βββ pdf/
β β βββ page.tsx # Print HTML (used by Chromium)
β β βββ download/route.ts # Headless Chromium β A4 PDF
β βββ api/screening/stream/route.ts # SSE β streaming verdicts
βββ components/ # UI
β βββ shell/ # Workspace chrome
β β βββ sidebar.tsx # Brand + recents + workspace footer
β β βββ new-screening-button.tsx # Client wrapper: mid-eval confirm + pending guard
β β βββ topbar.tsx # Breadcrumbs + βK input + theme toggle
β βββ ui/ # Primitives (Pill, IconButton, ScoreDisplay)
β βββ chat-stream.tsx # client: SSE consumer, optimistic render, publishes body.dataset.streaming
β βββ history-toast.tsx # One-shot toast on `?reset=1` after + New screening
β βββ streaming-verdict.tsx # Progressive verdict reveal during stream
β βββ verdict-header.tsx # Detail-page header β score + meta
β βββ requirement-list.tsx # Must-haves + nice-to-haves
β βββ bullet-block.tsx # Strengths / gaps two-column
β βββ recommendation.tsx # Slack-paste block + copy button
β βββ share-row.tsx # "Generate share link"
β βββ screening-card.tsx # Dashboard card
β βββ cmd-k-palette.tsx # βK search palette
β βββ theme-toggle.tsx # Light/dark with localStorage
β βββ message-bubble.tsx
β βββ screening-result-card.tsx # In-chat verdict card
β βββ state-pill.tsx
βββ lib/
βββ domain/ # Pure types & rules β no I/O
β βββ intent.ts # text β FSM event classifier
β βββ screening.ts # Zod schema for the verdict
β βββ verdict-style.ts # Single source of truth for verdict colors
β βββ fuzzy-match.ts # 30-line scorer powering βK
βββ fsm/ # XState machine + orchestrator
β βββ machine.ts # The state machine itself (pure)
β βββ orchestrator.ts # FSM β DB β AI bridge (server-only)
β βββ replies.ts # state β bot prompt
β βββ pair-screenings.ts # Pairs verdicts to messages in O(N+M)
β βββ snapshot.ts # zod-validated PersistedSnapshot
βββ db/ # Drizzle layer
β βββ schema.ts # conversations, messages, screenings, share_links
β βββ client.ts # Lazy singleton, dev-HMR safe
β βββ repositories.ts # Public/private split: getScreeningById vs
β β # getScreeningForShare (narrower type)
β βββ connection-string.ts # DATABASE_URL fallback chain
β βββ migrations/ # Generated by drizzle-kit
βββ ai/ # AI boundary
β βββ screen.ts # generateObject + streamObject + Zod
β βββ extract-pdf.ts # PDF β text via unpdf
βββ pdf/
β βββ browser.ts # puppeteer-core + @sparticuz/chromium launcher
βββ log.ts # Request-scoped structured logger
βββ cookies.ts # Conversation cookie helpers
e2e/
βββ screening.spec.ts # Playwright β dashboard + new chat + share round-trip
fixtures/ # Sample JD + 3 CVs (strong / weak / wrong-role)
docs/
βββ CHALLENGE.md # The original brief
βββ adr/ # Architecture Decision Records (0001β0006)
DESIGN.md # Design system β tokens, components, a11y
The lib/ layout follows a strict dependency order:
domain βββ fsm βββ ai
β² β² β²
βββββββββββββΌβββββββββββ
app, components
domain has no dependencies on anything else. fsm depends on domain. The
orchestrator is the only file that reaches across layers (FSM + DB + AI), and it's
server-only by design.
The README's grading criteria explicitly name testing strategy, so here it is in plain terms:
| Layer | Tool | What it proves |
|---|---|---|
| Pure domain | Vitest | Intent classifier, replies, schema validation |
| FSM | Vitest + XState | Every transition, /cancel from each substate, the error branch, snapshot rehydration |
| AI service | Vitest + MockLanguageModelV3 |
Schema validation, error propagation, latency tracking |
| Repositories | (Postgres integration) | Not implemented β would use Testcontainers in CI; the pattern is here |
| End-to-end | Playwright + WORKFULLY_FAKE_AI=1 |
UI wiring, FSM transitions visible to the user |
All unit tests green; coverage thresholds enforced (β₯80% statements/functions/lines, β₯75% branches; current ~95%). One drift test (verdict-style.test.ts) reads globals.css at runtime and asserts every CSS variable matches the constants in verdict-style.ts β the build fails if DESIGN.md and the runtime tokens diverge.
pnpm test # Vitest, ~200 ms
pnpm test:coverage # ditto + v8 coverage with thresholds
pnpm test:e2e # Playwright (needs DB + WORKFULLY_FAKE_AI=1)
pnpm check # format:check + lint + typecheck + knip + testSee docs/adr/0005-testing-strategy.md for
the full rationale on why tests are split that way.
Everything below runs in CI on every push and PR.
| Concern | Tool |
|---|---|
| Formatter | Prettier 3 + prettier-plugin-tailwindcss (sorts Tailwind classes) |
| Linter | ESLint 9 (eslint-config-next core-web-vitals + typescript) |
| Type checker | TypeScript 6 strict + noUncheckedIndexedAccess |
| Dead-code scanner | Knip (unused files / exports / deps) |
| Pre-commit hook | Husky 9 β lint-staged (Prettier + ESLint --fix --max-warnings 0) |
| Commit-msg hook | Husky 9 β commitlint (Conventional Commits, custom scope-enum) |
| Security scanning | GitHub CodeQL (security-and-quality queries) + pnpm audit --prod |
| Workflow linter | actionlint (Docker, lints .github/workflows/**) |
| Stale PRs / issues | actions/stale@v10 (30/7 day window) |
| Dependency upgrades | Dependabot (npm + github-actions, weekly, minor/patch grouped) |
| Coverage | Vitest + @vitest/coverage-v8, hard floors enforced |
| Editor consistency | .editorconfig, .nvmrc (Node 24) |
Conventional Commits are enforced both locally (commit-msg hook) and in CI:
feat(fsm): add presentingResult substate
fix(ai): handle schema-violation error from generateObject
docs(adr): add ADR 0006 explaining streaming verdicts
chore(deps): bump xstate to 5.32.0
Allowed scopes: fsm, ai, db, ui, domain, e2e, ci, deps, docs, config, orchestrator, proxy, actions, log.
pnpm installpnpm db:up && pnpm db:migrate- Copy
.env.exampleto.envand add yourOPENROUTER_API_KEY. pnpm dev- Open http://localhost:3000 β you land on the dashboard. Empty on first run.
- Click + New screening in the sidebar (or hit βK and pick "New screening")
to land on
/screening/new. Type/screen, paste a JD (tryfixtures/job-description.pdf), then a CV (cv-strong-match.pdffor a high-confidence verdict, orcv-wrong-role.pdffor a wrong-role rejection). - Watch the verdict stream in. When it lands, you redirect to a permanent
/screening/[id]page. Click Generate share link to mint a public/s/[slug]URL β paste it into Slack to see the OG card unfurl, or click the PDF icon on the share page to download an A4 print. - Back on the dashboard, your screening appears as a card. Use the tab strip to filter by verdict tier.
Other commands the bot understands: /newjob, /cancel, /reset,
or natural-language equivalents (screen a candidate, start over, stop).
βK (or Ctrl-K) opens the search palette from anywhere.
After the original v0.1.0 deliverable, an audit pass on the codebase produced 14 small wins plus one structural change. The headline:
Concurrency-safe orchestrator with FSM-owned timeout and structural cancellation.
Two reviewers (Codex CLI and an independent Claude subagent) audited
src/lib/fsm/orchestrator.ts and surfaced the same bug: under concurrent
requests on the same conversation, two writers could both read the same FSM
snapshot, both run actor.send, both write β last-writer-wins silently fork
the state machine. They also both flagged that the 60s EVAL_TIMEOUT_MS lived
outside the FSM, which leaked an in-flight generateObject HTTP call when it
fired and created a fork window where the FSM could land in
presentingResult while the orchestrator returned a "took too long" error.
First instinct was SELECT FOR UPDATE to serialise the writes. Rejected:
the lock would be held across the 10s+ AI call and convoy the connection
pool under load (Vercel Fluid Compute runs max: 1 per instance β one stuck
conversation pins the whole instance for up to 60s).
Shipped instead:
- Optimistic concurrency via a
versioncolumn onconversations. The orchestrator reads(snapshot, version), runs the AI call with no DB connection held, then commits viaUPDATE ... WHERE id = ? AND version = ?. CAS conflict throwsConcurrentModificationError; the action layer maps it to a typed product string ("This conversation changed in another tab β refresh to continue."). - FSM-owned timeout via XState's
afterdelayed transition. The orchestrator no longer races with the FSM β when timeout fires, XState cleanly transitions toidleand stops the invoked actor. - Structural cancellation. XState's
fromPromisesignal is forwarded intoscreen(input, { signal })and on intogenerateObject({ abortSignal }). When the FSM exitsevaluatingfor any reason, the in-flight HTTP call to OpenRouter is actually aborted, not just abandoned. - Real-Postgres integration test. One Testcontainers test in
test/integration/orchestrator.concurrent.test.tsverifies the SQL primitive serialises concurrent CAS attempts. Lives in a separate Vitest lane (pnpm test:integration) so the defaultpnpm teststays Docker-free.
Full rationale in ADR 0006.
The audit also produced 13 small wins around correctness, observability, and
DX: actor.stop() in try/finally on every error path, secure cookie flag
in production, Zod schema tightening so a malformed snapshot row can't crash
XState during rehydration, request-scoped structured logger that emits FSM
transitions and AI latency, unit tests for the proxy and Server Actions
surfaces, Codecov upload, CI gating fix so e2e waits for unit tests to pass,
Postgres connection-pool size that branches on process.env.VERCEL, and
explicit fixture markers for the fake-AI test escape hatch.
After the audit shipped, the chat at / started feeling like a tech demo, not a
product. Day three was a platform redesign β turn the single-page chat into a
workspace.
The routing model changed. / is now the dashboard. The active chat lives at
/screening/new. Every verdict gets a permanent URL at /screening/[id]. Public
shares get an unguessable slug at /s/[slug] with its own bare layout (no sidebar
leak), an Open Graph card at /s/[slug]/opengraph-image, and a server-rendered
A4 PDF at /s/[slug]/pdf/download (headless Chromium via puppeteer-core +
@sparticuz/chromium).
The verdict streams now. screenStreaming() wraps streamObject and emits
partials through an onPartial callback. dispatchStreaming() threads it through
the orchestrator. /api/screening/stream is an SSE route that emits
user-message, partial, done, and error events. <ChatStream> consumes the
SSE response, optimistically renders the user message, progressively renders the
verdict via <StreamingVerdict>, and redirects to /screening/[id] when the
stream closes. Fake-AI mode (WORKFULLY_FAKE_AI=1) emits 11 timed partials over
~2.5s so demos show the streaming shape without burning OpenRouter credits.
TypeScript-enforced privacy boundary. A new share_links table (128-bit
unguessable slug, ON DELETE CASCADE, UNIQUE on screening_id) backs the
public share. The repository layer splits into getScreeningById (private β
returns JD + CV + transcript) and getScreeningForShare (public β returns a
narrower type that omits all three). The public page literally cannot render JD
or CV β TypeScript fails the build if you try.
Design system as a living document. New DESIGN.md at the repo root captures
tokens, component contracts, responsive breakpoints, a11y rules, motion, and
dark-mode behavior. New src/lib/domain/verdict-style.ts is the single source of
truth for verdict color mappings β used by the dashboard, sidebar dot, screening
header, public share, OG card, and PDF page. A drift test reads globals.css and
asserts every CSS variable matches the constants. CI fails if they diverge.
Tailwind 4 utility-first refactor. Every component (~20 files) migrated from
inline style={{ ... }} to Tailwind utility classes generated from @theme inline
tokens. New utilities: w-sidebar, h-header, shadow-pop, animate-fade-in,
animate-scale-in. Net: zero remaining inline styles outside the @vercel/og
image route (which requires inline by design β next/og doesn't run Tailwind).
Other day-three bits. βK palette with a hand-rolled fuzzy-match scorer (30
lines beats a 9 KB library at this scale). ThemeToggle with a tiny inline
<head> script that sets data-theme before React hydrates β no flash of wrong
theme on first paint. candidateName and role extracted by the model on the
same AI call, so dashboard cards show real names instead of "Untitled screening".
End-to-end coverage updated for the new routing: dashboard listing, public share
access without sidebar, share-link round-trip.
- Uploaded-PDF storage. Right now we extract text from JD/CV PDFs and discard the original bytes. If recruiters need to re-download what the candidate uploaded, push the bytes to S3 and record a key on the screening row. The verdict PDF (which the share page generates) is rendered on demand from data β no storage needed.
- Screening replays. The
screeningstable already stores JD + CV verbatim, so re-running an old verdict against a different model is just a worker job away. - Multi-tenancy. Every row would gain a
workspace_id; cookies would carry it. Routes would scope queries on it. Pure mechanical work, omitted for time. - Per-conversation rate limiting. An in-flight token (Redis or in-memory if single-instance) would deflect concurrent dispatch attempts before they hit the DB at all. The CAS pattern handles correctness; this would handle abuse / accidental double-clicks gracefully.
- Broader repository integration tests. W19' shipped one Testcontainers case for the CAS primitive; the rest of the repo layer would benefit from the same treatment now that the infrastructure is in place.
- Eval harness.
pnpm evalagainst a labeled fixture set, run on every prompt change. That's how you measure verdict quality without unit-testing the model. - Job Builder for real. Today it returns a mock prompt β implementing it with the same FSM-as-source-of-truth approach would mirror the screening flow.
MIT (this is an interview challenge response β feel free to reuse anything useful).