Skip to content
Workfully Screening Botv0.3.3.0

How I built a finite-state-machine screening bot.

A walkthrough of the architecture, the decisions, and the tools I used to ship the optional FSM proposal from the Workfully technical challenge.

David Marin
Built this for the Workfully interview
  • 19Tests
  • 95%Coverage
  • 6ADRs
  • 8,788Lines of TS
  • 14Runtime deps
Overview

What I built.

A conversational candidate-screening bot driven by an XState finite state machine. The brief asked for three states (IDLE, SCREENING, JOB_BUILDER) with universal /cancel. I shipped the optional FSM proposal.

  • Idle → screening → job-builder, with /cancel wired once on the screening parent so any sub-state cancels the same way.
  • The screening AI call is an fromPromise XState actor invoked from evaluating. The machine declares what; the orchestrator provides how.
  • Verdicts get a permanent page at the dashboard under /screening/[id], an unguessable public share at /s/[slug], an OG card, and a server-rendered Chromium PDF.

The constraint I held myself to: the FSM is the source of truth. Server actions don't mutate state directly. The UI doesn't mutate state. The AI call doesn't mutate state. Everything goes through the machine. That is what makes /cancel work the same way from any sub-state and what makes a page reload put you back exactly where you were.

Architecture

The state machine is the source of truth.

Server actions translate user input into FSM events. Nothing else mutates conversation state. Page reload? Same place. /cancel from any sub-state? One transition on the parent.

Bot finite state machine — three top-level states with a screening sub-state groupIDLE transitions to SCREENING via START_SCREENING or to JOB_BUILDER via START_JOB_BUILDER. SCREENING contains gathering, evaluating, and presentingResult. CANCEL from any sub-state returns to IDLE.IDLEgreets, lists commandsJOB_BUILDERmockedSCREENINGgatheringJD + CV in any orderevaluatinginvokes screen() actorpresentingResulttyped verdictOpenRouter → Claude/screen/newjobboth filledonDoneonError / 60sfromPromise + AbortSignal/reset/cancel (parent transition)

The thing to look at is the dashed line from the SCREENING parent. That is one transition on the parent state, not four wired to every leaf. XState's hierarchical states are why /cancel from gathering works the same way as /cancel from evaluating. The other piece is fromPromise + AbortSignal: the FSM owns the 60-second timeout, and when it fires, it actually cancels the in-flight model call. ADR 0001 has the full reasoning.

Discipline

One Zod schema, three uses.

The same schema in src/lib/domain/screening.ts is the contract for the LLM, the inferred TypeScript types, and the Drizzle column type. Change one file, three layers update together.

src/lib/domain/screening.ts
// src/lib/domain/screening.ts
export const screeningResultSchema = z.object({
  candidateName: z.string(),
  role: z.string(),
  verdict: z.enum(["strong", "moderate", "weak", "wrong_role"]),
  score: z.number().int(),
  summary: z.string(),
  mustHaves: z.array(requirementMatchSchema),
  niceToHaves: z.array(requirementMatchSchema),
  strengths: z.array(z.string()),
  gaps: z.array(z.string()),
  recommendation: z.string(),
});

export type ScreeningResult = z.infer<typeof screeningResultSchema>;
src/lib/ai/screen.ts
// src/lib/ai/screen.ts
const { object } = await generateObject({
  model,
  schema: screeningResultSchema,    // ← LLM constraint
  schemaName: "ScreeningResult",
  system: SYSTEM_PROMPT,
  prompt: buildPrompt(input),
  maxRetries: 2,
});
src/lib/db/schema.ts
// src/lib/db/schema.ts
result: jsonb("result").$type<ScreeningResult>(),  // ← DB column type

This is what keeps the LLM honest in production. The model returns a typed ScreeningResultor it throws — there is no parse-the-JSON-and-pray path anywhere in the codebase. If a model can not produce schema-valid JSON after two retries, the FSM's error branch fires and the user sees a friendly error.

AI integration

Provider-agnostic by default.

One env var swaps the model. The schema, the prompt, and the FSM don't move when the provider changes. This is the abstraction I'd advocate for at Workfully too.

src/lib/ai/screen.ts
const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
const modelId = process.env.OPENROUTER_MODEL ?? DEFAULT_MODEL;
const model = openrouter(modelId);
  • Same code talks to Anthropic, OpenAI, Google, Mistral, anyone OpenRouter routes to.
  • A/B different models with one env var, zero code edits.
  • Single key to rotate. Single billing surface.

The model market keeps shifting. When Workfully wants to evaluate GPT-5.4 or Gemini 3 against the same screening prompt, it's a five-minute change instead of a sprint. See ADR 0004 for the full rationale.

Testing

Pyramid, not snowman.

Most of the value at the bottom: pure unit tests across the FSM, the intent classifier, the schema, the snapshot rehydration. Boundary tests above. One Playwright E2E at the top, deterministic via WORKFULLY_FAKE_AI=1.

  • Test myFSM, not XState's framework.
  • Test at the boundary, not the implementation.
  • Mock at the integration point (provide({ actors })), not deeper in the SDK.
  • One E2E for "wires connected," not for coverage.
  • Don't test the model's IQ. That is an eval problem, not a unit-test problem.

The escape hatch I'm proudest of.

