olivia
AI-powered resume tailoring
Olivia is an AI resume tailoring tool. Upload your resume (PDF or DOCX), paste a job URL, and it gives you an ATS-optimized resume tailored to that specific role. The idea is simple: you maintain one base resume and let the AI rewrite, reorder, and rephrase your bullets to match each job's requirements — without fabricating anything.
How it works
The core workflow is a five-step pipeline: scrape the job page, validate it's actually a job posting, retrieve the user's base resume, tailor it with GPT-4o-mini, and store the result. Each step updates a status field on the job record, so the client can show real-time progress.
export async function runTailoringWorkflow(
jobId: string,
url: string,
userId: string
) {
try {
// 1. Scrape
await updateJobStatus(jobId, 'scraping');
const { markdown, title } = await scrapeJobPage(url);
// 2. Validate — is this actually a job posting?
const { valid, reason } = await verifyJobDescription(markdown);
if (!valid) {
await updateJobStatus(jobId, 'invalid', { invalidReason: reason });
return;
}
// 3. Store valid job
await updateJobStatus(jobId, 'valid', {
content: markdown,
title,
analyzedAt: new Date(),
});
// 4. Retrieve the user's base resume
const baseResume = await getBaseResume(userId);
if (!baseResume) {
await updateJobStatus(jobId, 'error', {
invalidReason: 'No base resume found',
});
return;
}
// 5. Tailor
await updateJobStatus(jobId, 'tailoring');
const tailored = await tailorResume(baseResume.analysis, markdown);
// 6. Persist tailored resume
await addResume({
name: `Tailored - ${title ?? url}`,
url: baseResume.url,
jobId,
status: 'complete',
analysis: tailored,
userId,
});
await updateJobStatus(jobId, 'complete');
} catch (error) {
const message = getErrorMessage(error);
await updateJobStatus(jobId, 'error', { invalidReason: message });
}
}The status enum acts as a simple state machine: pending → scraping → valid → tailoring → complete, with invalid and error as terminal failure states. Each transition is explicit, and the invalidReason field gives users actionable feedback when things go wrong ("This page doesn't appear to be a job posting", "No base resume found", etc).
Fire-and-forget with after()
The tailoring workflow takes 10-30 seconds end to end. I didn't want to hold the HTTP response open that long, and websockets felt like overkill for a workflow you submit and wait for. So I used next/server after() — the Server Action creates the job record, returns the ID immediately, and kicks off the workflow after the response is sent to the client.
'use server';
import { after } from 'next/server';
export async function submitJobAction(url: string) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) return { error: 'Unauthorized' };
// Duplicate check
const existing = await getJobByUrlAndUser(url, session.user.id);
if (existing) return { error: 'You have already submitted this job URL.' };
// Create job record immediately
const newJob = await createJob({ url, userId: session.user.id });
// Fire-and-forget — runs after the response is sent
after(async () => {
await runTailoringWorkflow(newJob.id, url, session.user.id);
});
return { jobId: newJob.id };
}On the client side, a polling hook checks /api/jobs every 4 seconds, but only while there are active (non-terminal) jobs. Once everything is complete, invalid, or error, polling stops automatically.
const TERMINAL_STATUSES = new Set(['complete', 'invalid', 'error']);
const POLL_INTERVAL = 4000;
export function usePollJobs(initialJobs: Job[]) {
const [jobs, setJobs] = useState<Job[]>(initialJobs);
const hasActiveJobs = jobs.some(
(j) => !TERMINAL_STATUSES.has(j.status)
);
useEffect(() => {
if (!hasActiveJobs) return;
const id = setInterval(async () => {
const res = await fetch('/api/jobs');
if (res.ok) setJobs(await res.json());
}, POLL_INTERVAL);
return () => clearInterval(id);
}, [hasActiveJobs]);
return { jobs, addJob, refetch };
}Structured outputs over streaming
I chose generateText with Output.object() and Zod schemas over streaming. Resume data can't be half-formed — you need the full object to render a PDF. Streaming would mean buffering everything client-side and hoping the JSON is valid at the end. Structured outputs guarantee valid JSON that matches the schema, and the same Zod schema is used for both parsing and tailoring so the types are always consistent.
export const tailorResume = async (
baseResume: ValidatedResumeData,
jobDescription: string
) => {
const { output } = await generateText({
model: openai('gpt-4o-mini'),
output: Output.object({
schema: resume_parse_object, // same Zod schema for both parse + tailor
}),
messages: [
{
role: 'system',
content: `You are an expert resume tailor...
Rules:
- Rewrite the summary to highlight relevance to this role
- Reorder and rephrase experience bullet points for ATS keywords
- Prioritize skills that match the job requirements
- Keep all factual information accurate — NEVER fabricate
- You may rephrase, reorder, and emphasize, but must not invent`,
},
{
role: 'user',
content: `## Base Resume\n${JSON.stringify(baseResume)}\n\n## Job Description\n${jobDescription}`,
},
],
});
return output!;
};The resume schema is comprehensive — 11 top-level fields with nested objects for experiences (multiple positions per company), education, skills grouped by category, certifications, projects, awards, patents, and languages. Everything is nullable because real resumes vary wildly.
export const resume_parse_object = z.object({
full_name: z.string().nullable(),
phone_number: z.string().nullable(),
website_url: z.string().nullable(),
email: z.string().nullable(),
location: z.string().nullable(),
summary: z.string().nullable(),
highlights: z.string().nullable(),
skills: z.array(z.object({
title: z.string().nullable(),
skills: z.array(z.string()),
})).nullable(),
education: z.array(z.object({
school: z.string().nullable(),
degreeName: z.string().nullable(),
fieldOfStudy: z.string().nullable(),
startsAt: dateObj.nullable(),
endsAt: dateObj.nullable(),
})).nullable(),
experiences: z.array(z.object({
company: z.string().nullable(),
positions: z.array(z.object({
title: z.string().nullable(),
description: z.string().nullable(),
location: z.string().nullable(),
startsAt: dateObj.nullable(),
endsAt: dateObj.nullable(),
})),
})).nullable(),
certifications: z.array(/* ... */).nullable(),
projects: z.array(/* ... */).nullable(),
awards: z.array(/* ... */).nullable(),
patents: z.array(/* ... */).nullable(),
languages: z.array(/* ... */).nullable(),
});
export type ValidatedResumeData = z.infer<typeof resume_parse_object>;JSONB for resume storage
The entire parsed resume is stored as one JSONB blob in the analysis column, typed as ValidatedResumeData via Drizzle's jsonb().$type<>(). I considered normalizing it into separate tables (experiences, education, skills, etc.) but resumes are always read and written as a whole unit — you never query "all experiences across all users." JSONB means schema changes don't require migrations, and reads are a single query. The trade-off is you can't do field-level queries or indexes on the resume content, but that hasn't been needed.
Job scraping with Firecrawl
Job pages are often JavaScript-rendered SPAs (LinkedIn, Greenhouse, Lever). I initially considered Puppeteer but maintaining a headless browser in production is painful. Firecrawl handles the rendering, waits 3 seconds for JS to execute, extracts only the main content (skipping nav, ads, footers), and returns clean markdown. Markdown is ideal for LLM processing — much less noise than raw HTML.
After scraping, a separate AI call validates the content is actually a job posting. This catches cases where the URL leads to a company homepage, a 404 page, or a login wall. The validation uses a simple boolean structured output and stores the reason when it fails.
Database design
The schema is intentionally simple. Two main tables: job and resume, connected by an optional jobId foreign key. A resume with jobId = null is a base resume (the user's upload); one with a jobId is a tailored version. Each user has one active base resume — the most recent upload — which simplifies the tailoring logic.
export const job = pgTable('job', {
id: text('id').primaryKey().$defaultFn(() => createId()),
url: varchar('url').notNull(),
title: varchar('title'),
userId: text('user_id').notNull().references(() => user.id),
status: jobStatusEnum('status').default('pending').notNull(),
content: text('content'),
analyzedAt: timestamp('analyzed_at'),
invalidReason: text('invalid_reason'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (t) => [unique().on(t.url, t.userId)]);
// Status enum: pending | scraping | valid | invalid | tailoring | complete | errorThe unique().on(t.url, t.userId) constraint prevents duplicate submissions. There's also an application-level check before hitting the DB so the user gets a friendly error message instead of a constraint violation.
The hard parts
Resume parsing fidelity is the biggest challenge. PDF-to-text is lossy — tables, multi-column layouts, and styling are all lost in extraction. The Zod schema tries to capture structure (nested experiences with multiple positions per company, categorized skills), but ultimately it's garbage-in-garbage-out. A well-formatted single-column resume produces much better results than one with creative layouts.
The "NEVER fabricate" guardrail is critical. The tailoring prompt explicitly instructs the model to rephrase, reorder, and emphasize — but never to invent companies, dates, or skills the user doesn't have. This is enforced at the prompt level, not programmatically, which means it's only as reliable as the model. In practice, GPT-4o-mini respects this constraint well, but it's something I kept an eye on during testing.
Retry logic lets users retry failed or invalid jobs. The action resets the status to pending (only if the current status is error or invalid) and re-runs the full workflow via after(). The status check is done in the WHERE clause to prevent race conditions.
Service layer separation
The codebase follows a clean service split: ai.service handles all OpenAI calls (verify resume, analyze resume, verify job description, tailor resume), scrape.service handles Firecrawl, resume.service and job.service handle database operations, and workflow.service orchestrates the pipeline. Server Actions are thin — they handle auth, call into the service layer, and return. This keeps each piece independently testable.
Error handling maps AI SDK errors (APICallError, NoObjectGeneratedError, TypeValidationError, RetryError) to user-friendly messages that get stored in job.invalidReason. When something fails, the user sees "The AI model did not produce the expected structured data" instead of a raw stack trace.