CariKerja: measuring a market until the product had to change shape

An AI job-application agent for Indonesian job seekers, built Cloudflare-native. Four coverage measurements taken before writing the expensive part showed that the auto-apply premise borrowed from the US market does not transfer here, and the product was rewritten around that finding rather than against it.

Role Sole engineer, designer and researcher. Period August 2026, three days of concentrated work. Scope Product design, backend, discovery research, frontend prototype.

9.6%
EMPLOYER COVERAGE
Of 136 seeded Indonesian employers, only 13 had a job board that could be enumerated at all
0 of 93
EMPLOYERS PUBLISHING JOB SCHEMA
The generic extraction plan was falsified by measurement rather than by argument
160
TESTS PASSING
Across 13 files, run inside the real Cloudflare Workers runtime
4
COVERAGE RUNS BEFORE BUILDING
Each one revised the product downward, on purpose

Summary

CariKerja is an AI agent for Indonesian job seekers. The user uploads a curriculum vitae once; the system crawls company career pages, ranks new postings against the profile, drafts a cover letter and screening answers, shows exactly what will be sent, and on approval submits it. Recruiter replies land back in the product and move the application forward without the user filing anything.

The interesting result is not a feature. It is that the central assumption was tested before it was built, and it failed. The design copies a class of US product that works because American hiring concentrates into roughly twenty standardised applicant tracking systems, the software employers use to receive applications: a predictable system means a predictable form, and a predictable form is what makes automatic submission possible. Indonesia has no such concentration. Four measurements established that, and the product changed instead of the evidence.

What the product is meant to do

The promise is not volume, it is visibility. What makes an Indonesian job hunt miserable is silence: applications vanish and nobody says why. So the tracker names it. The board carries a group called Didiamkan, for applications sent with no reply after fourteen days. It corresponds to no status in the database and is derived purely from elapsed silence, which makes it the product thesis expressed as a schema decision.

The interface is in Bahasa Indonesia across seven screens, and the target user was corrected mid design by the crawl data. It started as Indonesian technology candidates, but only about eleven percent of visible postings were technology roles, the rest being branch managers, field supervisors and area managers across regional towns. Widening to the general market then reversed the advice the product gives about a curriculum vitae, since a photo and a date of birth are ordinary on a local one.

The gate, and what four measurements found

The design document names crawler coverage as risk number one and sets an explicit gate: seed two hundred employers, measure what fraction of postings have a plain submittable form, and only then decide whether to build the submission engine. Everything downstream stayed unwritten until the number came in.

The first run sampled 78 prominent technology employers and returned 100 percent submittable. That number is a trap. One employer contributed 490 of the 604 postings, and postings can only be counted from platforms exposing a public interface, which are exactly the submittable ones, so the denominator excludes the hard cases by construction.

The honest number was the other one. Only 10 of 78 hand-picked employers resolved to an enumerable job board. Fixing every broken seed address moved that to 14.1 percent and no further, settling that the addresses were never the bottleneck. Broadening the sample to 136 employers across banking, retail, manufacturing, mining, healthcare and state-owned enterprises moved it to 9.6 percent, because 58 general-market additions produced two usable job boards. Large Indonesian employers run bespoke recruitment portals of their own.

One hypothesis remained before committing to dozens of hand-written per-employer adapters. Search engines require a structured job format on the page, so employers wanting visibility should publish it. Across 93 employers, with a careers page located for 82 percent of them and 39 job detail pages sampled, the count publishing that format was zero. That saved a month: it rules the generic extractor out and sizes the real work at about 26 employers reachable through sitemaps or embedded page state, and roughly 50 needing an adapter each.

The recommendation is a hybrid: automatically apply to the genuinely reachable inventory, and for everything else do discovery, ranking, tracking and a prepared cover letter with the person submitting. The features carrying the visibility promise are unaffected either way.

What is built

The backend is Cloudflare-native: Workers with Hono for routing, D1 for relational data, R2 for files, KV for crawler caching, and a browser binding reserved for rendered pages. Resources are provisioned. About 2,700 lines of TypeScript are covered by 160 tests across 13 files, all passing inside the real Workers runtime rather than a simulation of it. Live routes cover sessions, profile storage, and the curriculum vitae pipeline: text extraction from the PDF, structured parsing through DeepSeek under a schema validator, and a hygiene checker that reports which sensitive fields a document contains without editing anything. The model was chosen on unit economics, at a measured parsing cost of roughly USD 0.0004 per document against an alternative projected more than twenty times higher.

