Building JobPilot's Technical Architecture

Inside JobPilot's SvelteKit, Convex, FastAPI, Supabase, Gmail, and task-agent architecture, from a personal search request to an updated application board.

Written in April 2026.

JobPilot is a multi-service application rather than a single prompt wrapped in a user interface. I built the main application with SvelteKit, store reactive task state in Convex, run personal job retrieval through a separate FastAPI service, persist search data in Supabase Postgres, and use Gmail signals to update the application board.

The technical design follows one rule: search results, imported job descriptions, generated documents, and email outcomes must all resolve to the same structured task record.

System boundaries and data flow

SvelteKit UI calls Convex and the personal search API routes; the FastAPI search backend writes to Supabase Postgres; Convex runs the task agent, receives Gmail signals and holds the Kanban task state

SvelteKit owns pages and server routes. Convex owns user-scoped application state and orchestration. The Python service owns scraping, normalization, deduplication, and ranking. Supabase stores search profiles, runs, canonical jobs, and matches. This separation keeps a slow search run or external API failure away from the interactive task-board runtime.

1. Personal search as a separate service

The My Job Search page sends user-owned keywords, location, country, date window, source selection, and remote preference through src/routes/api/personal-search/+server.ts. Keyword suggestions are handled separately in suggest-keywords/+server.ts, and send-to-tasks/+server.ts converts selected results into application tasks.

The backend performs scraping, normalization, deduplication, ranking, and selective enrichment before writing structured results to Supabase. Unlike JobLab, this instance is not locked to my optimization search. Each user supplies their own terms in My Job Search, or derives them from a CV profile, without redeploying the shared service.

Watch the video walkthrough on YouTube

2. Normalize every job into one task schema

A job can enter from pasted text, a URL, a company page, or a personal search result. src/lib/convex/todo/jobInput.ts normalizes those inputs before the task agent works on them, and the task fields are defined in src/lib/convex/schema.ts. Role, company, source URL, description, status, and generated artifacts therefore stay attached to one record.

Whatever the source, the result is the same set of fields on one task:

JobPilot task form after reading a LinkedIn posting: job title, company, position, job URL, platform, country and level filled in

Watch the video walkthrough on YouTube

3. Bound the task agent with stored context

src/lib/convex/todo/agent.ts defines when the agent may generate a motivation letter. It reads the job description, the stored CV or profile, and the user’s reusable letter instructions. It generates only when the task reaches the preparing stage and the letter field is empty, which prevents an agent run from silently overwriting a reviewed draft.

src/lib/convex/userSettings.ts manages the reusable profile and instructions. src/lib/convex/todo/messages.ts runs the model interaction, and the resulting letter is written back to the existing task for review, editing, copying, or download. The model call is one bounded step inside application state, not the state itself.

Watch the video walkthrough on YouTube

4. Match Gmail events to existing tasks

The Gmail integration is implemented in src/lib/convex/gmail.ts. OAuth requests read-only Gmail access. The sync retrieves recent messages, extracts sender, company, position, interview links, and date text, then scores candidate tasks with scoreTaskMatch. Strong company and position evidence increases confidence before an application record is updated.

Heuristics handle clear interview, rejection, acceptance, and follow-up signals first. A model fallback is reserved for messages that remain ambiguous. This ordering controls latency and model use while keeping the final task mutation explicit. Gmail is an input signal; Convex remains the source of application state.

Watch the video walkthrough on YouTube

Engineering decisions behind the workflow

The project keeps interactive state, long-running search, persistence, external email, and model calls behind separate interfaces. It also includes retry-aware actions, stuck-task cleanup, duplicate prevention, and explicit user-visible states. Those details matter more than the number of features because they determine whether the workflow survives partial failure.

To adapt JobPilot, start in My Job Search with your own keywords. For code-level changes, the main extension points are the SvelteKit API routes, Convex schema and actions, the FastAPI search service, and the Supabase search schema.

How to repeat this

The code is in Mbehbahani/JobPilot. You need Bun, Node.js and a Convex project; put CONVEX_DEPLOYMENT and PUBLIC_CONVEX_URL in .env.local. Then one command runs the SvelteKit app and the Convex backend side by side:

git clone https://github.com/Mbehbahani/JobPilot
cd JobPilot
bun install
bun run dev
  1. Read src/lib/convex/schema.ts first: every other part writes into the task record defined there.
  2. Add one job from a URL and check which fields src/lib/convex/todo/jobInput.ts filled in.
  3. Move the task to the preparing stage and confirm the agent writes a letter only when the letter field is empty (src/lib/convex/todo/agent.ts).

Gmail and the personal search service need their own credentials, so start without them. To use the hosted version instead, open JobPilot (sign-in required). The per-user model connection is described in Connecting JobPilot to ChatGPT on the old blog.

← All notes