Skip to content

// project

Candidai

A native, multiplatform AI career-intelligence engine, and the open-source project it's built on.

The brief

Candidai is a local-first AI career-intelligence engine: resume onboarding, reverse-ATS discovery, evidence-based fit scoring and tailored résumés, in one pipeline. The product idea and scoring model started on career-ops, an open-source Node.js job-search engine (75k+ stars) that I actively contribute to upstream. The native app is a from-scratch Kotlin Multiplatform rebuild: the same A-F fit-scoring engine, ported and verified line-for-line against the original, now running identically on Android, iOS, Desktop, Web and a Spring Boot server instead of a single Node process.

What shipped

  • 25-module Kotlin Multiplatform clean architecture (12 feature + 6 core modules) targeting Android, iOS, Desktop, Web and a Spring Boot 4 server from one shared engine.
  • core:engine is a no-IO module: A-F fit scoring, ATS search, SimHash fingerprinting, and funnel math ported 1:1 from career-ops and verified against its own test vectors.
  • 111 ATS & job-board provider integrations and a zero-token scan path (direct Greenhouse/Ashby/Lever APIs, no LLM cost) inherited from the open-source engine it's built on.
  • 25 merged PRs to the public career-ops project (75k+ stars): new ATS providers, an opt-in LLM re-ranker, an agent-inbox feature, and a run of correctness fixes, each with a reproduction and a regression test (full list below).
Kotlin MultiplatformCompose MultiplatformSpring Boot 4Room (KMP)Ktor111 ATS/board providers
Upstream (career-ops, 75k+ stars) Active · 25 PRs merged to public career-ops · member of the career-ops-hq org
built on
kmp-build-logic
kmp-toolkit
rebuilt from

private, not polled

// case study

The short version

The problem

career-ops's scoring engine lived in a single Node.js process. Proving the same logic could run identically on a phone, in a browser tab and on a server meant more than porting the syntax: it meant the scoring could not quietly drift from the original the moment it moved.

The decision

Port core:engine (A-F fit scoring, ATS search, SimHash fingerprinting, funnel math) as a no-IO module, then verify it against career-ops's own test vectors rather than trust a rewrite by inspection, the same zero-token discipline the upstream project already applies to keeping LLM cost off the hot path.

The result

One verified engine now drives five targets (Android, iOS, Desktop, Web and a Spring Boot 4 server) from 25 Kotlin Multiplatform modules, and 24 of the correctness fixes the port surfaced have already merged back upstream to the public career-ops project.

25 KMP modules · 5 targets

Evidence: One verified engine now drives five targets (Android, iOS, Desktop, Web and a Spring Boot 4 server) from 25 Kotlin Multiplatform modules, and 24 of the correctness fixes the port surfaced have already merged back upstream to the public career-ops project.
Go deeper

Analyst view: Candidai draws on 6 stack entries and links to 0 outside sources, and shares 2 shared foundations with 3 other builds.

25

KMP modules · 5 targets

45k

lines of Kotlin · 543 files

111

ATS & job-board providers

25

PRs merged upstream

// multiplatform

One codebase, every surface

Recorded app screens and, where available, the running build. Sample content inside a screen is separate from the source-linked project metrics.

Android screen (1 of 3)

Real Roborazzi captures: first screenshots off the actual Compose UI, not mockups.

// design notes

How it works

One engine, five targets

A 25-module clean-architecture split, 12 feature modules and 6 core modules, targets Android, iOS, Desktop, Web (wasmJs) and a Spring Boot 4 server from one shared Kotlin codebase: 543 files, ~45,000 lines. core:designsystem, core:protocol, core:engine, core:data, core:network and core:ai sit underneath feature modules for dashboard, pipeline, explore, intel, ops, profile, auth, assistant and more.

A no-IO engine, ported and verified

core:engine holds the A-F fit-scoring rubric, ATS search, SimHash fingerprinting for duplicate-listing detection, a liveness classifier, and the funnel math. None of it touches the network or disk. It's ported 1:1 from career-ops's original JavaScript implementation and checked against that implementation's own test vectors, so the scoring behaves identically whether it's running on Android, in a browser tab, or on the server.

Offline-first, agent-reachable

Room (KMP) plus DataStore caches everything locally over a Ktor REST + NDJSON/SSE sync layer, so the dashboard stays usable offline and catches up when connectivity returns. An agent-interop surface lets other agents, and the OS itself, drive the app without going through the UI. It covers Android AppFunctions, iOS App Intents/Shortcuts, candidai:// deep links, and a documented OpenAPI contract.

