Inspiration

Most AI resume tools help you create a resume by editing the fields via some form and choosing one of the existing templates. That is helpful, but nowadays merely creating a resume does not help you land a job. Due to so many layoffs and so much talent density in the market, it is getting difficult to create a resume which helps you land those interview rounds. And people are not ready for that. I have been trying to get a job for a while now, and the main issue is that I have not been getting interviews, let alone worrying about passing interviews!

MagicResume started from a simpler frustration: how to create a resume which does not get ignored, passes those ATS scores, and is not discarded amid thousands and thousands of applications for every other job.

There are already so many resume products which allow you to import, edit, and choose from existing resume templates, like flowcv.com. But there is no platform which allows users to create a winning resume. The resume which actually allows them a chance to interview! For the All Things Agentic hackathon, I built a collaborative career partner: something that remembers your work over time, asks the questions that uncover real impact, and turns each job into an application you can actually stand behind. It understands you. So, even if you are missing some requirements, it will suggest action items to close those gaps and prepare you and your resume for that job. It is your true career partner.

The thesis I kept repeating internally: a resume builder sees one document. MagicResume remembers a career.

What it does

MagicResume is your career partner, which helps you get those interviews at your dream companies. It is not a generic resume editor with templates or an ATS vendor simulator.

Career Partner. An AI agent which remembers all your strengths, skills, info, and experience over time and stores them in a career memory. A career memory is simply a pointer which tells something about the career accomplishment or milestone of the candidate, be it skills, or experience, or courses, or any other such information about the candidate. They are persisted in a Postgres database, which allows the partner to learn and persist the user's information over time and truly understand what their strengths are and where they lack. When you add your resume, the career partner parses it and adds all the information from the resume as career memory, which can be verified by the user. So, importing a PDF or screenshot of a resume is one way to add to your career memory. Another way is to tell the partner about a role in chat. You can edit, annotate, or delete a fact. Confirmation is optional reassurance, not a gate — interviewers verify claims, not this app. What is enforced: the model may select and reorder your material, but it cannot invent or inflate it.

Applications workspace. An application workspace which has a job tracker and a workspace where you can paste a JD, upload Markdown/text/PDF, or fetch a public URL (SSRF-guarded), and then the MagicResume agent takes over and, based on the JD's requirements and your abilities (via career memory), identifies whether your current skills are a match or have gaps for that specific job application. If it is a perfect match, it says that you can go ahead and apply for the job, and also generates a cover letter tailored to the JD and your abilities. If there are gaps, it tells you those gaps and the work items to fix those gaps (in terms of checkbox items) so that you can be prepared for that specific job.

None of this is a request you have to sit and wait on. Tailoring runs as a durable background job: seven steps — reading the job description, comparing it with your career memory, planning the work that closes the gaps, writing the cover letter, building the application resume, building the tentative resume, and checking everything holds together — each queued through Cloud Tasks as its own request, each saved to the database the moment it succeeds. Close the tab, lose your connection, or catch a deploy mid-run, and the next attempt reloads the application and carries on from the first step that has no output yet. While it works, you see the step it is actually on, not a blind spinner.

After pasting the job description, each application job produces:

  • an application resume grounded only in facts you supplied
  • a seven-block cover letter (150–320 words: greeting, hook, why them, evidence, remote proof, low-friction close, signature)
  • work items on the gaps you have
  • Tentative Resume: a very nice feature which allows users to visualise how their resume will look once they have closed the gaps for this job. It is tailored to this JD, not just annotated — the headline and summary are rewritten in the job's own language, entries are reordered so the strongest matching evidence lands in the first third of the page, and the work items you have not done yet are written as completed projects. It carries a TENTATIVE watermark and is downloadable on purpose: the watermark is the honesty signal, not a lock.

Now it is up to the user to edit the resume via Resume Studio (explained next), update the resume, work on the gaps, and get better in their job journey. Once those work items are completed, the tentative resume is promoted into your application resume, the watermark is dropped, and you can actually apply and succeed.

Resume Studio. A live canvas with bounded layouts (single, sidebar, two-column, three-column), imported visual fidelity, ATS & recruiter-readiness scoring, and Auto-fit to one page. Generated applications take design and contact from your master resume, not whoever happens to be open in the editor.

Identity and third-party work. The account has a real profile (name, username, contact, links). Career memory only ingests your resume. You can still import, fit, and export someone else's (a family member, for example) in a separate section — that work never contaminates your memory or JD tailoring.

How we built it

Architecture

