Building CareerCraft Studio
An AI career platform I built solo — a structured profile that an agent team turns into tailored resumes, cover letters, and real compatibility analysis. The what, the why, and the high-level how.

Applying to jobs is a data-transformation problem that everyone solves by hand. You have one messy source of truth — your actual career — and every posting wants a slightly different projection of it. So you copy last month’s resume, swap a few bullets, guess at the keywords the posting cares about, and fire it off. Repeat thirty times. The generic version underperforms and the tailored version costs an hour you don’t have.
I didn’t want a resume builder. Those just give you a nicer text editor for the same manual work. I wanted the transformation itself to be the product: keep my career as structured data once, and generate the right projection of it for each posting on demand. That’s CareerCraft Studio. It’s live at careercraft.studio.
“I didn’t want a resume builder. Those just give you a nicer text editor for the same manual work.”
What it actually does
You build a structured professional profile — work history, skills, education, projects, achievements, links — and you save the jobs you’re interested in. From that, the system generates tailored resumes, tailored cover letters, and a real compatibility analysis of your profile against a specific posting.
Conversational building
Describe a job in a sentence and an agent turns it into structured work history — no forms required.
Tailored materials
Resumes and cover letters generated for one specific posting, from a single structured profile.
Real compatibility analysis
See how your profile matches a posting, skill by skill — and where the genuine gaps are.
Resume import
Paste text or upload a PDF; it's parsed into the same normalized model everything else reads from.
Normalized skills
Aliases resolve to one canonical skill, so matching a job is exact rather than fuzzy.
Save jobs from a URL
Paste a posting's link and it's imported and analyzed server-side, safely.
The part that makes it feel different is how you build the profile. You can fill out forms if you want, but you can also just talk to it: “Add my job at Tech Corp where I led a team of five and shipped the billing rewrite.” An agent pulls the company, title, and achievement out of that sentence and writes it to the right tables. Or you paste in an old resume — or upload a PDF — and it gets parsed into the same structured shape. However the data gets in, it lands in one normalized model that everything else reads from.
- Company
- Tech Corp
- Role
- Software Engineer
- Team size
- 5
- Achievement
- Led a team of 5 building React applications
- Skills
- ReactLeadership
The stack, and why type safety is the point
The stack is deliberately boring where boring buys reliability, and modern where it buys leverage: Next.js 16 (App Router) and React 19 for the shell, tRPC v11 for the API, Prisma 7 / PostgreSQL (with pgvector) for persistence, NextAuth v5 for auth, and LangChain v1 + LangGraph + Gemini for the agent layer. Stripe handles subscriptions and usage quotas, Upstash Redis handles rate limiting, and Vitest keeps it honest.
The thesis holding this together: it’s type-safe end to end, from the Postgres schema to the React Query hook. tRPC gives me one source of truth. Change a Prisma model and I get compile-time errors everywhere that change actually matters — not a runtime surprise in production three weeks later.
// Illustrative — the real routers are split by domain.
// Change this Prisma-derived shape and every caller,
// including the React hook, fails to compile until it's fixed.
const { data } = api.compatibility.analyze.useQuery({ jobPostingId });
// data.overallScore is known to be a number — here and in the UI.
// A typo or wrong shape is a build error, not a 500 in production.The agent team
The heart of the product is a team of specialized AI agents running on a LangGraph StateGraph. Instead of one monolithic prompt trying to do everything, a central Supervisor reads each request and routes it to the specialist that owns that job. The graph handles the messy parts — cyclical back-and-forth, handing state between agents, routing that depends on where the conversation already is.
Supervisor
Reads intent, routes to one specialist
Data Manager
Stores and retrieves profile data, parses pasted resumes, and merges duplicate achievements.
Resume Generator
Produces resumes tailored to a specific posting.
Cover Letter Generator
Writes cover letters matched to the job at hand.
Job Posting Manager
Parses and stores postings, and compares their requirements against your skills.
Profile Insights
Answers questions about the data you've already stored.
One detail I’m quietly proud of: the entire agent roster is derived from a single manifest. The list of valid agents, the routing schema, the terminal tools — they’re all projections of that one source, with a test that fails if they drift apart. You can’t add an agent in one place and forget it in another, because there’s only one place.
The hard problems worth explaining
Skill matching that understands meaning
This is the piece I’d show off first. Naive skill matching is string matching, and string matching is a lie: a posting asks for “React,” your profile says “ReactJS” or “React (hooks, context),” and a dumb system calls that a miss.
- TypeScriptmatched
- Reactmatched
- PostgreSQLmatched
- Kubernetesgap
CareerCraft treats skills as a global, deduplicated taxonomy. Every canonical skill carries a semantic embedding vector (gemini-embedding-001, 768 dimensions), computed once and reused across every user and every posting. Matching runs as a pgvector cosine-similarity query, so related phrasings resolve to the same thing and a job asking for one skill can match a genuinely related one. Before it falls to vector similarity, it checks a curated override tier of hand-tuned edges — the cases where I know two skills are related and don’t want to leave it to cosine distance.
// Illustrative sketch of the matching tiers (not the real query).
async function matchSkill(required: SkillVector) {
// 1. Curated overrides win — hand-tuned edges I don't leave to math.
const override = curatedEdges.get(required.id);
if (override) return override;
// 2. Otherwise: pgvector cosine similarity over canonical skills.
return db.skill.findClosest(required.embedding); // gemini-embedding-001, 768d
}The compatibility analysis itself runs synchronously, and it’s memoized by an inputsHash — a SHA-256 of the profile-and-requirements snapshot that fed it. If the hash matches, you get the cached report back at zero LLM cost. Change your profile or the posting, the hash changes, and it recomputes automatically.
“Freshness is owned entirely by the hash. There’s no ‘is this stale?’ flag that can lie to you.”
That’s the part I like: the input defines the output. There’s no background job to invalidate caches, no status column that can drift out of sync with reality. The report either exists for this exact input or it doesn’t.
Turning a messy resume into structured data
Parsing is the other genuinely hard part. Unstructured resume text — pasted or uploaded — goes through LLM extraction and comes out as structured work history, education, achievements, skills, and links. If someone uploads a graphic-heavy PDF where the text isn’t cleanly extractable, it falls back through a vision model to recover the text first.
// Illustrative — parse once, then write in batched transactions.
const parsed = await llm.extractProfile(resumeText); // structured JSON
await db.$transaction([
db.workHistory.createMany({ data: parsed.jobs }),
db.education.createMany({ data: parsed.education }),
db.skill.connectOrCreateMany(parsed.skills),
]); // hundreds of round-trips collapse into a handfulBring your career data anywhere: the MCP connector
CareerCraft exposes a Model Context Protocol connector, which means an external AI assistant — Claude, say — can act on your career data as you: read your profile, generate a tailored resume, run a compatibility check, all from wherever you already are.
I refused to cut corners on the auth for this, because “let an external agent act as you” is exactly where corners get people breached. It’s real OAuth 2.1: dynamic client registration (RFC 7591), PKCE, single-use authorization codes that live for 60 seconds, and scoped, revocable bearer tokens. The raw token is never stored — only a SHA-256 hash of it. And every MCP tool call runs through the same per-user ownership guards as the app itself; a caller factory reuses the exact protected procedures the first-party UI uses, so there’s no shadow API with weaker checks.
// An external assistant is granted a scoped, revocable token.
{
"client": "claude.ai",
"grant": "authorization_code + PKCE",
"scopes": ["profile:read", "resume:write", "compatibility:run"],
"stored": "SHA-256 hash only — never the raw token"
}There are also macro tools that chain a whole workflow into one call. tailor_my_application runs compatibility, generates a tailored resume, generates a cover letter, and produces a PDF — one call, one coherent result. Metering is inherited from the sub-steps, so chaining a workflow never double-charges you.
Recent additions
Two capabilities I shipped recently round out the loop from “find a job” to “walk into the interview.”
URL job import, safely
Paste a posting's link and a tiered extractor pulls it in — ATS adapters, then JSON-LD, then LLM-over-HTML, first hit wins. The outbound fetch is SSRF-hardened, and compatibility runs the moment it lands.
Interview prep
A conversational prep tool scoped to a specific posting — so the practice is about that job, not generic advice.
On the URL importer specifically: the outbound fetch is hardened against SSRF — HTTPS-only, DNS resolved to an IP allowlist, IP pinning to defeat DNS-rebinding, re-validation on every redirect hop, and size/time caps. When it fails, it fails honestly — you get a real message, never a leaked raw error. And there’s a real internal design system under all of it — tokens only, light and dark, motion that respects prefers-reduced-motion — because craftsmanship shows up in the parts nobody tweets about.
What I’d tell another developer
The lesson isn’t “AI changes everything.” It’s the opposite. A real user problem plus boring, reliable tools is what made a sophisticated AI product buildable by one person. LangGraph gives me orchestration, but tRPC and Prisma and Postgres give me the guardrails that make the orchestration safe to change. Type safety, service boundaries, injected ports, hash-owned freshness — none of that is novel. It’s just applied, consistently, to the specific ways an AI app tries to rot.
“A real user problem plus boring, reliable tools is what made a sophisticated AI product buildable by one person.”
Start from the user’s actual problem. Let unglamorous, well-understood tools carry the weight. Reserve the fancy parts for where they genuinely earn their keep. That’s the whole playbook.
CareerCraft Studio is live. If you want to dig into the architecture — the agent graph, the MCP auth, the pgvector matching — I’m happy to go deep.