On-device AI, with a fallback that always works

Where an LLM adds real value, it runs on-device first: ML Kit GenAI / Gemini Nano on Android, Apple Foundation Models on iOS. Every AI-assisted step has a deterministic-heuristic fallback, so fit scoring and résumé tailoring keep working with zero model available, the same discipline career-ops applies with its zero-token scan path.

Zero tokens until an LLM is actually needed

The engine's scan path hits Greenhouse, Ashby and Lever APIs plus per-company local parsers directly, at zero LLM cost, falling back to an agent-driven search only for companies with no structured source. Every scanned posting passes through one shared trust-validator that scores and flags it before it reaches the tracker. 111 ATS & job-board provider modules plug into that one contract instead of reinventing trust scoring each time.

One engine, many candidates

career-ops's multi-profile architecture is a profiles.yml registry mapping each candidate to a private data root while sharing one engine install. That is the same shape the native app's per-candidate routing follows: one server, N profiles, a strict User/System data contract between them.

Bring your existing career-ops data

Already running the career-ops CLI? Point Candidai at the existing checkout instead of starting over: the first-run wizard's Profiles screen has a Bring your data step, a doctor gate checks it's a usable career-ops root and says exactly what's missing if not, then imports it as an active profile. Reference mode leaves the folder where it is so the CLI can keep reading and writing it, guarded by a cross-process tracker lock; copy mode clones it onto the server volume for a host with no shared filesystem. The same path is exposed over HTTP for a server deploy.

The agent-interop contract, not just an OpenAPI label

Any mutating request may carry an Idempotency-Key header; a retry with the same key on the same method and path is acknowledged without re-executing the write, the exact contract the mobile offline outbox itself relies on. Mutating requests to the API are rate-limited per client IP, with read-only GETs left unlimited. The scan and explore endpoints stream progress over SSE (text/event-stream) rather than blocking until a scan completes, so a caller sees offers arrive live instead of waiting on one long response.

Genuine upstream contribution, not a personal fork

25 merged pull requests against the public career-ops repository (75k+ stars, independently verifiable): two new ATS providers (BambooHR, Breezy HR), a dashboard rendering fix that rewrites only the changed Status cell instead of the whole row, an agent-inbox feature for queuing requests across sessions, an opt-in LLM relevance re-ranker for the pipeline, and a long run of correctness fixes. Most target one class of defect: code that reports success while doing the wrong thing. Distinct non-Latin company names collapsed to one key and silently deleted a tracked application; a `$` sequence in CV text spliced the template into the résumé while the build exited 0; a date filter was ignored in its `--flag=value` form, so a bounded scan silently ran unbounded; concurrent adds to the agent inbox dropped queued requests with no error; an unlocked append to shared scan history could interleave and corrupt it; and a `k`/`M`/`B` magnitude suffix walked an inflated claim straight past the fact-checker that exists to stop exactly that. Each shipped with a runnable reproduction and a regression test proving the fix.

// architecture

How it's built

One engine, five targets

Zero tokens until an LLM is actually needed

// under the hood

Tech stack

Native app

  • Kotlin Multiplatform
  • Compose Multiplatform
  • Spring Boot 4 server
  • Room (KMP) + DataStore
  • Ktor REST + NDJSON/SSE

On-device AI

  • ML Kit GenAI / Gemini Nano (Android)
  • Apple Foundation Models (iOS)
  • deterministic-heuristic fallback

Agent interop

  • Android AppFunctions
  • iOS App Intents / Shortcuts
  • candidai:// deep links
  • OpenAPI contract

Open-source engine (career-ops)

  • Node.js
  • 111 ATS/job-board providers
  • zero-token Greenhouse/Ashby/Lever scanning
  • A-F fit rubric

// directions

Same screen, 3 treatments

Pick a treatment to see it whole. Where one ships a dark counterpart, drag the divider to compare the two faces of it — same layout, same content, only the light changes.

theme — dashboard, light
theme — dashboard, dark
↔
lightdark
dashboarddrag or arrow-key the divider

// gallery

Screens (4)

Swipe or use the arrows · tap a screen to enlarge

Follow the connections

From this build, go deeper.

Shared foundations

This project uses kmp-build-logic and kmp-toolkit. Follow the same foundations into another build.

See how everything connects →