Inspiration
Every job-search tool I'd used was optimizing the wrong verb. They all wanted to help me apply; apply faster, apply to more, auto-fill the form. But applying was never my problem. The problem was sending an application, getting rejected, and having no idea why. Was I underqualified? By how much? In what dimension? The tools were silent on the only question that mattered. So Sage started as a reframe: stop treating job-seeking as a transaction to accelerate, and start treating it as a development problem to measure. Don't help people apply to more jobs. Help them become the person those jobs are looking for, and show them concretely what stands between the two.
What it does
Sage reasons about the gap between your experience and a target role, then turns that gap into a direction.
You provide your resume, then take one of two paths:
- Target a role type: pick something like "AI/ML Engineer, New Grad," and Sage reasons across a shortlist of postings for that role at once, producing a readiness assessment for the role type and, as the centerpiece, the skill gaps that recur across most of those postings but are missing from your resume.
- Paste a single job description: get a focused analysis of that one posting.
Every claim Sage makes is grounded and verified.
A fit score with its reasoning exposed. Not 78% in a vacuum, but a score calibrated against explicit anchors, with reasoning that cites specific resume items:
| Score | Meaning |
|---|---|
| ≈20 | Fundamentally different domain, or nearly every core requirement missing |
| ≈50 | Right domain, but several core requirements or a large seniority gap |
| ≈ 75 | Solid match with one or two real gaps |
| ≈ 90 | Meets or exceeds essentially all requirements |
Strengths quoted verbatim from your resume. Every strength cites the exact text that proves it, and that citation is programmatically checked to exist in your resume before it is ever shown.
Buried strengths. Real experience you have but under-sold, with guidance on surfacing it honestly, never an invented accomplishment.
Recurring gaps as directions, not specs. For a gap appearing across most postings in the shortlist, Sage says "build something that forces you to handle streaming data under backpressure" rather than "build a Kafka pipeline with three consumers and a Postgres sink." Coaching, not doing it for you.
Your resume is parsed in your browser, held in memory, and never stored on a server.
How I built it
The architecture rests on one insight that kept the build tractable: gap analysis, fit scoring, cross-posting readiness, and project directions are all the same underlying operation. A model reads a candidate profile plus one or more postings and reasons about the delta. So I didn't build five engines. I built one reasoning call and rendered its structured output several ways.
The reasoning contract came first. Sage deliberately doesn't use embedding similarity for matching. Similarity can rank, it can tell you a posting is numerically close to your resume, but it can't make the judgment a hiring manager makes in three seconds: this person has done this at internship scale, and the role needs production scale. That distance is the whole product, so the architecture is built around reasoning rather than retrieval.
Before any UI, I designed a strict JSON schema and a system prompt for GPT-5.6 using structured outputs, so the model must return parseable sections rather than a text blob. The prompt does real work: it forces reasoning about distance rather than keyword overlap, calibrates the score against the anchors above so results don't cluster in the comfortable \(60 \leq s \leq 85\) band, and actively fights the model's instinct to hand over full project specs.
The retrieval layer is deliberately dumb. Role types are a fixed, small dropdown, so retrieval is literally:
postings.filter(p => p.role_type === selected)
No embeddings, no search index. The cheap filter narrows the dataset to a shortlist; the model reasons over the shortlist. The whole dataset never gets pushed through the model.
Two schemas, not one with a flag. The single-posting flow uses a schema that structurally omits recurring_gaps. A single posting is physically incapable of emitting a recurrence claim. That guarantee lives in the type system, not in a hopeful prompt instruction.
The dataset is a curated representative sample: 18 postings across three role types, with requirements modeled on real-world listings rather than copied from them. I checked convergence deliberately, because the readiness feature only works if postings within a role type actually share requirements. They do:
| Role type | Recurring requirements |
|---|---|
| AI/ML Engineer | Python (6/6), deploy-to-production (6/6), evaluation & CI/CD (6/6), PyTorch (4/6) |
| Backend Engineer | REST APIs (6/6), distributed systems (5/6), SQL (4/6) |
| Data Engineer | ETL (6/6), warehousing & SQL (5/6), Spark (4/6) |
The stack: Vite, TanStack Start, and TypeScript, strictly typed end to end, with Tailwind carrying a dark, instrument-like design system: gold as the single brand color, functional colors used only where they carry data meaning. Resume parsing runs client-side with pdf.js and mammoth. The model call lives in a server function so the API key never touches the browser.
On AI-assisted development: the reasoning core; the strict two-variant schema, the verification layer, and the system prompt in sage-reasoning.ts was built in Codex. My Codex allowance was exhausted partway through the build, so the scaffold, dataset, resume pipeline, server boundary, and UI were built with additional AI tooling. Throughout, I owned the product decisions: verify-don't-trust, directions-not-specs, the two-schema split, the recurrence threshold, and prioritizing the readiness view as the centerpiece.
Challenges I ran into
The model counts, and the model lies about counting. My first instinct was to let the model report "this gap appears in 5 postings." That cannot be trusted, cross-item counting is exactly what LLMs fabricate confidently. So Sage never accepts a bare count. For each recurring gap the model returns posting_ids, the specific postings it claims support the gap. Code then validates every ID against the shortlist and derives the count from what survives:
$$ V = R \cap S $$
$$ |V| \geq 4 $$
where \(R\) is the set of IDs the model returned and \(S\) is the shortlist. The recurrence threshold is applied to that verified number, never the model's word. The model points; the code counts.
Verifying evidence through the wreckage of PDF extraction. I wanted every strength to quote the resume verbatim so nothing could be fabricated. Naively that's resume.includes(evidence). But extraction mangles text, like straight quotes become smart quotes, hyphens become en-dashes, spaces become non-breaking spaces, whitespace collapses unpredictably. Correct citations would false-negative constantly. So matching normalizes both sides first, with a source map that confirms the match on normalized text but returns the original resume substring for display. Anything still failing is dropped and logged, so I can tell a hallucination apart from an extraction artifact.
A silent-failure bug that defeated the whole point. The success path originally hardcoded a "complete" status and never reconsidered it against verification results. So a response where fabricated evidence had been silently discarded still reported "complete", quietly defeating the entire reason for verifying. Now, if any evidence or recurrence claim required adjustment, the run is downgraded to "degraded" and says so. A degraded run means the safeguards fired; what you're seeing are the survivors.
The model wants to do your homework. Left alone, it answers "what should I build?" with a complete, copy-pasteable project spec which is the opposite of the coaching thesis. The prompt has to suppress this with both a positive and a negative example, because a single instruction isn't enough to overcome the default.
Keeping parsing honest. "Parse to structured text" is tempting to over-engineer into "summarize and rephrase." But if parsing rewrites a single bullet, verbatim evidence matching breaks at step one. So parsing normalizes whitespace and never alters wording.
Accomplishments that I'm proud of
- A tool designed to say "I don't know" rather than make something up. The verification layer drops unproven claims instead of showing them, and reports when it did. In a space full of confident hallucination, building something structurally honest is the achievement I care about most.
- Guarantees in the type system, not the prompt. Making a single-posting analysis structurally incapable of claiming recurrence is correctness by construction, not by hope.
- One engine, many views. Resisting the urge to build five features and instead building one reasoning call rendered several ways kept the codebase small and consistent.
- A dataset built to be testable. Rather than grabbing postings at random, I checked requirement convergence within each role type first, so the readiness feature has real material to find.
What I learned
- Reasoning is a different product than retrieval. The hard part of an LLM feature usually isn't "can the model do it" but "how do I trust what it did." Most of Sage's engineering is verification, and that's where the product actually lives.
- Structured outputs change the ceiling. Forcing JSON with a strict schema is the difference between a chatbot and an instrument. It's what lets a UI render distinct, trustworthy sections instead of a wall of text.
- Prompts encode product decisions. Score calibration anchors, directions-versus-specs, cite-verbatim-or-omit. These aren't prompt tricks, they're the product's values written down.
- Constrain the model with code, not hope. Everywhere I let the model self-report (counts, status and completeness), it eventually let me down. Everywhere I moved the guarantee into code or the type system, it held.
- Working with AI tooling is a decision-density game. It removed the friction of scaffolding and boilerplate, which meant the scarce resource became my judgment: where to draw the server boundary, what to verify, what to refuse to invent.
What's next for Sage
- Resume-line reframing, expanded: beyond flagging buried strengths to proposing honest side-by-side rewrites, always grounded in real experience.
- Application-answer drafting with reasoning shown: tailored answers grounded in actual resume evidence, with the model's reasoning for choosing each experience exposed, and review-before-submit as a hard guardrail.
- Readiness over time: persist a readiness snapshot with consent, so you can watch a target role's score climb as you close gaps.
- A larger dataset across more role types and seniorities, keeping the verified-count-never-trusted-count discipline.
- Surfacing the verification log to the user: let people see exactly what was checked, kept, and dropped.
Built With
- codex
- github
- gpt-5.6
- json-schema
- mammoth
- node.js
- openai
- pdf.js
- react
- render
- srvx
- tailwindcss
- tanstack-start
- typescript
- vite
Log in or sign up for Devpost to join the conversation.