Zee - Blog
Published on

How Job Assistant is built - Backend & Agents

Authors
  • avatar
    Name
    Zee Lu
    Twitter

Introduction

In the frontend dev note, we explored how Job Assistant handles the chaotic DOM of job boards. But extracting the text is just step one. The real value—the "AI" in AI Assistant—happens in the backend.

To keep the extension lightweight and secure, I moved all intelligence to the server. Specifically, I built a coordinated suite of Edge Functions running on Deno (via Supabase).

This post is a detailed walkthrough of every single function in the plugin-functions repository, explaining exactly how each one works.

The Edge Architecture

Before diving into specific functions, let's look at the shared DNA across the backend. Every function shares three traits:

  1. Deno Runtime: We use Deno for its native TypeScript support and web-standard APIs (fetch, Request, Response).
  2. Hybrid Auth:
    • User Requests: JWTs verified against Supabase Auth (verifySupabaseToken).
    • Internal/Admin Requests: Self-signed hashed tokens (verifySelfSignedToken) for secure service-to-service communication.
  3. Database Traceability: Every single request—successful or failed—is logged to a dedicated Postgres table (e.g., extraction_requests, cover_letter_requests). We log the prompt usage, processing time, and the model used.

1. Company Research: The "Analyst Swarm"

The company-research function is the most complex. It doesn't just "ask ChatGPT". It mimics a team of human analysts working in parallel.

The Challenge

Research takes time. Sequentially searching for news, then financials, then culture would take 60+ seconds.

The Solution: Promise.all Orchestration

We spin up 4 specialized agents simultaneously.

typescript
// Simplified "Swarm" Pattern
const [financial, news, culture, industry] = await Promise.all([
  financialAgent.research(company), // Looks for stock, revenue, funding
  newsAgent.research(company),      // Scrapes recent PR and headlines
  cultureAgent.research(company),   // Checks Glassdoor, values, mission
  industryAgent.research(company)   // Analyzes competitors
]);

Streaming Updates (SSE)

Because even parallel agents take ~10-15 seconds, we can't leave the user hanging. We use Server-Sent Events (SSE) to stream "thoughts" to the UI.

  • data: { type: "status", message: "Searching for Q3 Financial Reports..." }
  • data: { type: "status", message: "Analyzing Competitors..." }

This "Optimistic UI" approach makes the wait feel negligible.


2. Resume Alignment: The "Hiring Manager" Simulator

The align-resume function is designed to be the "bad cop". Its job is to objectively score a candidate (0-100) against a Job Description (JD).

The "Assessment" Prompt

We don't ask for a generic opinion. We force the LLM into a persona:

"You are a Senior Talent Acquisition Executive... Conduct a rigorous, professional assessment."

Structured JSON Output

The critical part is the output schema. We define a strict interface:

typescript
interface AlignmentResult {
  score: number;
  summary: string;
  strengths: string[];
  gaps: string[];
  recommendations: string[];
}

The prompt explicitly forbids markdown or chatter. We strip fences (```json) and aggressively parse the output. If the model is feeling "chatty", the parser cleans it up before the UI ever sees it.


3. Resume Tailoring: The "Fact-Preserving" Rewriter

tailor-resume is dangerous. AI likes to hallucinate. If an AI invents a job you never had, you're lying to an employer.

The "Permutation" Constraint

To solve this, we use a technique I call "Permutation Prompting". In the prompt for tailorResumeWithLLM, we explicitly instruct:

"You MUST NOT add, remove, merge, split, or rename any skills. The output skills array MUST contain exactly the same items as the input array, only reordered."

We treat the user's experience as immutable blocks. The AI is only allowed to:

  1. Reorder skills to match the JD keywords.
  2. Rephrase bullet points to emphasize relevant tech.
  3. NEVER invent dates or titles.

PDF Service Handoff

Once the JSON is tailored, this function doesn't generate the PDF (Edge functions have 10MB limits and limited binary support). Instead, it acts as a coordinator, handing the confirmed JSON to a dedicated resume-svc-cf (Cloudflare Worker) that runs a heavy PDF generation library.


4. Resume Parsing: extract-info

Users upload PDFs, but code needs JSON. extract-info turns raw text into the StructuredResume schema.

Parallel Model Fallback

Parsing is critical. If one model fails, the user is blocked. In extract-info/index.ts, we see a robust pattern: the code is set up to potentially call multiple models (defined in MODELS). It uses Promise.race (or Promise.all depending on config) strategies to ensure we get some valid JSON back, even if the primary provider is slow.


5. Cover Letter Generator: The Anti-Robot

Most AI cover letters sound like: "I am writing to express my enthusiastic interest in the [Role] at [Company]. I am a hardworking..." Gross.

In generate-cover-letter, we engineer the prompt to be Anti-Robotic.

The "Banned Words" List

We explicitly ban the "ChatGPT Dictionary":

"BANNED WORDS: Do NOT use: delve, showcase, testament, landscape, tapestry, unwavering, spearhead..."

The "Hook" Strategy

We force the AI to skip the "I am writing" intro.

"THE HOOK: Never start with 'I am writing to express my interest.' Start with a direct connection between the candidate's specific past achievement and a problem listed in the JD."

This produces letters that sound confident and senior, rather than generated and junior.


6. Job Summarizer: The "Skim Reader"

summarize-job is a utility function for the "Job Board" view. When you have a list of 50 jobs, you don't want to read 50 JDs.

This function takes a flexible config:

typescript
interface SummarizeConfig {
  length: "short" | "medium" | "long";
  fields: Array<"title" | "company" | "salary" | ...>;
}

It condenses 2000-word corporate fluff into a 30-word "What they actually want" summary.


7. Check Usage: The Accountant

check-usage is simple but vital. It proxies requests to the OpenRouter API to fetch credit balances. Why a backend function? Security. We never want to expose our master API keys (which have billing access) to the client-side extension code. The extension asks the backend "How much money do I have?", and the backend—using the secure server-side env vars—checks the bank.


Conclusion

Building "AI" features is easy. Building reliable AI features is hard. By breaking the backend into these atomic, single-purpose Edge Functions, I've built a system that is:

  1. Resilient: If "Cover Letter" goes down, "Research" still works.
  2. Specific: Each function has a prompt engineered for exactly one task.
  3. Fast: Parallel execution patterns reduce wait times from minutes to seconds.