Inspiration
Job seeking is three disconnected tools: a job board, a CV editor, and a mock-interview app. None of them share context, so you explain yourself three times over and still apply with a CV that undersells you.
But the thing that actually started this project is smaller and more annoying than that. The strongest evidence for a job is usually not on your CV. It's the k3s cluster you run at home, the incident you handled at 2am, the migration you led that nobody wrote down as a project. You'd say all of it out loud in an interview. You never thought to write it down — and no tool has ever asked you.
Every "AI CV tool" I tried does the same thing: it takes the document you already have and makes it sound more confident. That is the wrong move twice over. It can't add what isn't there, so it fills the hole with fabrication instead — invented metrics, inflated titles, achievements that will not survive the first follow-up question.
CareerCopilot asks first, and only then writes. And it never writes anything you didn't say.
What it does
CareerCopilot is one agent with three specialists and a single shared memory of who you are. The flow is a loop, not a pipeline:
Scout → Coach (discovery) → Scout → Tailor → Coach (rehearsal)
1. Scout clarifies, then shortlists. Before it searches it asks a genuine clarifying
question — "you said remote; is hybrid acceptable?" — using ADK's get_user_choice, rather
than guessing at an ambiguous preference. It then ranks real job adverts and reports not
just a match score, but the specific requirements you cannot currently evidence.
2. Coach, in discovery mode, interviews you about exactly those gaps. It is not grading you here; it is collecting evidence. Everything you say is stored as a verbatim quote with provenance, so a claim can always be traced back to the sentence you said it in.
3. Scout re-ranks — and the shortlist visibly changes, because the evidence changed. In the demo run, one elicited fact moves a role from 40 → 60 and another from 45 → 65. Then you decide what happens next: commit, search again, or widen. Capped at three rounds, and the agent never ends the loop on your behalf.
4. Tailor proposes; you decide. Only once you commit does it rewrite anything. Every proposed line is checked against your CV text plus those elicited quotes before you ever see it — a rewrite with no traceable source is dropped, not shown. What survives is offered as a before/after you accept, reject, or edit. A phrasing you reject is stored and never offered again, for this role or any other.
5. Coach, in rehearsal mode, runs a mock interview against the committed posting using the tailored CV, drilling the gaps that are still genuinely gaps.
You can keep several roles in play at once (capped at four) while the Tailor and Coach work on one at a time. The second application costs a fraction of the first: the discovery round you sat through for role one already counts for role two.
The stance, enforced in code rather than asked for in a prompt: it proposes, it never acts. It never submits an application. It never invents experience, employers, dates, or numbers.
How we built it
Google ADK (google-adk 2.7.1). A root LlmAgent coordinates three sub-agents, and the
composition is a deliberate choice rather than a default:
- Scout and Tailor are
single_turnagents, called like tools. They run inline in the root's session, so the state they write is shared, not copied. - The Coach is a
taskagent. It needs to hold a conversation for an entire round — question, answer, follow-up — and then hand back deliberately. - There are no peer-to-peer edges. All coordination goes through shared session state, persisted to Firestore and keyed on user id, hydrated and saved by root-agent callbacks. A session survives a restart, and a redeploy.
Gemini 3.7 Flash on Vertex AI (global location) is the main model, with
gemini-3.5-flash-lite as a cheap tier for structured CV extraction. Cloud Run hosts the
service, built from a Dockerfile, with a rollback script that re-points traffic at an older
revision in seconds.
The numbers are Python, not model output. Match scoring, seniority inference, and the shortlist delta are all computed deterministically, so the model explains a number it did not invent. A posting's score is the fraction of its hard requirements you can evidence, with nice-to-haves worth a quarter of a requirement each — a tiebreaker, not a route to a high score on its own:
$$ \text{score} = \min\left(100,\ \left\lfloor 100 \cdot \frac{|R_{\text{met}}| + 0.25\,|N_{\text{met}}|}{|R|} \right\rceil\right) $$
where $R$ is the posting's required skills, $R_{\text{met}} \subseteq R$ those your evidence covers, and $N_{\text{met}}$ the nice-to-haves it covers. Evidence is the union of your CV tokens and the tokens of everything you told the Coach — which is precisely why a discovery round can move a score, and why the move is explainable to the character.
Tools are narrow, typed, docstringed Python functions; ADK derives the schema from them, so the docstring is the spec the model reads. Anything the model shouldn't improvise — IO, scoring, search — lives in a tool rather than in a prompt.
Python 3.12 and uv throughout, with ruff and pytest — including live integration tests
that hit Vertex and Firestore for real, because those are the only automated proof the
credentials still work. The surface is ADK's built-in web UI, which conveniently gives a
live state inspector next to the chat, so the shared state is visible rather than claimed.
Job data: real UK adverts from Arbeitnow, a free, keyless aggregator over public ATS feeds, snapshotted offline into the repo. No API key, no quota, no live-search failure mode mid-demo, and no scraping. The two dozen adverts that ship are curated for a spread of fit — strong matches, real gaps, and off-target noise — so the discovery loop has something to actually work with.
Challenges we ran into
Ranking by fit alone produced a useless agent. The first end-to-end run against the live model recommended four roles the user already matched perfectly: scores of 100, empty gap lists, nothing to interview about. Technically correct, and completely pointless — a recommender that only suggests lateral moves. Fit and desire are different axes. The target role now filters the pool before fit orders it, and the discovery loop suddenly had something to bite on.
Losing a user's decision is the worst thing this product can do — worse than a bad draft, because a bad draft is at least visible. Drafting again for a role used to overwrite the proposal set wholesale, silently discarding every accept and reject the user had already made. It now retains every settled proposal, skips re-offering a wording already put to you, and reports back what you'd already agreed to.
The model quietly dropping a constraint. It once passed an empty seniority value even though the user had just said "I'm mid-level" — silently disabling the filter and letting a staff-level role onto a mid-level shortlist. Seniority is now inferred from the CV's years of experience when omitted. This is a class of bug you cannot find by reading code; it only appears when you drive the whole thing against the live model.
A denied skill scoring as evidence for itself. "I've never touched Terraform" contains
the token terraform. Naive token matching therefore counts a denial as evidence for the
thing being denied. Elicited answers are now split into clauses and denials are detected, so
what you rule out actively blocks a match rather than creating one.
Latency — and, more usefully, what does not fix it. A demo turn runs 30–47s. Context caching was the obvious suspect, and measurably is not the cause: the spread between two runs of the same configuration is wider than the difference between configurations. The variance lives in the turns that generate a lot of text — the Tailor turn measured 37.8s once and 127.6s the next, same plot, same model, same prompt. The conclusion was to stop optimising it and meet the video's time budget in the edit rather than at the keyboard.
Mode bleed in the Coach. One agent, two very different jobs: discovery must never grade, and rehearsal must never fish for biography. Nothing in code catches this — it only shows up when you sit through a whole run and notice the interviewer being weirdly encouraging.
Accomplishments that we're proud of
The adaptation is real and it is visible. The clearest thing in the demo is the second shortlist differing from the first, with the delta computed in Python and attributable to a specific sentence the user said. That's not a model being agreeable; that's evidence changing a number.
No-fabrication is a mechanism, not a promise. "Never invent experience" as an instruction is a suggestion the model may or may not follow. As an evidence check that runs before the user sees the line, it's a rule. Unsourceable rewrites are dropped silently and the underlying gap is reported honestly instead.
Rejections compound. A phrasing you turn down is never offered again — including for a completely different job, months of shared state later. That's the kind of memory that makes a tool feel like it's on your side.
The second application is nearly free. This fell out of the architecture rather than being designed: because the elicited facts, rejected phrasings, and profile are global while the drafts are per-role, everything you invested in role one carries to role two.
It's genuinely deployed. Cloud Run, Firestore, real Vertex calls, integration tests, and a rollback script — not a local demo with a screenshot of a console.
What we learned
Shared state is the product, not the plumbing. Every good moment — the re-ranked shortlist, the rewrite that traces to something you said out loud, the cheap second application — is the same mechanism paying off again. It's also completely invisible unless the UX says it out loud, which turned out to be a design requirement rather than a presentational one.
A collaborative agent needs somewhere to put the "no". Capturing a rejection is trivial. Making a later turn demonstrably behave differently because of it is the actual work, and it is precisely the difference between a partner and a chatbot.
Derive state; don't track it. Where a role stands is computed from the artefacts that exist — no rewrites means not started, undecided rewrites means awaiting the user — rather than stored in a status field. A status field is a second source of truth that drifts the first time a tool half-fails.
Guardrails belong in tools, not prompts. Every rule that mattered ended up as deterministic Python running either side of the model.
Drive it end-to-end against the live model, early and often. Every genuinely important bug in this list was found by a scripted run through the real agent, and none of them by unit tests or by reading the code.
What's next for Career Copilot
- Assembly and cover letters — turning the accepted lines into a finished document per role, and drafting a cover letter under the same evidence rule.
- A Gemma pre-ranking pass, cheap enough to run over a corpus of thousands rather than dozens, with Gemini reserved for the roles that survive it.
- Live job-board integration behind the same snapshot interface the corpus already uses.
- Answer scoring in rehearsal — deliberately cut for the deadline, since question generation and follow-ups are what make the loop work; scoring is what makes it useful the tenth time.
- Longitudinal memory across a whole search — which gaps keep recurring across every role you want is, on its own, a career-development signal no CV tool currently gives you.
Built With
- ai-agents
- arbeitnow
- artifact-registry
- cloud-build
- cloud-run
- docker
- fastapi
- firestore
- gemini
- gemini-3.7-flash
- google-adk
- google-cloud
- google-genai
- job-search
- llm
- multi-agent
- pytest
- python
- rag
- ruff
- starlette
- uv
- uvicorn
- vertex-ai
Log in or sign up for Devpost to join the conversation.