The frontend is a React prototype of all seven screens plus sign-in, around 12,000 lines, running against mock data rather than the backend. Every call already carries its real endpoint path, and two headless browser sweeps assert the screens against both a populated and an empty account.

How the Worker and the harness are built

One Worker serves the whole API. src/index.ts mounts a Hono app with three route groups, /auth, /api/profile and /api/cv, over four bindings: D1 for relational data, R2 for files, KV for crawler caching, and a browser binding. KV and the browser binding are declared and referenced by no code path yet, which is the honest state of an unwritten crawler. Every route under /api first passes a middleware that recomputes an HMAC over the user identifier carried in the session cookie and compares it in constant time.

The curriculum vitae upload is the one pipeline running end to end. It accepts a PDF under five megabytes, then writes the bytes to R2 and records a row in D1 before parsing anything, so a later failure cannot lose the file. unpdf, a build of pdf.js that runs inside the Workers runtime, takes the text layer and the first page image count in a single pass; under 120 characters of text means a scan with no text layer and the request stops at 422. The text then goes two ways: a rule pass flags seven categories of sensitive field without ever returning their values, and DeepSeek is asked for JSON that is checked against a Zod schema before any write. A mismatch is a 502 and the stored profile is left untouched. Watch the two exits dropping out of the lower row; they are the only ways an upload fails once the file is already safe.

REQUEST PATH React 19 SPA Vite, mock fixtures Worker and Hono one Worker, four bindings Session check HMAC, constant time Route handlers auth, profile, cv not wired yet CV UPLOAD, POST /api/cv/upload Store first R2 then D1 row unpdf text and images Hygiene flags 7 field types DeepSeek JSON mode Zod schema validate, then write 422 no text layer 502 schema mismatch WHAT IT WRITES TO R2 carikerja-files the uploaded PDF, unchanged D1 carikerja-db profile, work, education KV cache declared, crawler unbuilt Browser binding declared, unused
The live request path and the curriculum vitae pipeline. The file reaches storage before any parsing starts, so both dashed exits fail an upload without losing it.

The harness that produced the coverage numbers is a separate script rather than part of the Worker. It walks the seed list under one request in flight per host, a 500 millisecond gap and robots.txt, tries each discovery strategy in priority order, keeps Indonesian postings and classifies each apply address, defaulting to complex whenever it cannot tell. Employers that return nothing are sorted into five named reasons instead of one zero. Read the lower row as the actual backlog.

ONE EMPLOYER, ONE PASS Polite fetch robots, 500 ms gap Strategy match priority order Enumerate jobs one row shape Indonesia filter location regex Classify apply SIMPLE or COMPLEX no Indonesian jobs TRIAGE, WHEN NOTHING CAME BACK policy-blocked ask the platform complex-login assisted apply only blocked-waf needs a real browser unlocatable verify the seed by hand needs-strategy write an adapter Two structured artefacts per run, not a printout gate-report.json carries every employer and its triage; discovery-todo.md is the same data as a backlog by category
The coverage harness. Every employer that yields nothing is given a named reason, which is what turns a coverage percentage into work someone can pick up.

The decisions that cost coverage on purpose

Three constraints were accepted knowing they reduce reach. The most common tracking platform in the sample forbids automated access in its robots file, so its adapter is switched off with the reason recorded in code, and that alone removed most of the remaining inventory. Aggregators forbidding automated applications are classified manual-only and never queued. And no code path exists between the language model and the uploaded file, so the document submitted is the document uploaded, byte for byte: a route that does not exist rather than a rule in a prompt.

Status

Not shipped and not deployed. What exists is a validated research base, a tested backend covering identity and the curriculum vitae pipeline, and a complete interactive prototype on mock data.

Unbuilt: the crawler, the matcher, the composer, the submission engine, the inbound email worker, and persistence behind everything except profiles. Onboarding is designed but not implemented, and the domain is not registered, which also blocks the decision about where the application lives. Nobody has ever used this, and no number on this page comes from anywhere except the repository.