I built MagicResume as a Next.js app on Cloud Run, with a server-managed Genkit career agent on Vertex AI Gemini 3.8 Flash in the global location. I made the model id come from GEMINI_MODEL, so rolling back to a previous Flash release is an environment change rather than a redeploy. Every Gemini call goes through one profiled wrapper with a per-task thinking level across twelve profiles — LOW for résumé import, ATS, and compression; MEDIUM for JD parsing, work-item planning, and partner chat; HIGH for gap analysis, the adversarial gap audit, resume synthesis, the destination resume, cover letters, and layout retries. Cheap work stays cheap; only the reasoning-heavy steps pay for thinking.

I moved the long AI work out of the HTTP request entirely. Tailoring a job makes six Gemini calls and takes six to eight minutes. Pasting a JD now creates a row in application_job and returns 202 with a job id in about 40 ms. The chain runs as seven durable steps — intake, gaps, goals, cover letter, application resume, destination resume, finalize — and I deliver each step as its own Cloud Tasks request to an OIDC-authenticated endpoint, so the queue owns retries and redelivery and a crash costs at most the step in flight.

I made every step a function of database state rather than of in-memory values from the previous step. Each step reloads what it needs and commits its own output, and completedSteps records what already succeeded. A step whose output already exists is skipped unless the job is a forced rebuild, so a retry costs seconds instead of another eight minutes of Gemini. I reclaim a job abandoned mid-step after a six-minute lease, and without Cloud Tasks any job still marked running at boot is treated as an orphan and requeued.

  • Neon Postgres (Drizzle) owns canonical facts, sources, applications, learning goals, feedback, templates, profiles, and the job ledger. I run Postgres 17 in Docker locally through the same driver and the same DATABASE_URL switch, so local and production execute identical SQL. I kept an embedded PGlite fallback for zero-setup starts, but it is no longer the default — PGlite is Postgres compiled to WASM inside the dev-server process, and killing next dev mid-write leaves the data directory unrecoverable.
  • Firestore stores user-scoped Genkit session snapshots — conversation state, not career truth.
  • Identity comes from Auth.js / Google OAuth server context. My tools never trust a model-supplied user ID.
  • AI is server-only. Client components never import Genkit. I caught this the hard way when a cover-letter import pulled fs into the browser bundle and the production build failed.
  • PDF export is window.print() plus a hidden print root. No server Chromium on Cloud Run.
  • The service is --allow-unauthenticated because users need it, so Cloud Run does not enforce the queue's OIDC token for me. I verify the token in application code — signature, audience, and the invoker service account — because the X-CloudTasks-QueueName header is spoofable on a public URL.
  • I deploy straight from local source through Cloud Build: build the image, run migrations as a Cloud Run job, then roll out the service. Secrets live in Secret Manager and never enter the image.
  • Telemetry keeps model, thinking level, latency, token counts, and retry outcome; I do not log prompt or resume payloads.

The page-fit algorithm (Pretext)

Auto-fit is the piece I am proudest of as engineering, because it is not an LLM guessing at CSS.

The problem: a resume might be too tall for one A4 or US Letter page. I need to shrink just enough to fit, without destroying readability. The knobs are font size, line height, section spacing, and margins. They move together so the name stays larger than the body.

Hard floors. The engine never goes below body 8.5pt, line height 1.05, margins 14pt, spacing 50%. If it still overflows, it reports "too long" and draws a page-two marker instead of silently crushing the type. Content reduction is a separate, explicit "Make it fit with AI" step — and that step may shorten approved text, not move regions or restyle the design.

The virtual ruler. Trying hundreds of configs by rendering the real page would be too slow. lib/fit/layout.ts walks the same block tree as lib/render/ResumePage.tsx (name → contact → summary → sections → entries → bullets) and sums heights. Those two files are kept in lock-step on purpose: the measurer must predict the renderer.

@chenglou/pretext is the text oracle, not the compressor. A hidden canvas (OffscreenCanvas or a DOM canvas) calls the browser's own measureText, so glyph widths match what the user will see. prepare() does the expensive font work once per (text, font); after that layout() is pure arithmetic — word/grapheme breaks via Intl.Segmenter, CJK/emoji widths, hyphenation points. That is how I can search hundreds of configs per keystroke. Pretext is browser-only; Node tests use a stub measurer. Fonts are loaded with document.fonts.load before measuring, because a fallback font would lie.