When WORKFULLY_FAKE_AI=1 is set, screen() bypasses OpenRouter entirely and runs a 30-line deterministic stub. CI runs Playwright against it: the build does not depend on OpenRouter uptime, the assertions can be precise, and the codepath is unreachable in production because the env var simply is not there.

src/lib/ai/screen.ts
// src/lib/ai/screen.ts
if (process.env.WORKFULLY_FAKE_AI === "1" && !deps.model) {
  return fakeScreen(input);
}

See ADR 0005 for the full strategy.

Tooling

Every PR runs through this gate.

Most of this is what I bring to every project I work on. It's the floor I refuse to ship below.

ConcernTool
FormatterPrettier 3 + prettier-plugin-tailwindcss
LinterESLint 9 (eslint-config-next + TS)
Type checkerTypeScript 6 strict + noUncheckedIndexedAccess
Dead codeKnip (unused files / exports / deps)
Pre-commitHusky 9 → lint-staged
Commit-msgHusky 9 → commitlint (Conventional Commits, scope-enum)
SecurityGitHub CodeQL + pnpm audit --prod
Workflow lintactionlint (Docker)
Stale PRsactions/stale@v10
Dep upgradesDependabot (npm + actions, weekly, grouped)
CoverageVitest + @vitest/coverage-v8 (hard floors)

Five-job CI on every push:

  • lint+typecheck
  • test+coverage
  • build
  • e2e (Playwright + real Postgres service container)
  • audit (high-severity prod CVEs)
How I built it

Claude Code as a paired engineer.

It's a fair question — this is a lot of code and a lot of polish for one challenge. Let me be honest about how I built it.

ADRs first. Every major decision has a one-page ADR in docs/adr/. I wrote them with the model and iterated. They are real engineering decisions with real tradeoffs, not summaries.

Tests force the design. The state machine has full transition coverage because the test suite told me when a proposed transition was wrong. The model proposed; the tests said yes or no.

Schema is the contract. I wrote the Zod schema by hand. The model implemented against it. generateObject enforces the same contract at runtime. Every layer of the stack speaks the same shape.

Reviews before commit. Every diff went through both a code-review agent and CodeQL. CodeQL catches what humans miss. The model catches what CodeQL misses. I catch what both miss.

The discipline matters more than the tool. AI accelerated this work by maybe four-x. The architecture, the tests, and the ADRs are why it's shippable.

Try it

Real verdicts from this app.

The cards below render via the same React components the running app uses. Click through to start a real screening of your own — drag in any JD + CV PDF and the bot evaluates them in about ten seconds.

See the dashboard

These render via the same React components the running app uses for /screening/[id] and /s/[slug]. The data is a hardcoded sample so the gallery has zero database dependencies — every prop here matches the Zod schema the LLM is constrained to produce.

Strong match · 4 / 4 must-haves

Priya Bhattacharya

Senior Backend Engineer
Fit score
92/100

Eight years building Postgres-heavy distributed systems and a track record of leading API platform work — directly aligned with what the role asks for.

Must-haves4 / 4
  • 5+ years backend engineering
    8 years at Stripe and Shopify, both backend-focused roles per the CV.
  • Production Postgres experience
    Owned the merchant-payouts Postgres cluster for 3 years; led one major migration.
  • Strong async / distributed systems
    Designed the order-fulfillment saga for ~$3B/yr GMV.
  • Mentorship of mid-level engineers
    Tech lead for a 6-person backend team at Shopify.
Nice-to-haves1 / 2
  • Open source contributions
    Maintainer of `pg-bulk-loader`, 1.2k stars.
  • Public speaking
Recommendation

Strong yes for a final round. Frame the Kafka gap as a calibration question, not a blocker — the systems thinking transfers cleanly.

Moderate match · 3 / 4 must-haves

Marco Lindholm

Senior Backend Engineer
Fit score
68/100

Solid backend fundamentals and clear ownership of one production system, but the experience is heavier on Node.js services than the Postgres-centric work the role calls for.

Must-haves3 / 4
  • 5+ years backend engineering
    6 years across two companies, both backend-focused.
  • Production Postgres experience
    Used Postgres at both companies, but most recent role moved to DynamoDB.
  • Strong async / distributed systems
    Async work limited to per-request background jobs; no saga or queue-based architecture mentioned.
  • Mentorship of mid-level engineers
    Mentored two junior engineers as a senior at last role.
Recommendation

Moderate — worth a technical screen focused on distributed systems judgment. If they handle a saga design well, advance.

Wrong role · 0 / 2 must-haves

Júlia Almeida

Senior Backend Engineer
Fit score
18/100

Strong product designer with 7 years of experience leading design systems work — interesting profile, but not what this role is for.

Must-haves0 / 2
  • 5+ years backend engineering
    All listed experience is in product/UX design.
  • Production Postgres experience
Recommendation

Wrong role for this opening. Worth keeping in the network if a Senior Product Designer slot opens — strong portfolio.

About

Built by David Marin.

I make AI tools for builders. Right now, I'm at Pump, where we help startups optimize cloud spend. Before that I built Basetool, an open-source no-code platform that put me on the YC W22 batch.

I built this project the way I work in production: tests first, ADRs for every architectural decision, and an LLM as a paired engineer rather than a copy machine. The discipline is the point.

Workfully Screening Bot v0.3.3.0 · source on GitHub · MIT

On this page