Skip to content
← ProjectsDevAIInfraIn production

A résumé and cover letter tailored to every job ad, ATS-ready, in seconds.

Role
Founder — product, full-stack dev, infra
Period
2025 — aujourd’hui
Context
Own product
Jobspark

3

Next.js apps + 1 API

FR/EN

languages

1

deployment manifest

24/7

auto staging on every push

Context

Candidates send the same résumé everywhere and get filtered out by ATS before a human reads it.

The challenge

Produce a truly tailored document for each ad, fast and reliable, keep AI costs under control, with simple payments and flawless layout.

  1. 01AI

    Constrained AI generation

    Structured prompts through OpenRouter (Mistral), validated outputs rendered by a dedicated document engine. Real-time voice interviews through OpenAI Realtime (WebRTC).

  2. 02Dev

    In-house ATS score

    An `ats-score` package measures résumé ↔ job fit and guides users before they apply.

  3. 03Dev

    Turborepo monorepo

    Three Next.js 16 apps (landing, app, web) + NestJS 11 / Drizzle API, shared packages (ui, renderer, score), end-to-end Playwright tests.

  4. 04Infra

    Platform as code

    Deployed on my Dokploy PaaS from a single manifest: staging on every push, production on demand, daily R2 backups.

Architecture

Stack

  • Next.js
  • React
  • NestJS
  • PostgreSQL
  • Drizzle ORM
  • Redis
  • MinIO / S3
  • Stripe
  • OpenRouter / Mistral
  • Turborepo
  • PPlaywright
  • DDokploy
  • Terraform / OpenTofu

Results

  • ✦Stripe credits and payments in production
  • ✦Bilingual FR/EN UI and installable PWA
  • ✦Free SEO tools for acquisition

The story

How it works

The screenshots come from a local instance, with a demo account and a fictional candidate, Léa Moreau. The walkthrough follows what a candidate does, from spotting an offer to rehearsing the interview.

1. Offers come to the candidate

“Offers of the day” page: six job cards with a match percentage and the recognised skills
The offers of the day, ranked by how well they match the candidate’s search and résumé, before any model is called.

The candidate describes their search once: job, place, contract. The API reads France Travail’s national feed in time slices, and the career pages of the companies they follow every 30 minutes. Each new offer is compared with the searches as soon as it arrives. Closed offers are anonymised, then purged after 30 days.

2. A score you can read

Offer detail panel: 81 % score, per-criterion breakdown and “Apply” button
An offer’s detail shows how its score breaks down, criterion by criterion: the candidate sees why it was suggested.

The percentage is not an AI grade. It is a deterministic 100-point scale: job title, skills, location, freshness, experience and salary. Skills are also read through France Travail’s ROME 4.0 framework, which catches a résumé that says the same thing in other words. An AI analysis of the offer exists, but it is optional and paid.

3. An application, its documents, its score

Applications list with status, generated documents, ATS score and date
Each application keeps its status, its documents and its ATS score, recomputed on every résumé save.

“Apply” creates an application from the offer. An offer pasted in or imported from a URL takes the same path: one model call structures it into title, duties, requirements and keywords. The résumé and the letter are then generated from the candidate’s profile. The ATS score shown in the list is recomputed for free on every save. How it is computed has its own article.

4. Documents the candidate stays in charge of

Cover letter editor: form on the left, preview of the final document on the right
The generated letter stays editable field by field, with the exact PDF preview, version history and DOCX export.

Nothing is frozen after generation. Every field can be edited, the preview on the right is the HTML that gets printed to PDF, and every generation or save creates a version that can be restored. Name, email and phone never reach the model: the server puts them back after generation.

5. Rehearsing the interview out loud

Voice interview in progress: animated orb “I’m listening”, timer and transcript
The practice interview is spoken, with a recruiter you can interrupt and a transcript written as the conversation goes.

The interview is a real voice conversation. The virtual recruiter works from the offer and the profile, follows an agenda of questions and can be cut off mid-sentence. A session is billed by the minute, because its real cost is too.

Interview report: answer count, average length, hesitations, offer keywords reused and a score out of 10
The report measures what can be measured (length, hesitations, offer keywords reused) and gives two precise things to work on.

At the end, a report counts the turns, the average answer length, the hesitations and how many of the offer’s keywords were used out loud. The per-dimension score can be followed from one session to the next.

Under the hood

A real-time interview without exposing the key

The first version of the interview chained transcription, model and speech synthesis on every turn. The recruiter answered too slowly and did not stop when interrupted. ADR-026 moved the voice to OpenAI Realtime over WebRTC, with the mini model, the only one that fits the price of a session.

Negotiation goes through the API, audio flows straight from the browser to OpenAI

The browser never holds the key or the prompt. It sends its SDP offer to the API, which opens the call at OpenAI with the model, the voice and the interview brief, then returns the SDP answer. Audio then flows directly. Meanwhile, the API joins the same call over a “sideband” WebSocket. It records the conversation in order for the report, updates the agenda after each reply and hangs up once the paid duration, plus 90 seconds, is over.

How the offers of the day are scored

The match score in the offer panel comes from this function. Each criterion is worth a fixed share of the 100 points, and for skills and title the better of two readings wins: résumé keywords or the ROME framework.

apps/api/src/job-search/matching/job-matching.ts
const breakdown: ScoreBreakdown = {
  experience: SCORE_WEIGHTS.experience * experienceScore(job, project),
  freshness: SCORE_WEIGHTS.freshness * freshnessScore(job, now),
  location: SCORE_WEIGHTS.location * locationScore(job, project),
  salary: SCORE_WEIGHTS.salary * salaryScore(job, project),
  // The best of the two readings: ROME only ever adds to the keywords.
  skills:
    SCORE_WEIGHTS.skills *
    Math.max(
      input.skills.length > 0
        ? Math.min(1, keywordSkills.length / Math.min(5, input.skills.length))
        : 0,
      romeSkills.ratio,
    ),
  title:
    SCORE_WEIGHTS.title *
    Math.max(
      titleScore(job, project),
      romeTitleScore(job, rome?.projectCodes ?? []),
    ),
};
// SCORE_WEIGHTS = { title: 30, skills: 25, location: 15,
//                   freshness: 15, experience: 10, salary: 5 }

It is simple on purpose. The score is computed for every new offer and every search, with no model call, so at no cost. The morning AI re-ranking is an option at 1 credit a day, charged only when the call succeeds.

What I take away

  • The model writes, the code decides. A résumé’s facts (companies, dates, figures, skills) are re-anchored on the profile after generation. Anything the profile does not back up is removed. The prompt asks for it too, but I do not rely on the prompt alone.
  • Bill the way you pay. A generation costs 3 credits, charged only once the output has been validated. The interview is billed by the minute since it became the most expensive part of an application. Credit sales are refused when the OpenRouter balance drops below a critical threshold.
  • Write decisions down. The repo holds more than twenty-five ADRs. When a model choice or a scoring scale changes, the reason and the measurements live in the repo, not in my head.

Gallery

Jobspark dashboard for a demo account: tracked and sent applications, response rate, credits and interview score
Applications list with status, generated documents, ATS score and date
Interview report: answer count, average length, hesitations, offer keywords reused and a score out of 10
Shipyard
Next project →ShipyardFrom a spec to a repo delivered autonomously by agents.

Let’s talk. Describe your need in a few lines, I’ll get back to you within 48 hours.