Search. Phase A finds the largest font that fits (step down 0.25pt), then binary-searches line height, spacing, and margins back up toward the theme so the result looks as natural as possible. Phase B repeats at tighter floors. Later I made fitting two-axis: not just vertical squeeze, but also margins, column ratios, gaps, and padding, without stretching glyphs or changing topology.

Multi-column. Measuring each region at its own width was not enough. A right-sidebar export would spill the main column onto page two while the sidebar sat half empty. I added a Pretext-scored flow optimizer that tries whole-section assignments across sidebar / two- / three-column regions and minimizes the tallest parallel flow before touching typography. Contact lines can wrap horizontally when that reclaims a row. Placement overrides live in FitConfig, not in the editable ResumeDoc, so turning auto-fit off restores the last arrangement the user saw, and preview / print / save / share stay identical. Custom imported layouts stay fixed for source fidelity. After browser calibration I raised the virtual-vs-DOM drift cushion from 4px to 16px so "one page" in the engine actually means one page on screen.

Other design decisions that shaped the product

  • Bounded document, not generated HTML. Import uses the PDF page image as design authority (split-color names, labeled contact grids, rules, bands, accents). The model returns tokens; my normalizer clamps them. I report graphic-heavy or freeform layouts as unsupported instead of silently flattening them. The model never supplies arbitrary HTML, CSS, or fonts.
  • Region graph. Resumes are versioned rows/columns/regions, not a single vertical stream. One geometry resolver drives preview, print, share, templates, and the fit oracle.
  • Two resumes, and the watermark is the honesty signal — not a lock. The application resume carries only what career memory supports. The tentative resume is that same evidence re-tailored to the JD with the work items written as completed projects, stamped TENTATIVE. I made it deliberately downloadable: my integrity check fails the artifact if it is not exportable, and separately fails it if the word "TENTATIVE" appears anywhere in the prose rather than as the stamp. Complete the work items and I promote that document into the application resume with the stamp dropped, because by then the claims are true.
  • Nouns are not synonyms. I made gap classification deliberately pedantic — queues are not Temporal, REST is not GraphQL, using Node.js is not Node internals — enforced by deterministic rules that run after the model, plus an adversarial second pass that may only downgrade a classification, never upgrade one.
  • Every gap the user picks becomes real work. My automatic plan only writes work items for blocking must-haves, so I made choosing any other gap — a nice-to-have, or one that is merely transferable — create one on demand. "On your list" always puts something on the list, and it appears instantly: I write the placeholder in about 40 ms and replace it with a model-written version in a separate request.
  • Gaps and goals are two calls, not one. A single high-thinking response asked for a row per JD requirement and a work item per gap ran out of output budget mid-object, and the whole response was discarded. Split apart, a goals failure degrades to deterministic defaults instead of losing the analysis.
  • Content-free templates. Imported designs become private templates (layout + theme fingerprint only). Publishing is explicit; another candidate's contact and bullets never ride along.
  • Master resume. One of your resumes supplies design and contact for every generated application. Off-allowlist fonts still work as a contact source; they just yield no reusable design.
  • Tailor from memory, not the open editor. I took baseDoc out of the request. The skeleton is composed server-side from career memory + master design + profile contact, so a third-party resume cannot leak into your application.
  • Trust without friction. I trust user-supplied facts and keep memory editable, so mistakes are corrected by the user rather than by a checkbox. The invariant I enforce instead is that the model can select and reorder your material but never invent or inflate it.
  • Cross-industry work, not a software-only schema. Experience is employer/practice plus nested engagements (client, product, project, clinical, teaching, volunteer, …) with confidential/omitted organization labels and a bounded Markdown subset.
  • ATS is a labeled heuristic. 55% deterministic structure/readability + 45% Gemini impact/clarity/keywords. I show both, plus matched/missing JD terms with exact resume citations. It is "ATS & recruiter readiness," not a named vendor score.
  • JD URLs are untrusted data. I strip scripts and styles; refuse private, loopback, link-local, and cloud-metadata hosts; bound the length; and rate-limit per user so the server cannot be used as a general-purpose page fetcher. Fetched pages are information, never instructions. Thin or walled pages ask for a paste instead of guessing.
  • Cover letters are structured and gated. Seven blocks, 150–320 words, at least two evidence bullets each carrying a number and a named system mapped to the JD's own nouns. I made explaining the company back to the company a failed quality check, same as a missing close. A letter failure never eats the resume.
  • Editorial chrome vs paper. I rebuilt the app as a dark career studio (Lora / Work Sans, terracotta, persistent sidebar). Resume paper stays white. APIs and export rules did not change with the redesign.

Accomplishments that we're proud of

  • A deterministic one-page fitter that uses the browser's real font engine via Pretext, searches hundreds of configs, and refuses to lie about overflow.
  • Layout fidelity without executable design. Practical single-, sidebar-, two-, and three-column PDFs keep their regions, bands, and accents; unsupported graphics are named instead of faked.
  • Long AI work that survives real life. Tailoring returns in about 40 ms with a job id instead of holding a connection for eight minutes, then runs as seven durable steps through Cloud Tasks. I proved it by SIGKILLing the server mid-build: on restart the job resumed at the exact step it lost, with the finished steps intact, and ran through to completion with correct output. Closing the tab does not cost you a build.
  • Idempotency that saves real money. A step whose output already exists is skipped unless you force a rebuild, so a retry costs seconds instead of another six Gemini calls.
  • Memory-sourced tailoring. Applications are composed from what the partner remembers about you plus the master resume — not from the tab you forgot was open.
  • A gap analysis that argues with itself. Deterministic noun rules run after the model, and an adversarial second pass may only downgrade a classification, never upgrade one — so "queues" never quietly becomes "Temporal."
  • Explainable ATS, not a fake vendor score.
  • A seven-block cover letter that mirrors JD nouns, requires at least two numbered evidence bullets from career memory, and treats explaining the company back to the company as a failed check. Enforcing that took it from 61 words to 172–202.
  • Production on Cloud Run + Vertex AI + Cloud Tasks + Neon + Firestore, with Google sign-in, tenant isolation, OIDC-verified job delivery, and payload-free traces.
  • 178 tests covering the parts that are easy to get quietly wrong: resume-from-crash, skip-when-satisfied, forced rebuild, retry budgets, double-claim protection, integrity validation, gap-contract noun rules, and the fit engine's readability floors.

What we learned

  • Pretext's split (prepare vs layout) is the difference between a slider and a search. Canvas once, arithmetic forever.
  • Models are untrusted until a schema and a validator say otherwise. Structured output is not the same as a grounded resume.
  • A truncated response can look like a valid one. Thinking tokens count against maxOutputTokens, so a high-thinking call on a small budget stops emitting mid-JSON. Because every field in my schema was optional, the partial object parsed cleanly and validated as a complete cover letter — 61 words that looked deliberate. Making required fields required turned a silent corruption into a loud failure I could retry.
  • Schema complexity is a budget too. Sending a deeply nested document schema to Vertex was rejected in under a second, long before generation, and the failure surfaced downstream as mysteriously empty output. Flattening the generation grammar and assembling the document in code fixed it.
  • A multi-minute AI chain is the wrong shape for one HTTP request. Not because it is slow, but because a dropped connection, a deploy, or a scale-down strands it. Durability is a better answer than a longer timeout.
  • Idempotency has to be a rule, not an afterthought. Making every step a function of database state rather than of the previous step's variables is what makes resuming possible at all — and it is much harder to retrofit than to decide up front.
  • Run the deterministic pass before asking the model. My noun rules downgraded capabilities after the model had already planned work items, so gaps that only became "missing" during that pass never reached it and fell back to generic placeholders. Reordering the two removed the placeholders entirely.
  • Identity and tenancy beat prompt cleverness. Whose resume is this? Whose memory? Whose JD? Get those wrong and the agent is dangerous.
  • Floors are kinder than "make it fit." Readability floors plus an honest overflow state beat a 6pt resume.
  • Do not import server AI into the client. Type-only convenience is how you ship Vertex to the browser.
  • Tests pass, users still find things. The sign-out control existed only on one screen, and no test noticed because nothing asserted it should be everywhere. I found it in production. Clicking through the running app is not optional.

What's next for MagicResume

  • Move the partner-chat build onto the same durable job path. Pasting a JD is already a background job; asking the partner to build an application in chat still runs synchronously, and it should not.
  • Cut the six-to-eight minute tailoring time by running the steps that do not depend on each other in parallel, instead of strictly in sequence.
  • Notify people when a background build finishes, rather than making the open tab poll for it.
  • Web-search grounding for cover letters, still treating retrieved pages as data, never as instructions.
  • True repeating regions for multi-page CVs, and honest support (or better approximation) for graphic-heavy designs.
  • Multiple career memories, paid third-party resume management, and real billing — the free/pro field is not billing yet.
  • Feedback that visibly changes the next application, not just the current screen — the loop the category is supposed to prove.

The long-term product is the same as the thesis I started with: help people become the candidate, instead of pretending they already are.

Built With

Share this project:

Updates

Submission history