Reference

Features

Every screen and setting in the app, described in full. This is a technical reference written for people who already use Job Search Terminal, or who are looking at the code — it assumes you know the jargon and does not stop to explain it.

If you are deciding whether to try the app, start with the overview instead. It covers the same ground in plain English and takes two minutes.

Last updated: September 13, 2026

Features

This document describes every feature in the current application. Use it as a reference for what the app does and how each section works.


The Shell header provides two navigation groups:

Primary nav (always visible): Dashboard · Jobs · Applications · Interview Prep · Analytics · Resumes

Account dropdown (hover on “Account”): Profile · Strategy · Settings

Not in the nav: the Evidence bank at /evidence is deliberately kept out of the primary nav. It is reached from the “Top gap patterns” card on Analytics and from the “Evidence gaps to finish” card on the Dashboard, and it renders with Analytics lit in the nav plus a ”← Back to Analytics” link so its place in the hierarchy stays clear.

Help link appears immediately after Account and opens the in-app help site at /help in a new browser tab (target="_blank") so the user doesn’t lose their current context.

The Account menu shows a live AI provider health dot. The indicator reads the provider priority chain from providerOrderJson and checks each provider for a credential (API key for cloud providers; a non-empty base URL for Ollama):

  • Green: the first enabled provider in the chain has a credential configured
  • Yellow: a credential exists somewhere but the first-in-chain provider is missing one
  • Red: no credential is configured for any provider in the chain

The app redirects / to /dashboard on load. The app also serves /favicon.ico (redirected to /images/JST-logo.svg) so browser default favicon requests resolve without 404 noise.

Branding

Job Search Terminal carries its own product mark. public/images/JST-logo.svg (the red chevron-and-triangle mark, #d74527, aspect ratio roughly 1:2) is used for the app header logo in Shell, for the favicon / shortcut / Apple touch icon in src/app/layout.tsx, and as the /favicon.ico redirect target. The Next.js file-convention icon at src/app/icon.svg renders the same mark in white on a rounded #d74527 square.

The UX Design Lab mark (public/images/UXDL-logo.svg) is retained for attribution only: it appears beside the “Made with care by UX Design Lab, LLC” line in the shell footer.

The shell footer’s bottom bar carries a build stamp between the attribution and the copyright line: Version 0.1.0 · b5bcb0d. The number is version from package.json; the short code is the commit HEAD points at. A trailing * means the working copy has uncommitted changes. The number itself links to CHANGELOG.md on GitHub — the changelog is what makes a version number mean anything, so it hangs off the number rather than needing its own footer item. Hovering the stamp reveals the branch, how long ago the commit was made, and whether the copy is modified.

Beside it, the update state:

StateRendering
BehindAmber Update available — N commits behind ↗ badge, linking to the GitHub compare view for those exact commits. Title text tells the user to run git pull.
Current· Up to date, with the last check time in the title.
Unknown· Update status unknown, with the reason in the title (no git history, GitHub unreachable, rate-limited, commit not pushed).
DisabledNothing beyond the version.

Why commits and not releases. The repository publishes no tags or releases, so a version string cannot tell anyone whether they are behind. The check asks GitHub’s compare API how far HEAD is behind the default branch, which is the same question a user would answer by hand with git pull --dry-run. If the project ever starts tagging releases, /releases/latest is the natural extension point in src/lib/version/update-check.ts; the commit compare stays as the fallback for untagged checkouts.

Why ahead_by, and why the base is the merge base. Two things that are easy to get backwards, and were:

The request is compare/{base}...{head} with base = our commit and head = the default branch, and GitHub reports both counts from the head’s point of view. A checkout ninety-four commits stale therefore comes back {status: "ahead", ahead_by: 94, behind_by: 0} — the branch is ahead of us. Reading behind_by was wrong twice: zero for every stale checkout, so no update was ever reported; and for a checkout carrying local commits it counted those, announcing the user’s own unpushed work as updates waiting to be pulled.

The base sent is merge-base HEAD origin/main, not HEAD. With no local commits they are the same. With local commits HEAD is a private identifier that exists nowhere but that machine, and sending it would break the promise the footer makes; the merge base is provably on the remote, and counting from it still answers the real question — what git pull would bring down. When no remote-tracking branch resolves (origin/main, then origin/HEAD), the check reports unknown and sends nothing rather than falling back to HEAD.

Source files:

FilePurpose
src/lib/version/local-version.tsResolves the running version: package.json version, HEAD SHA, branch, commit date, dirty flag, and the owner/repo parsed from the repository URL. Every field degrades to null rather than throwing, so a zip download with no .git still renders.
src/lib/version/update-check.tsCached GitHub comparison and the UpdateStatus union.
src/components/app-version.tsxServer component rendering the stamp and badge.

Help Site /help

The in-app help site is a self-service documentation surface for open-source users. It is designed as a mini website inside the product, with a landing page, search, workflow cards, a persistent documentation sidebar, screenshots, related guides, and per-topic pages.

Help home:

  • Hero with product screenshot and calls to start the guide or open the resume and ATS guide.
  • Near-hero privacy note that stored data stays on the machine and AI actions use the configured provider: OpenAI, Anthropic, Google Gemini, or Ollama locally.
  • Search across all help pages.
  • Workflow groups for setup, profile, jobs, applying, tracking, interview prep, privacy, and troubleshooting.

Guide pages:

  • /help/getting-started — setup, onboarding, and daily workflow.
  • /help/ai-providers — how to configure OpenAI, Anthropic, Google Gemini, or Ollama (local); create and add API keys; set the provider priority chain; test providers; and protect keys.
  • /help/resume-lanes — resume lanes, resume upload, ATS-friendly formatting, PDF guidance, and bullet quality.
  • /help/job-search — dashboard scans, job sources, manual job entry, filters, and saved presets.
  • /help/linkedin-scanner — Claude/Codex browser-board scanning for LinkedIn, Wellfound, Work at a Startup, Glassdoor, Indeed, and Monster; Dice MCP scanning (no browser required); imports, duplicates, limits, and safety notes.
  • /help/evaluate-tailor — evaluation, tailored resume generation, PDF export, application answers, research, and outreach drafting.
  • /help/applications — statuses, table and kanban tracking, follow-ups, and archive vs. delete behavior.
  • /help/interview-prep — STAR stories and voice practice.
  • /help/privacy-data — local data, AI-provider data flow, the daily version and update check, backups, and safety boundaries.
  • /help/troubleshooting — common setup, AI, resume/PDF, scan, and LinkedIn fixes.

The help content is sourced from src/lib/help/content.ts and rendered through the shared help components under src/components/help/.


Dashboard /dashboard

The command center. Has two states depending on setup progress.

First-run onboarding (shown until dismissed): Opens as an isolated dashboard modal so the user can finish setup without leaving the flow.

It is a real modal, not just an element carrying aria-modal. useModalDialog (src/lib/hooks/use-modal-dialog.ts) moves focus to the dialog on open, traps Tab inside it, marks everything outside inert, locks body scroll, routes Escape to the same confirmation the × uses, and returns focus on close. Before this the dialog declared itself modal while leaving 21 focusable elements reachable behind it — the nav, the footer, and the readiness card’s links to the very settings the wizard was trying to own — with focus still on <body> and Escape doing nothing. The inert pass walks up from the dialog marking each ancestor’s other children, because the dialog renders deep in the app tree rather than as a child of <body>; marking only body’s children would skip the one branch that contains the whole page.

The dialog’s content area is a <div>. It used to be a <main> nested inside the page’s own <main> — invalid, and it broke the skip-to-content landmark.

Each step button carries aria-current="step" when active, visually-hidden position and state text (“Step 2 of 5. Locked.”), and an aria-describedby naming what would unlock it. State previously reached assistive tech as a ✓ glyph and a colour only.

The × close button is always visible. Clicking it when setup is not fully complete shows a warning with two options — “Back to setup” or “Dismiss setup” (exits immediately and records dismissal). Once dismissed, the modal never re-appears regardless of whether all steps are complete; onboardingDismissed is the authoritative gate, and only the user clears it — via Resume guided setup, which appears on the dashboard’s “Finish profile setup” card and under Settings → AI Provider once the wizard has been dismissed (reopenOnboardingAction). Dismissal used to be a one-way door: nothing in the UI cleared the flag, and the wizard’s own “Resume onboarding” banner rendered for a single paint before the refresh unmounted the component, so it was never reachable. That banner is gone; the dashboard card is the persistent surface. setOnboardingPreferencesConfirmed deliberately leaves the flag alone: it used to clear it in the same UPDATE, which meant every resume upload — including a routine replacement years into a search — reopened the full first-run wizard over an established user’s dashboard.

On a genuinely fresh start — no provider and no resume — the modal opens on a Before you start briefing rather than straight into the first step: what setup needs (an API key from OpenAI/Claude/Gemini, or Ollama installed locally; a text-based resume PDF; the roles and places being searched), roughly how long it takes, that everything stays on the machine, and the list of steps ahead. Get started enters the wizard. It is a briefing rather than a step, so it carries no sidebar row and no completion state, and it never appears again once a provider or a resume exists — asking for a paid API key as the very first thing, with no warning of what else is coming, was the gap it fills.

The wizard itself has 6 panels — 4 required steps, 1 optional step, and a closing summary:

  1. AI provider (required) — selects one provider and saves its key inline. The step opens with a required-notice explaining what the key is for (fit scoring, resume tailoring, answer drafting), that one key is enough, and that keys stay on this machine. It embeds the same provider list as Settings → AI Providers (in compact mode), so its drag handles get their keyboard-drag instructions from the same stable DndContext id — see Settings → AI Providers. Because onboarding renders on /dashboard on every load until dismissed, this was where the hydration mismatch surfaced most visibly; the dashboard console is now clean on a first-run load.

    In compact mode the step is a three-screen drill-down, not one long form. Each screen replaces the last inside the same fixed frame, so the panel never grows and the key field is always the thing in front of the user:

    1. Choose a provider — four rows, each a full-width button with the provider’s name and a one-line blurb (“Cloud · pay per use · GPT models”, “Local · free · runs models on this machine”). Already-added providers are marked Added and disabled.
    2. Enter the key — the list is replaced by that provider’s key entry, with the caret placed in the field on arrival. Carries a ”← Choose a different provider” back link, a link to where that provider issues keys, a collapsed Model options disclosure, and Test connection. Ollama gets its full configuration block here instead of a key field. Two save buttons share one code path (persist(nextPhase)): Save and continue verifies, saves, and advances the wizard to the resume step — the step is done, and a button that says continue should continue; Save and add another does the same but returns to the chooser, staying on this step so a fallback chain can be built without round-tripping. The second button is hidden when every other provider is already added, and while a verification failure is pending — that moment should be a single decision about the key in front of the user, not a fork.
    3. Your AI providers — the summary, reached by returning to this step once a provider is saved rather than immediately after saving. Lists what is configured in fallback order, each with its verification result and Edit (and Remove once there is more than one); “Add another provider as a fallback” returns to screen 1, and Continue advances the wizard.

    Revealing the key card under the four-row list, as an earlier pass did, pushed the field below the fold and grew the dialog on every selection — the thing this step could least afford. The drag-to-reorder priority list stays in Settings; onboarding orders providers by the sequence they were added.

    Every provider starts unselected. A tick claims the provider is set up, and the provider_order_json column ships defaulted to three cloud providers, so seeding selection from it used to greet every new install with three keyless providers presented as ready. AISettingsForm now seeds from what actually holds a credential (for Ollama, from presence in the saved order — it has no key to check). A drafted provider joins the saved chain only on a successful save, so an abandoned detour does not leave a keyless provider enabled behind the user. The optional Adzuna and Brave fields do not appear here at all — step 4 owns them.

    The step’s intro copy carries a bold This step is required. lead-in rather than a bordered warning callout — a warning panel over the primary instruction read as an error on a screen where nothing had gone wrong yet. The lead-in disappears once a key is saved.

    Ollama is verified down to the model. Selecting it fetches the installed list from /api/ai/ollama-models and moves the model setting onto an installed model when the current value is not one — the stored default is llama3.1:8b, which most machines do not have. The model field is a picker of what is installed rather than free text, and falls back to a text input plus Choose… only when the list cannot be fetched. hasCredential("ollama") requires the server to answer and the model to be present, so the row reads Not running, Model needed, or Ready accordingly.

    OllamaProvider.testConnection enforces the same thing. It used to return ok: true whenever the server answered, displaying modelNames[0] when the configured model was absent — so onboarding showed “Verified” against a model the app would never use, the step went green, and the first real request failed with a bare 404 during profile extraction, two steps away from the screen that could fix it. It now fails with the missing model’s name, the ollama pull command for it, and the list of what is installed; on success it reports the configured model. Its 404 message names the model instead of a literal <model-name> placeholder.

    Onboarding passes requireCredential, which makes the submit button refuse an empty or keyless chain (with a line naming what is missing) and run the same connection test as the “Test connection” link before saving. A failed test keeps the step open, shows the provider’s own error, and re-labels the button Save without verifying so an offline or rate-limited setup is not trapped; editing the key clears that escape so a corrected key is verified again. Ollama counts as configured only once it answers, since its “credential” is a base URL that always has a default. Settings leaves requireCredential off — an established user may be mid-rotation — and keeps the single-page layout: the drag-to-reorder priority list, all three cloud cards, and the integration keys.

    The wizard opens on the first unfinished required step, or Ready when all four are done. A completed step is never locked — gating on prerequisites alone produced sidebar rows that were both ✓ and locked, closed to the user who had just finished them. Including the optional Integrations step in that scan used to park an otherwise-finished user on a step they could skip.

    The step advances on a button press, never on a state change. The auto-advance effect skips the AI step for the same reason it skips the resume step — a key can be saved from several places, and jumping steps the instant one verifies takes the screen away mid-thought. onComplete carries the deliberate press back to the wizard, from either Save and continue or the summary’s Continue.

  2. Resume lanes — uses the normal multi-lane resume upload cards. Uploading a PDF adds any titles it recognizes to desired positions and positive title filters, and AI extraction can enrich the full profile. It never replaces those lists: title extraction matches a fixed vocabulary, so plenty of real resumes (nursing, accounting, teaching, law) yield nothing, and replacing would wipe the values the search actually runs on — including values just confirmed in step 3, reachable through “Add another lane”. The upload response reports addedRoles and addedFilters, and the card says how many titles were added. Re-confirmation of step 3 is requested only when the upload actually contributed something. The “Add another lane” button only appears once all existing lanes have a file uploaded (to prevent accidentally adding duplicate empty lanes).

    laneHasResume (src/lib/profile/resume-lane.ts) is the single predicate for “this lane holds a usable resume” — a saved file and extracted words. The wizard read sourceFile while the lane card read wordCount > 0, so a PDF that saved but yielded no text showed “Not uploaded” on a card inside a step the wizard considered satisfied.

    Inside the wizard, Edit resume opens the inline builder (onEditRequested) rather than routing the page to /profile/resumes/:id/builder and abandoning a dialog that says to finish every step there. The inline builder’s loading state carries a Back to setup button; if the builder version never arrived it was a full-screen dead end with no close, no Escape and no back link, escapable only by reloading. If AI extraction fails (e.g. MAX_TOKENS on a long resume), the “Continue to job preferences →” button becomes enabled anyway with a note that extraction can be re-run from the Profile page; the user is never trapped.

  3. Roles & titles — requires desired positions and at least one include-title keyword. The form refuses to advance without both and names which is missing; it used to accept a blank submit and move on while the sidebar re-locked the later steps behind the user.

    Include-filters are keywords, not job titles. A positive keyword matches from a word boundary with the end left open, so ux matches “Senior UX Designer”, “UX Researcher” and “UI/UX Designer” — while a whole title like senior hci engineer / principal ux designer only matches a job whose title starts with that entire phrase, which is effectively a dead filter. Resume upload used to write parsed titles into both the desired-positions list and the include list; extractTitleKeywords (src/lib/jobs/title-keywords.ts) now derives short keywords for the filter while positions keep the full titles. It matches a curated vocabulary of domain keywords, drops any keyword another kept keyword already covers (design covers design system), excludes bare role suffixes and bare product (which would match “Production Engineer”), and falls back to the words the titles repeat — minus rank words — so a nurse or paralegal resume yields nurse / paralegal rather than nothing. The step’s hints say so, and a list that still holds pasted titles gets an inline Replace with keywords offer rather than a silent rewrite.

  4. Locations — work arrangement plus where each arrangement applies. Scanning needs both halves: the on-site/hybrid list becomes the location a job board is searched with, and the remote list decides whether a region-restricted remote posting is in scope. Each list is shown only when the arrangement that needs it is selected.

    Both lists use PreferredLocationsInput — the same picker as Profile → Preferences, with autocomplete against /api/locations/search (OpenStreetMap Nominatim), a Regions section offering the supra-national groups the geocoder cannot resolve, and “Add typed location” for anything neither list offers. That matters beyond convenience: filtering compares against exact stored values, so free-text boxes would have let onboarding save a spelling the scanner then failed to match. The component was already parameterized for both lists on Profile, so onboarding passes its own inputId, labels, hints, and the country-oriented placeholder for the remote list. An on-site or hybrid selection requires at least one place; the remote list is deliberately optional, because empty means “anywhere” — a real and common answer, and a listing that names no region is never ruled out. This used to be three checkboxes at the bottom of the preferences step with no way to say where, so a search could be set to on-site without ever naming a city. Resume upload or extraction may prefill role and title values; readiness follows saved data regardless of whether it came from the onboarding wizard, Profile, Settings, or resume extraction. A compatibility mode inferred from an older remote-preference value does not count as the user’s work-mode selection.

  5. Integrations (optional) — covers two free API keys that extend job coverage. Each card shows a short explanation, a “Help →” link to the relevant help section, and inline input fields with a “Leave blank to keep existing” placeholder when keys are already saved.

    • Adzuna (job aggregator) — App ID + API Key. Links to /help/job-search#aggregator. Enables Adzuna API scanning alongside ATS sources on every dashboard scan.
    • Brave Search (source discovery) — API Key. Links to /help/ai-providers#discovery-aggregators. Enables the “Search for companies” button in Settings → Scan sources. The sidebar marks this step with a dashed “Optional” badge and a · in the step circle when not yet configured. Clicking “Skip for now” advances to Ready without saving. “Save and continue” saves any non-blank fields (blank fields keep existing keys) and also advances to Ready.
  6. Ready — explains the next operational steps: review scan sources in Settings, run Scan for new jobs on the Dashboard, then review and evaluate imported matches.

    The panel branches on actual readiness. “Skip for now” and the integrations save both land here directly, so it is reachable with required steps unfinished; it used to claim Setup complete regardless, over a readiness card that was still listing what was missing. When incomplete it reads Almost there, lists the unfinished required steps as links back to the step that owns each, and the button reads Open dashboard anyway.

    The scheduled-scan checkbox defaults to off, matching the scan_schedule.enabled database default, and only the panel’s own button writes it. The × used to route through the same handler, so closing the dialog switched on six-hourly background scanning that the user had never been shown.

The resume step is complete once a PDF has been uploaded to any lane — the same rule the dashboard uses. It briefly also required AI extraction, which made the two disagree: the wizard called the step unfinished while the dashboard already treated the profile as ready, because readiness follows the saved profile data regardless of which screen filled it in. Extraction is still pushed hard inside the step, and the Continue button stays secondary until it has run or failed, but it does not gate the tick and never blocks moving on — a failed extraction used to leave the step ticked-but-stuck. The dashboard derives readiness from the actual saved setup data: a configured provider in the active chain, an uploaded resume, desired positions, at least one included title filter, an explicitly saved work arrangement, and — when that arrangement includes on-site or hybrid — at least one place to work. It does not depend on a wizard-only confirmation flag, and inferred compatibility defaults do not satisfy readiness. Unrelated profile edits and resume uploads preserve this distinction instead of writing an inferred compatibility mode back as an explicit selection. When setup is incomplete, the dashboard names each missing item and links to the screen where it can be completed. Once ready, the header no longer announces readiness as a badge — it shows the Check sources button instead (below). The Integrations step is never a gate — completing it only enables optional features.

Normal dashboard (after full setup):

  • Top dashboard tiles — metric cards appear before “Fresh matches” and “Apply next”. “Priority matches”, “Applications sent”, “Follow-ups due”, and “Interviews active” are pinned in a two-row half-width block, with “This week” occupying the other half.

  • Fresh matches — only unprocessed jobs discovered by scheduled or manually triggered scans inside the selected freshness window. Applied, rejected, manually added, stale, archived, and duplicate jobs stay out. Each row badge shows Posted <date> when a publish date exists, otherwise Fetched <date> from the discovery timestamp. Rows use the same compact list treatment as “Apply next” for consistent scanning, and row text wraps (no ellipsis truncation) so full titles and location lines remain visible on narrower viewports.

  • Evidence gaps to finish — appears above the action queue whenever there is outstanding gap work. Shows three counts (started but still needing detail; raised by 2+ roles and unanswered; answered and reused) and links to the Evidence bank. Hidden entirely when nothing is outstanding. Unfinished gap answers weaken every application at once, which is why they get a dashboard prompt rather than sitting inside a single job page.

  • Action queue — “Apply next” shows high-score jobs not yet applied to and “In flight” shows active applications (interviewing, follow-up needed). Each card shows company, title, fit score, and recommended next action.

  • Stat cards — supporting metrics for priority matches, applications sent, new jobs this week, generated PDFs, follow-ups, interviews, and skipped jobs.

  • Recent activity log — a “Source not returning jobs” warning appears first when any scan lane is failing silently (see below), then the “Latest scan” summary (status badges and per-source errors with inline “Disable source”), followed by the timestamped list of user actions.

  • Zero-yield warning — a lane can break without ever reporting an error. The private-page-scan lane returned total_jobs_found = 0 on every run for a week while still reporting 31–61 “companies scanned” and a completed_with_errors status, so nothing surfaced and the outage went unnoticed. detectZeroYieldLanes (src/lib/scanner/scan-yield.ts) flags any lane whose most recent runs all reached at least ZERO_YIELD_MIN_SOURCES (10) sources yet retrieved zero postings, reporting the streak length and start. Only the leading streak counts, so a recovered lane clears itself.

    A run that retrieves postings but imports none is deliberately not flagged — that is the normal steady state for the careerops lane, where every match is already in the database, and flagging it would train the warning to be ignored.

    History comes from getRecentScanYieldRuns(), which windows runs per scan type rather than applying a flat limit, so a high-frequency lane cannot crowd a low-frequency one out of the sample. When a lane’s whole sample is one streak, the warning reads “since at least ” rather than implying the sample boundary is the true start.

  • Check sources button in page header (hidden for new users) — a secondary button linking to Settings → Scan sources (/settings?tab=scan-sources, the tab that holds the sources table). It replaced the static Profile ready badge, which restated something the rest of the header already implied and never changed once setup was done.

  • Last source check line in the “This week” card, stacked directly under “Last scan” in a right-aligned column: the age of the last completed Crawl for companies or Search for companies run, using minute/hour/day relative time and falling back to “never”. It sits with the card’s other recency lines rather than on the button, so all three ages (“Last application”, “Last scan”, “Last source check”) read as one group. The footer row uses items-start rather than items-center, so “Last application” lines up with “Last scan” instead of floating between the two right-hand lines; below sm all three stack left-aligned.

    The age comes from loadLastSourceDiscoveryAt(), which reads fetchedAt from data/discovered-sources.json. Both discovery paths write that field only after their result is ready, so the widget reports the button activity that actually searches for sources. Importing an already-pending candidate does not invent a newer search time.

    An earlier version read the newest CareerOps scan run instead, on the theory that CareerOps is the only lane walking the whole enabled source list. That was wrong twice over. CareerOps is the scheduled discovery lane, so with scans enabled it runs every few hours and pinned the line to “today” permanently — it could never report staleness, which was its only purpose, and it merely restated “Last scan” in coarser units. And a single-source Scan jobs click persists a CareerOps run of its own, so checking one board claimed the entire list had just been checked. Neither the button next to the line nor anything else on the Sources page could move the number, because validation results were not persisted at all.

    It is rendered by LocalDaysAgoLabel (src/components/local-time-label.tsx), because the day boundary depends on the viewer’s time zone and a server-rendered count would disagree with the client near midnight. The label refreshes hourly. Its formatter, formatDaysAgo(), lives in src/lib/dates.ts so it is unit-testable — the vitest config only collects src/**/*.test.ts and cannot transform JSX, so a formatter left in the .tsx file is untestable by construction. It compares calendar days rather than elapsed hours, so a check at 11pm reads “1 day ago” the next morning instead of “today”, and clamps a future timestamp to “today” rather than reporting negative days. dates.ts also now owns parseStoredDate(), the ISO / bare-YYYY-MM-DD parser that the other local-time labels previously kept as a private copy. Covered by src/lib/__tests__/days-ago-label.test.ts.

  • Scan for new jobs button in page header (hidden for new users).


Jobs /jobs

The full job pipeline. Lists every discovered job with filtering, preference status, posting maintenance, and bulk tools.

Features:

  • Fit score badge, status badge, freshness label, and role archetype per row.
  • Sort by fit score, date, company, preference, or workflow status.
  • Filter by workflow status, preference status, score range, location, company, recommendation, posted date availability, and added date availability.
  • The last sort and filter settings are restored automatically on the next visit; named presets are still available for recurring review modes.
  • Company focus (/jobs?company=<name>) — arriving from a job detail header link (see Job Detail) focuses the list on one company and shows every position at it in every status. While focused, the saved column filters are deliberately bypassed rather than merged: the default status filter hides Applied, Rejected and Skipped, which are exactly the rows the link exists to surface. A banner names the company and the row count, and Show all jobs clears the focus and restores the saved filters via router.replace("/jobs"). Opening any column filter menu also clears the focus, since a filter that did nothing would look broken. The focus is never persisted to the saved table state. The mobile card list applies the same filter server-side in src/app/jobs/page.tsx; the desktop table receives every row plus a companyFocus prop.
  • Narrow-screen card list — the card list under lg:hidden renders at most MOBILE_CARD_LIMIT (50) cards. getJobs() orders by fit score, so those are the 50 strongest matches; below them a line names the count and points at the desktop table, which is where sorting and filtering live. The cap exists because the cards are markup the desktop layout hides rather than skips — an uncapped list put all 600 jobs into every page load a second time.
  • Page weight — the desktop table is a client component, so its rows are serialized into the page once each. src/app/jobs/page.tsx therefore projects each job down to MainJobTableRecord (see data model) before handing it over, rather than passing whole records. Job descriptions and evaluation arrays are not part of the table and stay on the server. Together with the card cap this took /jobs from a 9.9 MB document at 600 jobs to 1.5 MB. The size mattered beyond load time: at 9.9 MB React reported a recoverable hydration error on roughly half of page loads (it discards the server HTML and re-renders the whole tree in the browser); at 1.5 MB it did not recur across 18 consecutive loads. /archived uses the same projection via ArchivedJobTableRecord.
  • Preference column — shows Match when a job still fits the current profile preferences and constraints, or Out of scope when saved preferences have changed and the job no longer fits. This is a derived display/filter value, not a separate stored job status.
  • Posted column — shows a short MM/DD/YY when a real posted date is known; the cell is left empty when there is no date or the stored value is not a parseable calendar date (no em dash or placeholder text).
  • Text search across company and title.
  • Bulk operations: change status on multiple jobs, archive, or delete in bulk. Select jobs with the row checkboxes, then use the bulk action bar:
    • Evaluate selected — runs AI evaluation on all selected jobs in sequence. A per-row progress indicator shows Pending, Evaluating, Done, or Error.
    • Retry failed (N) — appears after a batch run if any jobs returned an evaluation error. Re-runs evaluation only on the jobs that failed, without re-evaluating already-successful ones.
  • Marking a job Skipped (individually or in bulk) removes it from this list immediately — it is auto-archived and moves to the Archived page.
  • Bulk delete asks for confirmation. If selected jobs have user activity, the confirmation warns before deleting.
  • Maintenance tool to verify posting liveness, archive expired untouched jobs, and identify active jobs whose titles no longer match saved title filters. Automatic cleanup archives rather than deletes — a single unauthenticated liveness fetch is not strong enough evidence to destroy a row and its evaluations, and archived jobs are permanently protected from further automatic removal. Out-of-scope cleanup only bulk-deletes unprotected jobs; jobs with user activity or recent discovery must be removed through explicit selected-job actions. Clicking Verify active postings opens a blocking ProgressModal while the liveness check runs; on completion the modal shows a badge summary (checked / active / uncertain / expired counts) and prompts the user to close and take action on expired jobs in the section below.
  • Bulk evaluate — selecting jobs and clicking Evaluate N opens a blocking ProgressModal that tracks per-job progress (“Job X of N”) while the AI evaluation streams for each selected job. On completion the modal shows how many evaluated successfully and how many failed.
  • Add job manually via modal (paste URL or fill in details). Jobs added this way are stored with source = 'manual' and display a Manual badge in the Source column.
  • Source column — shows where a job came from, resolved in this order (getJobSourceLabel in src/lib/job-table-helpers.ts): the browser-board source name (LinkedIn, Wellfound, Indeed, …), then the ATS provider for <provider>-api sources (Greenhouse, Lever, Ashby — previously blank), then the originating site derived from source_url, and finally Scanner when there is no usable URL. Sites the public host list does not name render as their bare hostname; config/source-labels.local.json (gitignored, see config/source-labels.example.json) can give them friendly names without publishing that list. When the job has an HTTP(S) source_url the label is a link that opens the original search result in a new tab; otherwise it stays a plain badge. javascript: and other non-HTTP URLs are never linked. The label is resolved on the server and reused for display, sorting, and filtering, so all three always agree. Scanner remains a filter option even when no job currently uses it, so a saved filter from before per-site labels existed stays selectable — and it still matches every source without a browser-board name.
  • Email job alert imports — drop .eml, .html, or .txt files into data/email-job-alert-imports/. The local watcher parses them and queues extracted candidates in the Email approval modal — jobs are never added automatically. Each candidate is pre-scored against your saved target roles and positive title filters (Matches criteria / Off target / No criteria set). Candidates matching your criteria are pre-checked; off-target ones appear unchecked. Choose Add to jobs or Dismiss selected per candidate, or Dismiss all to clear the queue. Unchecked candidates stay pending until you add or dismiss them. The modal appears on both the Dashboard and Jobs pages and polls every 8 seconds for new arrivals. Jobs without a direct posting URL are imported as email leads that can be resolved via Resolve posting on the job detail page.
  • Table width — the Jobs table fits inside the Shell’s max-w-6xl container rather than widening the page. Grid wrappers carry min-w-0 (grid items otherwise default to min-width: auto, so one wide descendant stretches the track and drags every sibling out with it), and cells use break-words for long tokens. The table deliberately does not use overflow-x-auto: setting overflow-x makes overflow-y compute as a scroll container, which breaks the viewport-relative sticky header (.data-table-sticky-head). The same min-w-0 treatment is applied to the Dashboard, whose scan-error list can contain 250-character URLs with no spaces.
  • Column filters — click any column header to open a sort + multi-value checkbox filter dropdown. Active filters show a count summary (“X of Y jobs”) with a “Clear all filters” link.
  • Saved filter presets — name and save up to 5 filter+sort combinations as reusable chips above the table. Presets are persisted to the database and survive page reloads. Click a chip to re-apply; click × to delete.
  • Review queue banner — when low-confidence imports are present (jobs with a description under 100 characters), a yellow banner appears at the top of the Jobs page showing the count of jobs pending review. The banner auto-hides when the queue is empty. It is informational: the per-row Review column with Approve / Dismiss buttons was removed to keep the table inside the page width. approveReviewAction and dismissReviewAction remain in src/app/jobs/actions.ts but are not currently wired to any UI, so review_status is not clearable from the Jobs table.

Job Detail /jobs/[id]

Tabbed view for a single job. Five tabs — Overview, Evaluation, Resume, Apply and Outreach:

The company name in the page header ({company} · {location} · {remoteType}) is a link to the Jobs list focused on that company, when there is anything there beyond the job already open. The rules live in src/lib/jobs/company-link.ts and are driven by getCompanyJobStats(company), which counts non-archived jobs rows for that exact company name:

Company stateHeader renders
Applied to one or more positions (applied > 0)Link with the applied count — Reddit (2)
More than one active position, none applied to (total > 1, applied === 0)Link, no count
A single active position, never applied toPlain text, no link

applied counts the statuses in APPLIED_JOB_STATUSESApplied, Follow-up needed, Recruiter responded, Interviewing, Offer, and Rejected. Everything after Applied can only be reached by having applied, so all of them count as an application at that company; Skipped and Archived do not.

The link is /jobs?company=<encoded name>. Counting and matching are exact on jobs.company, matching the Jobs table’s own company filter, and archived jobs are excluded so the count always equals what the focused list shows.

Overview tab

  • Company, title, location, remote type, ATS source, freshness.

  • Fit score, recommendation badge, role archetype.

  • Match rationale, main concern, salary notes.

  • Requirement match table showing which JD requirements the profile covers.

  • Gap list: requirements not yet addressed.

  • Red flags list.

  • Recommended resume — a sidebar box under Next step naming the resume to tailor from, whether that is your saved choice for the role or the evaluation’s suggestion, with a link into the Resume tab. This used to be a full-width Resume evidence column in the match grid, which gave a one-line answer the same weight as two long lists; the grid is now two columns (requirement match, gaps and red flags). Any resume-evidence line the evaluation recorded still shows in the box, unless it just repeats the lane name.

  • Job description — collapsed panel showing the saved description text. When nothing is saved and the posting URL is resolved, a Fetch description button reads it from the board (src/lib/scanner/jd-fetcher.ts). The button reports what the attempt actually did, and says so in place rather than claiming a save:

    OutcomeWhat the user sees
    fetchedthe card becomes the collapsed description panel
    unsupportedThis posting is not on a job board the app can read. Paste the description under Edit job details.
    emptyThe job board answered, but had no description for this posting. — usually a posting that has since been taken down
    unreachableCould not reach the job board just now. Try again in a moment.

    It previously used the shared SubmitButton, which shows Saved ✓ whenever a form action finishes. A fetch that returned nothing therefore looked identical to a successful one, while the card underneath went on saying the description was missing. FetchDescriptionButton (src/app/jobs/[id]/fetch-description-button.tsx) uses useActionState so the server action’s outcome reaches the screen.

    Boards the fetcher reads. Greenhouse, Ashby and Lever, chosen from the job’s source or the host in its URL. Everything else is unsupported.

    • Greenhouse needs a board token and a numeric posting id. Jobs found through the board API are stored under the employer’s own careers URL, because that is the absolute_url Greenhouse returns — samsara.com/company/careers/roles/7839138?gh_jid=7839138 names no board, so the fetcher looked for a token in the URL, found none, and gave up silently. It now reads the id from gh_jid (or a numeric path segment) and recovers the token from the scan source the job’s company was found through, via mergeTrackedCompanies in careerops-scanner.ts. Board-hosted job-boards.greenhouse.io/{token}/jobs/{id} URLs still resolve from the URL alone.
    • Ashby scrapes the posting page for its application/ld+json block; a page without one — typically a posting since taken down — is empty, not an error.
    • Lever reads descriptionPlain from the public postings API.

    Greenhouse markup. Greenhouse returns content entity-escaped: &lt;p&gt;… rather than <p>…. htmlToText stripped tags first and decoded afterwards, so the tags only existed after the strip had run and were saved as literal text. It now decodes an escaped payload before stripping. Descriptions saved before this change are not rewritten — re-fetching one means clearing it under Edit job details first.

  • Edit job details — collapsed form to overwrite position, company, job posting URL, and job description without creating a duplicate record. Useful when LinkedIn or other scanner sources capture only partial metadata. All four fields are pre-filled with the current values. A reminder to re-run evaluation is shown after saving, since any description change makes the existing AI analysis stale.

Evaluation tab

Evaluation answers one question — should I spend more time on this position? — and does no work belonging to a later stage. It runs only when you click Evaluate; discovering a job triggers no AI.

Fast Evaluation (fast-v2), one AI generation:

  • Fit score out of 100, summed from four components the model scores separately: core requirements (0–40), role and seniority (0–25), relevant evidence (0–20), preferences and direction (0–15). The model never returns a total — JST calculates it, so the headline number and the breakdown beneath it cannot disagree.
  • Recommendation, derived from ordered rules rather than by the model: BlockedPriority apply (fit ≥ 85 and strong direction alignment) → Strong apply (fit ≥ 70, strong or partial) → Review manually (fit ≥ 55) → Skip.
  • Confidence — High / Medium / Low, describing source quality, not candidate quality: how much usable job description and resume evidence the assessment had. Calculated locally with no AI call.
  • Direction alignment — strong / partial / none. Whether the role matches the direction you are searching in, which is separate from whether you could do the job. A capable match in the wrong direction lands at Review manually, not Strong apply.
  • Strengths, concerns, requirement tally (8 supported · 2 partial · 1 unknown), posted compensation, and recommended resume lane.
  • View details discloses the component breakdown, direction rationale, requirement matches, evidence used, red flags, and the provider/model/duration for the run.

Requirements in the posting. A second column beside the evaluation box lists what the posting actually asks for, as bullets. A score’s first follow-up question is “against what?”, and the answer was previously a tab away in the collapsed job description. Sources, in order (src/lib/jobs/posting-requirements.ts):

  1. The evaluation’s own requirement list (modelOutput.requirementMatches) — the requirements the fit score was computed from, each tagged supported, partial or unknown. Preferred because showing a different list beside the score would ask the user to reconcile two lists that were never the same list.
  2. Merged requirement strings from a run stored before modelOutput existed (Lead end-to-end briefs. — supported (…)), split back into requirement and status. Used only when a majority of the lines actually carry a status word: the oldest evaluations wrote free-form “X aligns with Y” notes into that same field, and presenting those as the posting’s requirements would put words in the posting’s mouth.
  3. The saved description, parsed for bullets — bullets inside a requirements-style heading when the posting has one (continuing through Preferred qualifications, stopping at Benefits / Compensation / About us), otherwise every bullet, since a flat list is common. Fragments under 12 characters, paragraphs over 300, and duplicates are dropped, capped at 24 items. Labelled as taken straight from the description and not yet checked against your resume.

An older evaluation whose notes are all there is falls back to those, labelled as notes. With no description saved and nothing recorded, the panel says so and points at Fetch description. No AI call is involved at any step.

A failed run is reported, never scored by rules. When the AI does not produce a usable evaluation, the run fails with the reason and the job keeps whatever state it had. It used to fall back to the keyword scorer and save that as the evaluation: a Senior Director, User Experience posting came back 64% “Technical Specialist”, saved, badged and counted exactly like a real assessment. A wrong answer presented as an answer costs more than no answer, and every one of these failures is something the user can act on:

What happenedWhat the message says
The model ran out of timeollama / qwen3.8:27b-mlx did not finish within 600s. plus what to change
Unreadable output after 3 tries…returned a response that could not be read as JSON, after 3 attempts. A larger model (14B+ locally) is more reliable at structured output
An answer missing its core fields…answered, but the answer was missing what an evaluation is made of (fitComponents, roleArchetype).
Auth, quota, networkthe provider’s own message, per provider (see the chain report in Settings)
No provider configured at allNo AI provider is configured — add an API key in Settings → AI Provider.

The step named is the step that broke. Each message is prefixed with the phase the run failed in, and the phase is now read from a table covering all four progress steps rather than a ternary chain with a default. preparing fell to that default and was attributed to validate, so anything thrown before a provider was ever contacted — no provider configured, a job-description fetch that failed — was reported as “The AI response was incomplete.” for a run that never asked for a response. Relatedly, the not-configured error contains the words “api key”, so it matched the invalid-key branch and told someone with an empty settings page to re-enter a key they had never entered; it is answered before that test now. Errors carrying their own phase (EvaluationPhaseError) still override the table.

The phase table (PHASE_FAILURE_ATTRIBUTION), the per-phase prefixes (FAILURE_PHASE_MESSAGE) and the message mapper (toUserMessage) live in src/lib/evaluation/failure-reporting.ts. They were lifted out of the route because a Next.js route file may only export its handlers and a few reserved config symbols — anything exported for a test breaks the build — and these three are the part worth testing directly.

Evaluations saved by the old behaviour still exist and still render; they say scored by local rules, no AI model in the run line and carry a banner saying the score and role archetype are a rough sort rather than an assessment. Re-evaluating replaces one.

Deadlines are per provider, and a chain spends them in turn. A cloud call is capped at 150s — the cap exists so a stalled paid call cannot run up a bill. A local model gets 10 minutes, because a local model’s speed is a property of the machine it runs on, not of the request: the same 12B model that answers in 70s on one Mac needs several times that on older hardware, and a 27B model needs more again. Any bound tight enough to feel responsive on fast hardware makes the app unusable on slow hardware, where waiting is the trade the user already accepted by running locally. The local bound exists only so a wedged request cannot hang forever; impatience is served by Cancel, not by a short deadline.

Each provider in the chain is bounded on its own, so a local model that runs out of time hands over to the cloud provider configured behind it — which is the entire point of putting one there. The run’s outer bound covers the sum, since a bound sized to the first provider would end the run before the fallback could take its turn.

One reader for model JSON. Every provider is asked for raw JSON and told not to use markdown fences; models wrap it in ```json anyway, because an instruction is a request rather than a guarantee. Anthropic and Gemini each grew their own unwrapping regex and Ollama grew none, so a local model whose answer was perfectly good inside a fence was reported as “Ollama returned invalid JSON” — over one backtick, after 80 seconds of work, followed by a paid call to fix a problem that did not exist. parseJsonResponse in src/lib/ai/json-response.ts is now the single reader for all three: it unwraps a fenced block (tagged or bare — Anthropic’s own pattern required the json tag and missed bare fences), falls back to the first embedded object or array, and on failure raises an error naming the parse reason with a 300-character preview. The words “invalid JSON” are load-bearing in that message: they are what marks the failure retryable and worth failing over, rather than an auth or quota problem the user has to act on. Ollama’s path also separates the three things that used to share one message — an answer cut off at the token limit, an empty answer, and output that is genuinely not JSON — because they call for different responses.

A local model gets a second try before the chain spends money. Output quality is non-deterministic: the same model that mangles one answer usually produces a clean one next time. The economics are lopsided — a local retry costs time the user has already committed, while moving on spends a paid call — so a local provider is retried once on unusable JSON before the chain hands over. Cloud providers are not retried at this level: withRetry already covers the whole chain, and retrying a paid call twice in a row is how one rate limit becomes two. A local failure that is not about JSON (the server is down, the request timed out) hands over immediately.

The modal follows the chain. Progress used to name the chain’s first provider for the whole run, because that is what a FallbackProvider answers until something succeeds — so a run that fell through to the cloud after 20 seconds spent two minutes telling the user a local model was working on it, then reported a different one at the end. Each hand-over is now announced: the running line switches to the provider and model actually working, and the one that stopped is listed above it, struck through, with its reason (ollama (gemma4:12b-mlx) — Ollama returned invalid JSON. Try a larger model (14B+)…). The same correction applies to failure messages, which read the provider after the call rather than before, so a validation failure is attributed to the model that produced it.

An auto setting is resolved before it is announced (AIProvider.prepare()): latest-sonnet names a policy, not a model, and it only became a concrete id inside the request — so the modal would have shown the sentinel while the user waited. Resolving first costs nothing, since the lookup is cached per key and the request makes it anyway.

Cancel, and the model switch it offers. The evaluation modal’s Cancel now stops the run rather than only hiding the dialog. Closing the EventSource is the only cancel signal a browser can send on a stream it did not open with fetch; it arrives at the route as the stream’s cancel callback and aborts the run. What that stops is the waiting, not the generation — a request already sent finishes wherever it is running — so the guarantee is about the save: an answer that arrives after the user walked away is discarded rather than landing on a job they have moved on from. The dialog says so (gemma4:12b-mlx may still be finishing on your machine — its answer is discarded) rather than claiming the model was stopped.

It also stops the work that has not started. The abort signal now reaches the retry loop and the provider chain, not only the deadline wrapped around them. Without that the cancelled run kept going underneath: the retry loop would wake and spend another attempt, and the chain would walk on to the paid provider behind the slow one — on behalf of a user who had already stopped waiting. The chain checks both before a provider and again after resolving its model, since resolution is a network call the user can cancel during, and a cancelled run never fails over — moving on is the spending being prevented. An in-flight request still cannot be recalled; the next one can be, and now is.

Navigating away counts as cancelling. Closing the stream is the only cancel signal the server gets, and unmounting is a cancel the user never gets to click. Leaving the job page mid-run used to leave the EventSource open: the route’s cancel callback never fired, the run was never marked aborted, and the evaluation was saved onto a job the user had already left — a chain would also walk on to the paid providers behind the one that was running. Both of the modal’s close paths need a click on a dialog that no longer exists, so the stream is now closed when the component unmounts. Navigating away and clicking Cancel are the same guarantee: the answer is discarded, and nothing behind it is started.

Cancelling a local run is usually a verdict on the model’s speed, so the answer offered is the other models that machine already has: a picker of installed Ollama models, smallest first (on one machine, size is the closest proxy for speed there is), and Switch and evaluate again — which saves the choice as the Ollama model and restarts the run. POST /api/ai/ollama-model changes that one field, because the settings form submits every field at once and someone cancelling a slow run should not have their keys and provider order make the round trip. Embedding and reranking models are filtered out of the list everywhere it is used: they answer /v1/models alongside chat models and cannot serve a generation at all, so offering one is a choice that can only fail.

Blocked is not Skip. Blocked means a saved non-negotiable rules the role out however well you score — a 92% fit that requires relocation you have ruled out. Skip means nothing blocks it but the fit is too low to justify the effort. A hard blocker requires explicit evidence on both sides: something the posting actually states and a constraint you actually saved. Missing salary, an unknown reporting line, an absent preferred qualification, or an inferred culture mismatch are never blockers.

Both halves are checked against their sources, not just for being present. The candidate constraint has to match one of your saved constraints or deal breakers, and the quoted evidence has to appear in the posting — in any field the model was shown, since an imported job often states “On-site” in its location and remote-type fields and never repeats it in the body. Checking only that the model returned two non-empty strings let it invent both halves, and nothing downstream questions a blocker once it exists: a hallucinated pair ruled a high-fit role out on nothing. A job with no stored posting text blocks nothing, which is the safe direction.

Only the AI path raises blockers. The check confirms a quote is real; whether it conflicts with the constraint is the model’s judgement, and a keyword matcher cannot make it. The rule-based fallback tried: it flagged a deal breaker whenever any word over three characters appeared anywhere in the posting, so “Remote only” was flagged by every posting containing “remote” — including the remote job you want — and the compatible role came back Blocked. That signal is back to what it always was, a few points off userPreferences, and §15 stands: an inference never blocks.

Unknown is not a mismatch. Requirements the resume is silent on are counted as unknown, never as gaps.

Failure behavior:

  • Progress streams as ordered phases — preparing → evaluating → validating → saving — with the provider, model, and elapsed time. There is no percentage bar: the work is a single call, so a filling bar would be invented.
  • Core fields decide whether an evaluation exists at all: role, direction alignment, and the four components. Everything else degrades to empty and records a completeness warning, so one malformed field no longer costs the whole evaluation.
  • Malformed JSON is retried automatically (3 attempts). If it still cannot be read, the run fails and says so — it is not scored by rules instead. Auth, quota, and network errors surface immediately with an actionable message rather than being retried.
  • Every generation is bounded, because not every provider bounds itself: 150s for a cloud call, 10 minutes for a local one, per provider and in turn (see Deadlines are per provider below). Cancel stops waiting at any point. An unbounded call would leave the fallback chain unreachable and the spinner running forever.
  • Errors name the phase that failed rather than a block letter.

What evaluation no longer does. Evaluation performs no ATS keyword extraction, no compensation research, no live web research, no company research, no contact lookup, and generates no interview stories.

ATS keyword extraction, requirement extraction, evidence mapping and compensation now run in Application Preparation, which is triggered by Generate Resume — see the Resume tab.

Interview-story work now lives in Interview Prep. Evaluation no longer proposes stories; stories saved before that change keep their kind and stay filterable.

Legacy evaluations remain readable. Jobs evaluated before this change still render their original A–G sections and can be re-evaluated. Re-evaluating preserves the old detail, your gap answers, saved stories, generated documents, company research, and outreach drafts — and no longer resets an Applied or Interviewing job back to Reviewed.

User correction still overrides score and recommendation with a note, and now includes Blocked in the vocabulary.

AI evaluation data sources (all fed into the analysis):

  • Full job description (up to 6,000 characters — captures required qualifications that appear deep in the posting).
  • Candidate profile: goal, urgency, direction, compensation needs, work preferences, target roles, deal breakers, constraints.
  • Skill inventory (up to 30 skills with strength level and evidence source).
  • Role strategy (role-fit scores and rationale from the profile).
  • Active resume excerpts — every active lane, sharing a 5,400-character budget (1,800 each up to three lanes, and divided evenly beyond that, so the combined excerpts never exceed the budget however many lanes exist) — so strengths and proof points are grounded in actual resume text, not inferred from skill abstractions. Lanes used to be taken two at a time by array position, which broke the multi-lane model from both ends: every active lane name is offered for the resume recommendation, so the model could pick a lane whose text it never saw, and confidence is derived from the character count of every active lane, so a run that read two of three still reported itself well-evidenced.

Resume tab

  • Generate tailored resume for this job: picks best base resume, produces HTML and PDF output with tailoring summary and keyword coverage %.
  • Per-section Keep / Update / Hide modes. Summary, key achievements, and experience default to Update; every other section defaults to Keep. Only sections set to Update are sent to the AI and written back. Modes are addressed by section id, and each section type also resolves by its type name, so a lane built from the blank starter (ids like s-summary) tailors the same as one extracted from a PDF (ids like summary) — previously those lanes silently fell through to Keep and were never tailored at all.
  • Resume draft editor: edit the tailored resume before export.
  • Keyword coverage progress bar.
  • Download PDF button.

Generation progress and Stop. The modal streams the generation from POST /api/resume/generate/stream (server-sent events) instead of waiting on one blocking request behind a spinner that promised “15–30 seconds” while a local model took five minutes. It lists four stages — Reading the posting, Writing the tailored sections, Checking claims and keywords, Saving the draft — ticks each off as the server reports it, names the provider and model running the current stage (including a fall-through down the chain), and shows elapsed time. It shows no percentage: each stage is one long call with nothing partial to report. A reused preparation reads Used this job’s saved posting analysis. Stop aborts the request; the route’s AbortController stops everything not yet started and, above all, the save — the existing draft is untouched (generateResumeDraft checks the signal immediately before writing). A request already running at a provider cannot be recalled and finishes there unused. A credits notice from either AI stage is shown in the modal, and an ai_credits_exhausted error refreshes the page so the credits banner appears at once. The plain POST /api/resume/generate route still exists and returns the same result without progress. The elapsed clock sits outside the live region (aria-live="off") so a screen reader announces stage changes, not every second. When the job already has a draft the modal warns that generating replaces it — edits included — and points to per-section ↻ Regenerate; while running it shows how long the last draft for this job took and on which provider (generation_ms / provider_used), as an honest expectation rather than an estimate the app would have to invent.

One pipeline. buildTailoredDraft(jobId, { resumeId, sectionModes, onStage, signal }) in src/lib/documents/resume-generator.ts runs preparation, the AI rewrite, the evidence guard, keyword preservation, and coverage, and returns the draft plus timing (generationMs, per-stage stages, providerUsed, modelUsed, notice). generateTailoredResume (HTML + PDF) and generateResumeDraft (editable draft) each carried their own line-for-line copy of this before; both now call it and differ only in what they save. The lane’s approval is checked before any AI work, so an unapproved lane fails immediately rather than after preparation. AI tailoring now runs whenever the writer chain has any provider — the old check looked only for the three cloud keys, so a user running Ollama alone never had a resume tailored and was not told why.

One part at a time. Tailoring writes each selected part in its own call through writeUnit (src/lib/documents/resume-unit-writer.ts): every job’s bullets, the key achievements, the skills list, each custom section, and — last — the summary. It replaced a single call that returned every rewritten section as one JSON answer, which failed three ways at once: the later roles in a long answer were the weakest, one malformed answer lost the whole resume to source text, and the summary was written before the bullets it summarised.

  • Order and concurrency. Roles (newest first), then key achievements, skills, and custom sections, three in flight on a cloud provider and one at a time when the writer chain leads with Ollama (runUnits) — a local server answers one request at a time, and a request left waiting past Ollama’s own limit is dropped. When a cloud-led run falls through to Ollama mid-run, the three parts’ chains reach it independently, so the Ollama adapter serialises its own generations per server (inTurnForServer in src/lib/ai/ollama.ts): the wait happens inside the app’s per-provider deadline instead of in Ollama’s queue, which drops requests. A queued request checks, when its turn comes, whether its run has already ended — writing calls pass the run’s signal as AIProviderConfig.signal, which withChainDeadline fires on success, timeout or cancel — and whether it has waited past the local deadline, and is skipped if either is true (OllamaRequestSkippedError). The same signal aborts an in-flight Ollama request, so a stopped resume stops using the local model. Inside a fallback chain, FallbackProvider.attempt hands each try its own signal — linked to the run’s signal and the chain’s cancellation, and aborted when that try fails or its per-provider deadline passes — so a local model that times out stops when the chain moves on to the next provider, rather than running until the whole run ends. The summary always runs last, from summaryContextFor the parts as they were just written, so it describes the resume being sent.
  • Shared prefix. Every call’s system prompt (buildUnitSystemPrompt) and candidate/ posting context (buildUnitSharedContext) are byte-identical across parts and across regenerations of a job; only buildUnitTask — the part, its evidence-map entries, the job phrases it already carries, and any note — differs. A local model’s prefix cache and a cloud prompt cache pay for the long prefix once.
  • Evidence map in use. Preparation’s evidence map (requirement → verbatim proof) was computed and never read by tailoring. Each part’s task now lists the entries whose quoted evidence sits inside its own lines (evidenceForLines, using preparation’s normalizeForGrounding), as What these lines prove for this posting.
  • Reorder by relevance, never drop. A role, key achievements, skills, or custom section comes back as { "lines": [{ "source": n, "text": "…" }] } in the writer’s order. validateUnitOutput accepts it only when every source line appears exactly once; a dropped, duplicated, or out-of-range line fails the part rather than being patched, because a guessed mapping is how a bullet’s facts land on the wrong line. applyUnitResults then puts the source in the same order, so the evidence guard, keyword preservation, and the unchanged-section measure — all positional — still compare each line with the line it was written from. The keyword-count rankItems order remains the fallback when no AI runs.
  • Writing rubric instead of “rewrite everything”. The prompt’s truth rules are unchanged in substance; the old instruction that handing a line back unchanged was not a rewrite is gone. In its place: start with a verb in the source’s tense; what, how, scale, and result as the evidence gives them; the posting’s supported language early; named specifics kept; at most 220 characters; no two bullets in a job opening with the same verb; most relevant first; a strong relevant line may keep its wording. Summary: 2–4 sentences, at most 90 words, identity and scope first.
  • Title alignment for the summary. The summary task names the posting’s title and closestHeldTitle — the job the candidate actually held sharing at least two title words with it — and asks for an honest professional identity in the posting’s words, never the posting’s title itself. On a real run a 12B model otherwise opened every summary with a generic “Product design leader”, which fails the title check even when the candidate held a title very close to the one being hired for.
  • Job phrases in sentence case, never tacked on. Keywords are shown to the writer in a sentence’s own case (sentenceCase: “Cross-Functional Collaboration” becomes “cross-functional collaboration”; single words and acronyms keep theirs), and the rubric forbids bolting a phrase onto a sentence end. Measured on a real summary, a model did both anyway until the check below sent it back.
  • One repair, bounded. Each part is checked by lintPart (see Resume checks). A part that breaks a rule is sent back once with the specific problems listed; the answer with fewer problems wins, and any that remain are reported, not retried.
  • An empty summary is still written. Whether the summary is written depends on the section being present and set to update, and on the draft having bullets or key achievements to write it from — not on the approved lane having summary text. A lane built from the blank starter can leave it empty; it previously came out with no summary at all. ↻ Regenerate on such a summary works the same way, and ↻ Regenerate is enabled even when the editor box is empty, since it starts from the approved lane.
  • Failures stay local. A part that fails keeps its approved wording and is recorded in evidenceAuditJson.unitFailures; the editor names it (Not tailored, kept as in your approved resume: Design Lead, Northwind (…)). Only when every part fails does the draft become source-only.
  • Progress. The writing stage reports each part as it starts — Part 2 of 6: Design Lead, Northwind — with the provider and model.

Resume checks. src/lib/documents/resume-lint.ts measures the rules that can be decided from text, which used to live only in the prompt. It is imported by the browser too, so it stays free of Node APIs.

  • lintPart(kind, lines) — per part: hype terms and self-rating openers (the same HYPE_TERMS and SELF_ASSESSMENT_OPENERS lists the prompt quotes), first person (a capital “I”, or my/mine/we/our — not “US” or “ME”, which resumes use as places), bullets over 240 characters, a summary over 4 sentences or 90 words, two bullets in one job opening with the same verb, duplicate and empty lines, and — given the job phrases the part did not already carry — any such phrase tacked onto the end of a sentence with a connective (”… using user-centered design.”). Skills are checked only for duplicates and empties.
  • checkResume(draft, keywordSignals, supportedKeywords, targetTitle) — the whole-draft ATS & recruiter checks report: the target role’s words in the headline or summary; every must-have phrase the evidence supports shown in the summary or a bullet, not only in Skills (unsupported phrases are never asked for); no job phrase both used more than 3 times and above 2.5% of the words (a core subject spread across a two-page resume is not stuffing — ten uses of “accessibility” in ~900 words measured about 1%); an email address and a phone number; experience and skills under headings an applicant system recognises; one date format across jobs; and the per-part writing rules. Stored at generation in evidenceAuditJson.checks, and recomputed live in the editor as the user types. It checks only sections that will print (printedSections): the editor’s Remove takes a section out of sectionOrder and keeps its content, so reading every stored array let removed text satisfy a check.

Skills keywords join their category. When a confirmed, skills-safe keyword is still missing after tailoring and the skills list is written as Category: a, b lines, the keyword is appended to the matching category line — by category name first (tool → Tools/Technologies, methodology → Methods/Process, and so on), then by shared words, else the last line. It used to be appended as a bare new line, which printed a lone word under the categories. A plain list still gets a new line.

Which provider writes. Preparation, tailoring, and ✨ Improve all use getWritingProvider(): the provider chosen under Settings → AI Provider → Resume writing uses, then the rest of the enabled chain as fallbacks. With no writer chosen it is identical to getActiveProvider().

Request tuning for writing calls. Measured on the real provider chain before this change, one draft took 5m04s on a local gemma4:12b: 1m54s for preparation and 3m10s for tailoring, of which 1m49s was a hidden thinking pass before any JSON was written. Writing calls now pass reasoning: "low" and a low temperature through AIProviderConfig, and each adapter maps them to what its resolved model accepts:

Providerreasoning: "low"temperature
Ollamareasoning_effort: "none" — thinking off (a one-bullet rewrite went from 770 tokens / 27s to 17 tokens / 1s)sent
OpenAI GPT-5 / o-seriesreasoning_effort: "low"not sent — reasoning models reject it
OpenAI older chat modelsnot sentsent
Anthropicoutput_config.effort: "low" on Opus 4.5+, Sonnet 4.6+, Fable, Mythos; not sent on Haiku 4.5 / Sonnet 4.5, where it errorssent only to models that still accept sampling (Opus ≤ 4.6, Sonnet/Haiku 4.x); Opus 4.7+, Sonnet 5, and Fable reject it with a 400
Gemininot sent — thinking controls differ across Gemini generations and a rejected parameter would fail the callsent

After the change the same job generated in 2m22s on the same local model (preparation 1m09s including model load, writing 1m12s); regenerating it reused the preparation and wrote in 46s. Tailoring is also bounded like preparation and evaluation now: withChainDeadline over the whole chain, cancellable, with maxTokens: STRUCTURED_OUTPUT_MAX_TOKENS. It previously had no deadline and no output budget, so a reasoning model could truncate its JSON at 4,096 tokens and withRetry would run the whole chain again, up to three times.

Tailored resume AI context (shared by every part):

  • The approved lane, whole, as evidence (buildBackgroundBlock with the heading Candidate’s Approved Resume (evidence)) — every section, including the ones being written, since each part is written separately. This replaced a 5,000-character excerpt of the source PDF, which repeated the selected sections already in the prompt and, being cut at a character count, dropped the later sections first.
  • What this posting requires (buildRequirementsBlock) — up to 20 requirements from Application Preparation, one line each with type and evidence status.
  • The job description, capped at 6,000 characters (was 10,000) — the requirements and keywords were already extracted from the full posting.
  • The prompt is ordered from what changes least to what changes most — rules, the candidate’s evidence, this posting, then the one part being written — so a regeneration and a local model’s prefix cache reuse as much as possible.
  • All validated keyword signals with their priority, category, source, and rationale.
  • Missing keywords — keywords absent from the pre-AI source draft are identified before the AI call and passed as a separate priority list so the AI knows exactly which terms to weave in where the source resume provides supporting evidence.
  • Protected keywords — per part, the job phrases its lines already match exactly, listed as phrases the rewrite must keep verbatim somewhere in that part. The mirror image of the missing-keyword list, and enforced after the call by the keyword preservation pass.
  • Job-specific gaps and red flags — the evaluation’s gaps (up to 5) and redFlags (up to 3) are included so the AI tailors content to address the specific shortfalls identified for this position, not just generic keyword coverage.
  • Evaluation strengths (top 4) as suggested emphasis signals.
  • Gap responses — user-supplied notes addressing identified experience gaps. Gaps and red flags are addressed via a structured modal (see below).
  • Profile supplements — any extra context the user has added.
  • Gap answer quality checks — vague gap responses and supplements are saved as drafts with a follow-up question, and only confirmed answers are used during resume tailoring.

Gap addressing modal (on the job Overview tab, “Gaps and red flags” card):

  • Clicking Address (or Edit) opens a modal instead of an inline form.
  • Company checkboxes — loaded from the user’s resume experience entries via /api/resume-companies; selecting companies pre-structures the response as “At Company A, Company B: [description]”.
  • What did you do? — editable textarea, prefilled by parsing the gap text into a first-person statement (strips “The posting requires…” boilerplate, extracts the core activity). When the gap matches no known preamble it is the evaluator’s own complaint rather than a claim the candidate can make (“The available resume evidence does not explicitly document…”), and the box is left empty. Prefilling those verbatim wrote the complaint into the answer, saved it as the candidate’s evidence, and left the assessor reading the gap back to itself.
  • Key metrics or outcomes — optional single-line field; appended as “Key results: …” in the saved response.
  • Polish with AI — sends the structured response for AI polishing and quality assessment in one step; closes the modal when qualityStatus === "addressed".
  • Save — saves raw without polish; also closes on “addressed”.
  • If the AI returns needs_followup, the modal transitions to a follow-up step showing the AI question and the saved response, with a textarea for more detail.
  • Escape key closes the modal.
  • Modal slides up from the bottom on mobile, centers on desktop.

Answers are global, not per-job. Every saved gap answer — including ones the assessor marks needs_followup — is promoted to the Evidence bank keyed on the gap text, so unfinished work is never stranded on the requisition that surfaced it. Consequently:

  • A gap this job raised that was already answered elsewhere is auto-filled, badged ↻ From your evidence bank. Answering it again is never required.
  • A job-specific answer overrides the bank for that job only. The one exception is an unfinished job-level draft (needs_followup): a completed bank answer replaces it, otherwise finishing a gap in the Evidence bank would leave the job page showing the stale draft it was meant to replace.
  • Clear removes both the job-level answer and the bank record, since the bank is the single global copy — leaving it would let the answer auto-fill straight back in.
  • Job description excerpt (up to 10,000 chars) — allows the AI to verify keyword context and understand requirement weight, not just the extracted keyword list.
  • Skills preference flags — skills the user wants to emphasize or de-emphasize (derived from use_more / use_less preference on each skill record).

Keyword placement strategy (added to tailoring prompt):

  • Evidence-supported, high-priority language is considered first. Exact wording is preserved when natural and accurate because recruiters can use literal or Boolean searches, but no phrase is forced merely to raise a percentage.
  • Tool/methodology keywords belong in Skills or within the experience bullet where that tool was actually used.
  • Unsupported requirements remain gaps and are never inserted into Skills.
  • The target title is never copied into held job titles. It appears in positioning copy only when the source resume supports that professional identity.
  • Repetition and keyword dumping are explicitly prohibited.

Three-tier keyword coverage (resume draft editor): The keyword panel in the draft editor classifies each keyword into one of three tiers:

TierDisplayMeaning
Exact phrase✓ green chipFull verbatim phrase is present in the resume
Related wording~ amber chipSignificant terms are present, but not as the same phrase
Missing+ or ! chipNot found; either add to Skills (if evidence confirmed) or confirm evidence first
  • The header shows job keyword alignment, not an employer ATS score. Critical, required, and preferred signals receive weights of 5, 3, and 1; related wording receives half credit.
  • The UI shows Must/Core/Pref labels and explains that different ATS products and employer configurations search, parse, and match resumes differently.
  • Clicking any chip highlights the keyword in the preview panel (exact phrase = bright yellow outline; term occurrences = faint yellow).
  • supportedKeywords detection uses the same phrase-aware matching algorithm as coverage (previously used raw string.includes() which gave false positives).

Job-keyword alignment metric:

  • For each evaluation keyword, first tries an exact phrase match in the resume text; if that fails, splits the keyword into significant words (stripping stop words and single-character tokens), then checks whether the meaningful terms appear within a 30-word context window instead of anywhere in the document. This related-term fallback handles multi-word phrases like “agile methodologies” or “Healthcare SaaS” that the LLM evaluator commonly produces. Specific acronyms (HIPAA, HL7, FHIR) still require an exact match and correctly flag as gaps when missing.
  • Displayed as a percentage on the edit-draft page subtitle and is recomputed live from the current evaluation keywords each time the page loads (not cached from generation time).
  • Color-coded bands remain green ≥ 70%, orange 40–69%, and red < 40% for quick comparison, without presenting 70% as a universal ATS cutoff.

Tabs. Overview | Evaluation | Resume | Apply | Outreach. Analysis was renamed to Evaluation; old ?tab=analysis links still resolve there, so bookmarks and notes keep working. Outreach currently opens the existing generic-draft page and becomes a contact workspace in a later phase.

Evaluation run line. To the right of the tabs, a single muted line records the run behind everything on screen: Evaluated gemini / gemini-3.5-flash · 41.2s · first assessed Aug 19, 2:18 PM — the provider and model that ran it, how long it took, and when the job was first assessed. A row saved by the old rule-based fallback reads Evaluated scored by local rules, no AI model · 152.9s · first assessed Aug 19, 2:18 PM, because local-fallback / local-fallback is a stored value, not a sentence, and the one thing such a row has to say is that no model produced it. It is read from the stored evaluation (created_at, provider_used, model_used, generation_ms), so it describes the evaluation you are looking at rather than the last run in the session.

The two halves describe different runs, which is why each is labelled. created_at is deliberately preserved across re-evaluations so a job keeps the date it was first assessed, while provider, model and duration are replaced every time. The line originally read Evaluated <date> · <model> · <duration>, which put a stale date beside fresh provenance and claimed today’s model ran on the day of the first assessment. Either half is dropped, separator and all, when it is unknown. When an auto option resolved a sentinel, the concrete id it resolved to is what gets recorded and shown — latest-sonnet names a policy, not a model, and saying it here would answer the wrong question. Nothing is shown for a job that has not been evaluated. It sits beside the tabs rather than inside the Evaluation tab because run cost is worth seeing from any tab — a three-minute run is only noticeable if it is always in view. The full ISO timestamp is available as the line’s tooltip.

Next best action. Each job shows one primary action derived from its records — no new status column, so it cannot disagree with what actually exists:

StatePrimary action
Not evaluatedEvaluate
Evaluated, Skip or BlockedReview evaluation (no nudge to proceed)
Evaluated, no resumeGenerate resume
Resume ready, not appliedApply
Applied, nobody contactedFind people
Interviewing or OfferPrepare interview

Outreach is promoted to the primary action only after you have applied with nobody contacted. Outreach may happen before or after applying, but it never displaces the step you are actually on. A Blocked or Skip role gets no encouragement to proceed — the tabs remain available, but the app stops suggesting.

The primary action is not rendered as a button or a sentence on the job page. Every action it can name is already one click away in the header (Evaluate with AI, Check live, Job posting) or is a tab, so a CTA there was the same click twice. It is used only to decide which breadcrumb step is marked as next.

Opportunity progress. A single breadcrumb line above the tabs, showing the five moves a job goes through in the order they happen:

✓ Evaluate › Resume › Apply › Outreach › Interview prep

Every step is a link to where that step happens — Evaluate → Evaluation tab, Resume → Resume tab, Apply → Apply tab, Outreach → Outreach tab, Interview prep → /interview-prep — so the breadcrumb is the navigation, not a decorative status strip. State is shown three ways, never by colour alone: a completed step is green and carries a , the next step is bold in the accent colour and marked aria-current="step", and later steps stay muted. Which step counts as “next” comes from the next-best-action rules above, matched on a shared step id rather than on label text, so rewording either one cannot desynchronise them. A Skip or Blocked role therefore highlights nothing, matching the rule that the app stops suggesting.

Application preparation is deliberately not a step. It happens inside resume generation, so listing it named an internal stage the user never separately performs — it is folded into Resume.

Application Preparation. Generating a resume first prepares the application — one structured AI call producing:

  • Detailed requirements from the posting, each marked supported, partial, or unknown against your evidence. Silence in your resume is unknown, never a mismatch.

  • ATS keyword signals — 12–18 high-signal phrases, validated against the posting so an invented title variant or a phrase that never appears cannot survive. Tenure requirements (“6+ years of experience”) and work arrangements (“Remote”, “Hybrid”) are rejected too: a model extracting verbatim returned them as a critical credential and a domain keyword, the rewrite was told to work them into the summary, and coverage counted them as misses. The same filter runs when stored signals are read, so preparations saved earlier are cleaned without a re-run.

  • An evidence map — which of your evidence supports each requirement and where it belongs on the resume. A mapping citing evidence that does not exist is discarded rather than passed through, because it would otherwise become a false claim on a document you send to an employer.

  • Compensation context — the posted range when the posting states one; otherwise at most one live search (Brave, or your provider’s web search). When neither is available it says so and falls back to your saved target rather than inventing a range. The search starts alongside the model call rather than after it — the two share no inputs, and the resume used to wait on a salary search it never reads. Preparation waits for it at most COMPENSATION_WAIT_MS (8s) after the model answers: the search has no timeout of its own, so awaiting it still let a slow or hung search hold the resume up. Past the wait the preparation is saved with compensation_research_status = not_run, and the lookup finishes in the background and fills in the saved row — only if that row still has the same JD and evidence hashes and is still not_run, so it never overwrites a newer preparation. The late result is written by updateApplicationPreparationCompensation, which updates the compensation columns only: re-saving the older run’s whole record put its requirements, keywords and evidence map back over a newer preparation for the same job. While research is pending the saved salary answer says research had not finished rather than that it was unavailable, and application answers drafted in that window use the saved target. If the model call fails, the one search has already been made.

    Claude’s server-side search tool comes in two variants, and the wrong one is rejected, so AnthropicProvider.webSearch picks it from the model that actually resolved: web_search_20260209 (dynamic filtering) for Opus and Sonnet 4.6 and later, and the basic web_search_20250305 for everything else — including Haiku 4.5, which outranks Sonnet 4.5 numerically but is not in the supported set, so the rule is per family rather than one global cutoff. An id the app cannot parse gets the basic tool, which every model accepts. Selecting it per model matters now that a Latest option can change which model runs without anyone editing the setting (webSearchToolType in src/lib/ai/anthropic-models.ts). Gemini uses its own googleSearch tool, which has no such split.

Output budget. Every provider defaults to 4096 output tokens, which is smaller than these shapes need — a preparation answer alone carries 12–18 keyword signals with rationale, a requirements list and an evidence map. Over the limit the answer stops mid-object and arrives as Unexpected end of JSON input, which is indistinguishable from the outside from a model that cannot follow a schema. Both Fast Evaluation and Application Preparation now ask for STRUCTURED_OUTPUT_MAX_TOKENS (8192) explicitly at the call site, so the budget holds whichever provider serves the request. Before this, preparation failed on every attempt, which meant no job ever had ATS keywords: the resume builder’s keyword panel was empty and its header read 0% job keyword alignment on every draft.

Run budget covers the whole chain, not the first provider. Preparation is bounded the same way evaluation is: each provider gets its own deadline (150s cloud, 10 minutes local) and the run’s outer bound covers their sum. It previously used a flat 150s bound regardless of the chain, which cut the run off at the first provider — a local model placed first burned the entire budget and the cloud provider configured behind it never got its turn. The run failed as Application preparation Generation exceeded 150s and was abandoned instead of falling over, and because preparation is the only source of ATS keyword signals, the resume that followed was tailored against nothing: 0% keyword coverage, Top keywords inserted only where supported: none captured, and the base lane merely reordered. The bound now comes from totalGenerationDeadlineMs over the chain’s provider names (runDeadlineMs in src/lib/application-preparation/index.ts).

A provider’s budget covers its retries. Summing per-provider budgets only bounds the run if each provider actually stays inside its own. It did not: a local provider gets a second try when the first returned unusable JSON, and every try was handed a fresh full budget — so Ollama could spend 2 × 600s against a run bounded at 600 + 150 + 10. The run’s deadline expired mid-retry and the cloud provider behind it never ran, which is the failure the sum was introduced to prevent, reappearing one level down. A retry now gets what is left of the provider’s budget. That keeps the retry worth having where it pays — a mangled answer that came back quickly leaves room for another go — while a first attempt that consumed the whole budget has spent that provider’s turn, and the chain behind it is the better use of the remaining time.

A run that ends stops the chain behind it. withDeadline rejects its own promise, but the chain it wrapped kept running detached: after preparation reported failure, the chain walked on and called the paid provider anyway, spending a cloud call for a user who had already been told the run failed. Preparation now bounds the run with withChainDeadline (src/lib/ai/retry.ts), which tells the chain to stop as soon as the run is over, however it ended. A request already in flight cannot be recalled; what stops is the next provider starting, which is the part that costs money.

Evaluation is bounded the same way. It had guarded this path for user cancellation only, so a lapsed deadline still let the chain walk on to a paid provider. withChainDeadline now takes the caller’s own cancellation signal too and forwards both reasons into one signal for the chain, so cancelling and timing out both stop it. The caller’s signal is still what bounds the wait, so the two stay distinguishable where it matters: cancelling raises GenerationCancelledError, which evaluation treats as “the user stopped waiting” rather than a failure, and a lapsed deadline still raises GenerationTimeoutError.

Telling the chain is necessary but not sufficient, for two reasons. On the commonest setup there is no chain: with exactly one provider configured, buildProvider returns the raw adapter, which has no abortOn at all. And withRetry sits outside the chain either way — a request that outlived the deadline and then failed with a retryable error would wake the retry loop and start another one, after the run had already been reported as failed. So the combined signal is handed to the wrapped function as well, and both preparation and evaluation pass it into their retry loop. Work already in flight still cannot be recalled; what stops is anything further being started.

Preparation failure is visible in the draft. Resume generation degrades rather than aborting when preparation fails — a resume without keyword targeting beats no resume — and the reason is recorded on the document. It used to be shown only when tailoring also degraded to source-only, so a draft that tailored fine but had no keywords said nothing at all. The draft editor now names the state in both places: the header reads job keywords not generated rather than 0% job keyword alignment (0% reports a measurement that never ran, and reads as “this resume matches nothing”), and a banner carries the recorded reason with what to do about it.

Reuse and staleness. A preparation is reused while both its job-description hash and its evidence hash still match, so editing a draft does not pay for it again. The evidence hash spans your whole evidence bank — answering a gap on /evidence for one role marks every affected preparation stale, including jobs you answered it from somewhere else.

Evaluation is required, and never silent. Resume and Apply used to run an evaluation themselves when one was missing — an expensive AI call with no user action behind it. They now stop and ask you to evaluate first.

Apply tab

  • Prepare application answers: paste common or custom application questions, generate AI answers for copy-paste. App never auto-submits anything.
    • Pressing Prepare answers opens a blocking ProgressModal (“Drafting answers grounded in your resume and evaluation…” + spinner) while the request is in flight. On success the modal transitions to a done state (“Answers prepared — scroll down to review them.”) with a Close button. On error it transitions to an error state with the failure message and a Close button (questions are preserved).
    • After answers return, the question inputs reset to a single empty row and the drafts list refreshes in place — no page reload is required to submit another batch of questions.
    • Compensation. AI answers (prepareApplicationAnswersWithAI) receive the saved target, the posting’s salary notes, and — once Application Preparation’s live market research has finished (compensation_research_status = completed) — up to 600 characters of its summary, with a rule to lead with the target and state no figure absent from the target, the posting, or the research. They previously never read the preparation, so research that landed after a resume could not reach an AI answer however often it was drafted again. The non-AI answers use the preparation’s suggested_compensation_response. Both paths use the preparation only while its jd_hash still matches the job (computeJdHash): editing a job’s title, location or salary notes leaves the row in place until the next resume, and its research and answer describe the old role.
    • Gap responses flow into answer generation: all gap and red flag responses with qualityStatus === "addressed" are loaded and injected into the AI system prompt as verified evidence. The AI uses polished responses (or raw if no polish) to strengthen “why fit” and custom answers. The template fallback (no AI key) surfaces the most relevant addressed gap in the “why fit” answer and lists all gaps as supporting evidence for custom questions.
  • Application status selector: move the job through the 11-status funnel.
  • Follow-up date picker.
  • Contact field.

Sub-pages from Job Detail:

Research /jobs/[id]/research

AI-generated company intelligence:

  • AI strategy and product direction
  • Recent company movements (hiring, layoffs, expansions)
  • Engineering / design culture
  • Technical and organizational challenges
  • Candidate positioning angle

Clicking Start research (or Re-research) opens a blocking progress modal (“Researching company” + spinner) that streams a live status line as each of the six axes completes. On completion the modal shows “Research complete — scroll down to read all six sections.” The modal cannot be dismissed while the request is in flight; the page-level Cancel button stops the stream and closes the modal, and an X button and Close button appear only in the done state.

Outreach tab

Generate a recruiter or hiring manager outreach message tailored to the job and user profile. Shows character count. User copies the message manually.

Clicking Generate messages (or Regenerate) opens a blocking progress modal (“Generating outreach messages” + spinner). On success the modal shows “3 messages ready — scroll down to copy and send them.” The modal cannot be dismissed while the request is in flight.


Outreach /jobs/[id]/outreach

Part of the job workspace, not a separate screen — the job header, status control and progress breadcrumb stay visible while you work. The old /jobs/[id]/outreach URL redirects to ?tab=outreach, so existing links keep working.

Real people rather than abstract personas. Contacts are global, so the same person can be linked to several opportunities, while their role, relevance and outreach status stay per job — someone marked Contacted for one role remains Found for another.

Finding people with Clay. When Clay is connected, Find relevant people builds a five-person outreach shortlist at the hiring company. It runs only when you click it — never on discovery, evaluation or page load — because each result spends your Clay allowance.

The People card shows the search requirements before that click: Clay API connection, hiring company, company website or LinkedIn company page, and role focus. Missing required details are labelled in place, and Find relevant people stays disabled until the search is ready. The company identifier and role focus can be fixed in the card; the confirmed identifier is saved to the company profile for later searches.

The card previews three searches and their exact title phrases: two likely hiring leaders, two leaders close to the function or team, and one recruiter targeted to the role. A Reports to title in the job description is used first when available. Job Search Terminal uses the job description locally to build that plan; Clay receives only the company identifier and the displayed title phrases. Your job description, resume, private notes, Story Bank and gap answers never leave Job Search Terminal.

The three searches request no more than five results in total: 2 + 2 + 1. Results are deduplicated, ranked by Job Search Terminal’s own rules, and saved as ordinary contacts you can edit, delete or forget. Search-lane fit is included in the visible relevance reasons. Anyone you previously chose to forget is filtered out before being saved, so a later search cannot resurrect them.

The company must be identified first. A saved company domain is used if there is one; otherwise it is derived from the job URL — but only when that URL is the employer’s own site. Links to Greenhouse, Lever, Ashby, Workday, LinkedIn, Indeed and similar are refused, because deriving a domain from them would search a real company that is not the employer. When nothing reliable is available, the card asks for the employer’s website, domain, or LinkedIn company page before enabling search rather than returning confident results from the wrong organisation. Job-board links are rejected if pasted into that field.

When Clay has a problem, each case says something different and useful: key rejected, allowance used up, rate limited, company ambiguous, or unreachable. A Clay failure never affects evaluation, resumes or applications.

Finding a work email. Search never returns emails, and enrichment is a separate, per-contact action — Find email appears on a contact once you have decided they matter. It is never applied across a search result set, so five results cannot quietly become five enrichment charges.

Automatic enrichment, when it is switched on, submits only people the search actually created and whose email is still missing. Before that it batched everyone the search returned who had a LinkedIn URL, so re-running the same search bought the same addresses again. “Has no email” is not the same question as “has not been tried”: someone whose earlier lookup came back empty would be paid for on every later search that found them, so the test is whether the contact is new. Retrying them stays available as the explicit per-contact Find email, where you are choosing to spend the credit.

Clay API, not the ChatGPT Clay MCP connection. Job Search Terminal uses the scoped Clay API key saved under Settings → Integrations. It does not share ChatGPT’s Clay MCP session. The app reproduces the useful part of that workflow with three targeted searches while keeping the job description and private career material local.

Step-by-step setup: see docs/clay-enrichment-routine.md, including npm run clay:routine to validate a routine id before saving it.

This needs setup, and the reason is Clay’s. Clay has no direct “find this person’s email” endpoint. The only path is executing a routine you build in your own Clay workspace. Job Search Terminal does not create Clay routines or tables, so you build one that takes a LinkedIn URL and returns a work email, then paste its routine id in Settings → Integrations. Leave it blank to skip enrichment entirely — everything else works without it, and the button explains what is missing rather than failing.

Automatic lookup. Once a routine is configured, you can turn on Look up emails automatically for search results in Settings → Integrations. Every person a search returns is then enriched in a single routine run, rather than you clicking each one.

It is off by default because it costs real credits: batching saves round trips and latency, but Clay charges per person enriched either way. Clay’s managed enrichment function is 12.8 credits per person, so a five-result search costs 64 credits to enrich automatically versus 12.8 for the one person you actually contact.

Whether that matters depends entirely on your plan — check Usage → Workspace balances in Clay. A trial allowance comfortably absorbs it; a smaller one will not.

If automatic lookup fails, the search still succeeds: the people are already saved and usable without an email.

Enriched addresses are labelled unverified, because that is what they are — the routine found an address, nothing confirmed it deliverable.

Adding a contact. Name, title, company (blank uses the hiring company), relationship, LinkedIn URL, work email and notes. Every contact is scored by the same deterministic rules a provider result will be, so a manually added person is not a second-class record.

Relevance is explained, not just scored. The score comes from function overlap with the role, hiring authority, seniority, whether they work at the hiring company, and whether you can actually reach them — each contributing a stated reason. It is shown as a band — Recommended, Optional, Low value — rather than another number to interpret. No AI call is involved.

Statuses: Found, Shortlisted, Drafted, Contacted, Responded, Not Relevant.

Drafting a message. Each contact has its own draft area. Pick a channel — LinkedIn connection note, LinkedIn message, or email — and the message is written to that person about this role, using the job, its evaluation, your Application Preparation when it exists, the contact’s relationship to the role, and your saved writing style. Every draft starts with the organization’s or team’s need and explains how you can help. Your background appears only as evidence for that contribution, not as the subject of the message or as a compressed biography.

The button changes to Generating message… as soon as the request starts. An inline status names the person and channel, explains that the AI is still working, and remains visible until the draft is saved or an error needs attention. The contact is not reported as newly drafted by this control while generation is still running.

The active AI provider receives the role-specific part of the job description, relevant evaluation and Application Preparation evidence, the contact’s professional details, and the saved writing style needed to write the draft. It does not receive unrelated jobs or contacts. If the first result centers the candidate, Job Search Terminal asks for one automatic rewrite. A second failure is shown clearly and no draft is saved. When the older three-message generator is used, it validates all three replacements before changing the saved set, so a failed regeneration keeps the previous drafts intact.

Length follows the channel, not one universal cap. A connection note aims for ~280 characters and warns past 300; an email aims for ~1,200 and has a subject line. The count is always shown, and nothing is ever silently truncated — a message cut mid-sentence is worse than a long one.

What the drafts will not do. No generic praise, no fake familiarity, no claims about the company that are not in the context, no assertion that this person owns the role unless that is known, and no invented experience, metrics or mutual connections. Organization-first framing is a locked rule even when the editable outreach prompts are customized. Drafts are editable in place before you use them.

There is no Send button. Job Search Terminal drafts, tracks, and stops there. Copy the message into LinkedIn or your email client and send it yourself.

Three ways to remove someone, meaning three different things:

  • Remove from this job — drops them from this opportunity; they stay in your contacts.
  • Delete contact — deletes them and their outreach history everywhere. A later search may legitimately find them again.
  • Forget this person — deletes them and remembers a one-way fingerprint so a later search recognises and discards them. JST keeps no readable trace: not the name, not the email, not the profile URL. Adding them again is refused with an explanation until you clear the forgotten list in Settings → Integrations, which restores nothing.

Contact details are stored locally and included in account backups — the unencrypted-backup warning names them explicitly.

Applications /applications

Application funnel tracker with two view modes:

  • Table view — sortable list of all active applications with status, company, role, applied date, score, follow-up date, and overdue indicator.
  • Kanban view — drag-and-drop board organized by status column.

Features:

  • Follow-up overdue alerts (highlighted when past the follow-up date).
  • Status transition buttons inline in each row.
  • Filter by status, overdue, or company.
  • Column filters and saved filter presets (same system as the Jobs table).
  • Summary funnel metrics at the top: applied, in progress, responded, interviewing.

Archived /archived

Jobs that have been manually archived or skipped.

  • Table of archived jobs with original score and archival date.
  • Column filters and saved filter presets on the archived jobs table.
  • Restore action: move a job back to active.
  • Delete action: permanently remove the job and all associated records.

Auto-archive on skip: marking a job as Skipped automatically moves it to the archive. The job leaves the active pipeline immediately — it will no longer appear on the Jobs page or the Dashboard action queue. It remains visible on the Archived page and can be restored at any time.


Resumes /resumes

Resume studio showing all resume lanes and generated documents.

Two sections:

Base resumes — the source PDF lanes uploaded by the user, or resumes built from scratch inside the app. Each lane represents a different career angle (e.g., “Leadership”, “IC / Individual Contributor”, “Domain Specialist”). The app ships with five default lane names that can be renamed; new lanes can be added at any time.

Each lane shows extraction status, word count, and resume-builder approval state. Existing uploaded resumes are backfilled from stored extracted text, so current users do not need to upload them again.

The /resumes page uses the same dashboard table pattern as the Jobs page on desktop, with compact cards on smaller screens. The table shows lane name, builder status badge, source word count, extraction date, and an action button. Each lane has a direct Review and approve or Edit approved version action that opens the builder.

Create new resume button appears in the page header on /resumes and in the Resumes tab of /profile. Clicking it creates a new blank lane with starter sections (Contact, Summary, Experience, Skills, Education) pre-populated and immediately opens the Resume Builder.

Resume Builder /profile/resumes/[id]/builder

Structured source editor for each resume lane — works for both PDF-extracted and from-scratch resumes:

  • Parses the uploaded resume into editable sections, or starts from blank starter sections when the lane was created from scratch.
  • Preserves custom sections such as Recognition when detected.
  • Supports editing, adding, removing, renaming, and reordering sections.
  • Add section menu — dropdown picker with section types: Summary, Key Achievements, Experience, Skills, Awards & Recognition, Education, Custom.
  • Add role / Add entry buttons inside Experience and Education sections to append additional entries without leaving the section.
  • ✨ Improve with AI — available on Summary, Key Achievements, Skills, Awards & Recognition, Experience bullets, and Custom sections. Sends the section content to the resume writer chain, which returns an improved version. There is no posting in the builder, so this uses the plain /api/resume-sections/improve route rather than the grounded draft-editor one; its prompts no longer ask for “measurable” bullets or “compelling, keyword-rich” summaries, and tell the model to keep every existing number and add none. The suggestion is shown inline with Accept and Discard buttons; the original is preserved until the user accepts. The request allows up to 4096 output tokens, so local reasoning models have room to think before writing the rewrite. If a provider returns an empty rewrite anyway, the editor shows an explanatory error (“The AI provider returned an empty rewrite…”) instead of a generic failure, and a fallback chain moves on to the next configured provider.
  • Helpful placeholder text in every input guides users building from scratch.
  • Uses the same split editor/preview layout as the generated resume editor, so source edits can be checked against the rendered resume while reviewing.
  • Saves a draft or approves the lane version used by job-specific generation.
  • Remove button in the builder header — deletes the resume lane from the system after inline confirmation (“Delete this resume? / Yes, delete / Cancel”). Available for all resumes, not just new ones.
  • Back button — navigates to /resumes. For newly created (unsaved) resumes, clicking Back shows a leave confirmation dialog with four choices:
    • Save draft and leave — saves the current state as a draft, then navigates away.
    • Delete and leave — permanently deletes the resume lane.
    • Leave without saving — navigates away without saving (lane is kept but blank).
    • Keep editing — dismisses the dialog and stays on the page. The browser’s native beforeunload prompt also fires if the user tries to close the tab or navigate directly while a new resume has not yet been saved.

The HTML resume template renders experience entries with the organization and location left-aligned and the date range right-aligned on the same line, matching standard resume layout conventions.

Generated documents — tailored resumes produced for specific jobs:

  • Table showing job, lane used, keyword coverage %, generation date, and status.
  • Generated documents keep a stable link to their source resume lane, so PDF export continues to work after the lane is renamed.
  • Column filters and saved filter presets on the generated documents table.
  • Links to preview HTML and download PDF.
  • Delete document action.

Resume Editor /generated-documents/[id]/edit

Full draft editor for a tailored resume before exporting to PDF. Matches the approved-resume builder experience with identical section controls on every section:

  • Section title — editable input that updates the heading printed in the PDF.

  • ✨ Improve and ↻ Regenerate — on Summary, Key Achievements, Skills, and custom sections, and on each Experience entry (✨ Improve bullets / ↻ Regenerate). Both call POST /api/generated-documents/[id]/sections with { action, unit, note?, draft }, which writes the one part through the same unit writer, evidence guard, keyword preservation, and writing checks as generation (rewriteSection in src/lib/documents/section-rewrite.ts). Improve starts from the text in the box, including the user’s edits, and keeps its substance; the user’s own text counts as evidence for that request, so only claims the model adds beyond it and the evidence bank are reverted. Regenerate starts from the approved lane — a role is matched by title and employer before position — and reverts to that. The suggestion panel shows the lines, which were kept at earlier wording and the claim that caused it, job phrases kept, remaining writing problems, any credits notice, and which model wrote it in how long. Nothing changes until Accept; Cancel aborts the request. Show what each line was written from pairs each new line with its starting line (the route returns sources in output order). After accepting, Undo accept restores the replaced text once. A screen-reader announcement (“Suggestion ready…”) fires when a request finishes, since the result appears away from the button that was pressed. Add instruction reveals a short field (“shorter”, “lead with the accessibility work”) sent with either action; the writer follows it within the truth rules. Awards & Recognition keeps the plain ✨ Improve, since it is not a part the writer tailors. Improve used to be a separate prompt that saw neither the posting nor the evidence, asked for “measurable” bullets and “compelling, keyword-rich” summaries, and bypassed the claim guard entirely.

  • ATS & recruiter checks — a collapsible list above the keyword panel, recomputed as the user types (checkResume). It opens when anything is flagged and reads All clear otherwise.

  • ↑ Move up / ↓ Move down — reorders sections; order is reflected in the preview and the generated PDF.

  • Remove — hides a section from the PDF (data is not deleted).

  • A notice names any part the writer could not produce at generation, kept at approved wording, and suggests ↻ Regenerate on it.

  • Header (name, headline, contact) is always pinned at the top and is not moveable or removable.

  • Education is always shown last and is display-only (pulled from the base resume).

  • Keyword coverage panel — collapsible panel between the help text and the first section showing all job keywords as chips. Green ✓ chips = exact phrase or strong term-overlap coverage in the current resume text; + chips = supported by existing evidence and ready to add; ! chips = missing evidence. Clicking a + chip adds the keyword to Skills. Clicking a ! chip opens a guided wizard: select the companies and roles where the skill was used, optionally add context, then review distinct resume-writer suggestions grounded in each role’s existing bullets. Each suggestion rewrites the strongest relevant bullet instead of appending a generic line. The user can edit or remove any change before approval. Company confirmation is required; writing is optional. When AI is unavailable or returns an unsafe suggestion, the app uses a claim-preserving fallback rewrite. Updates instantly as the user types (no debounce — pure JS computation). Starts expanded when coverage is below 70%. Collapses to just the covered/total counter when the user has seen enough. The page header uses the same live matcher.

  • Speed — section requests run on the resume writer chain with reasoning: "low": a local thinking model took 1m43s on a 225-token summary rewrite, nearly all of it reasoning.

  • Provenance line — the header reads Generated in 2m 22s with local model (gemma4:12b-mlx) (or the cloud provider’s name) from generation_ms, provider_used, and model_used. Hidden for drafts made before those were recorded.

  • Evidence guard — AI-proposed headline, summary, impact, skill, recognition, experience, and extra-section claims are checked against every active resume lane plus confirmed gap answers and supplements. Unsupported AI changes revert to source wording. If manual edits introduce unsupported quantified claims, PDF export opens a review dialog listing every claim, its location, and the affected line. The user can return to the editor to fix the claims or explicitly choose Export anyway to preserve the draft as written. The saved document audit records the flagged claims, the explicit export override, and every section that was reverted.

    What counts as an unsupported claim:

    • Quantified claims. In the summary and headline the figure must appear somewhere in the evidence corpus — those sections condense the whole resume, so a number there belongs to no single line. An open-ended N+ figure is also accepted when the evidence states an equal or larger M+, because claiming less than the evidence supports is not a fabrication. Everywhere else the figure must appear in a related evidence line, which is what stops a metric being moved between roles.
    • Named entities. Tools, standards, employers, and products — detected by capitalization rather than by a list, so “React”, “Svelte”, “Kubernetes”, and “HIPAA” are caught without anyone enumerating every tool that exists. A word counts when it carries an internal capital, is an acronym, or is capitalized anywhere other than the opening of a sentence or bullet.
    • Seniority, credential, and recognition words (“director”, “staff”, “certified”, “award”, “professor”). Inventing one misstates the candidate’s level or qualifications rather than their phrasing.

    Everything else is treated as rhetoric and never triggers a revert on its own. The guard originally worked the other way — every word absent from the evidence was a claim unless a list said otherwise — and that list leaked three times in practice (“strong”/“brings”/“vision”, then “consulting”/“expertise”, then “stakes”/“cycle”), each leak throwing away a good summary over a word that asserts nothing. English holds more rhetoric than any list can. Guarding what a claim is rather than what it is not took the false reverts on the stored corpus from 88 of 93 summaries to 14, with every remaining catch a checkable fact: an invented tech stack, a compliance standard the resume never mentions, a seniority word the evidence does not support. Terms are matched by stem, so “wireframes” is supported by “wireframe”.

    Unconfirmed posting requirements are deliberately not part of this. Absent from the resume is not the same as fabricated — guarding them reverted summaries over “user needs” and “business outcomes” — and the keyword alignment panel already lists them under Needs confirmed evidence before use for the user to judge.

  • Keyword preservation — tailoring may add job language but never trade away language the resume already matched. Before the AI call, the phrases the source draft already matches exactly are sent as protected terms the rewrite must keep verbatim. After the call, every keyword is re-measured on the three-tier scale (exact / related / missing). Only phrases that were exact in the source are defended — a related-wording match is fuzzy overlap across the whole document, so no single line owns it and reverting one would cost real tailoring for no gain an ATS can see. A defended phrase that is no longer exact is repaired by restoring the one source line that carried it, leaving the rest of the rewrite intact. Restoring a line often repairs several phrases at once, so the loss is recomputed after each restore; a phrase no single line can repair is skipped rather than abandoning the repairs that are still possible. The tailoring plan on the preview page names the lines that were kept and the phrases they saved. Without this a rewrite could turn “service design” into “service maps” and come back with fewer matches than the untouched resume while still reporting healthy coverage.

  • No-op tailoring detection — a provider outage is already reported, but a model that runs and declines to rewrite was not: the draft stored a supported audit over source content and read as tailored. Every selected section is now compared against the source draft on the AI’s own output, before the evidence guard and the preservation pass run — both of those restore source wording deliberately and would otherwise be counted as the model doing nothing. A section where at least half the lines came back verbatim is reported; a run where every selected section came back untouched is recorded as source-only, the same status a provider failure produces, with the count as its reason. The counts are stored in evidence_audit_json.unchanged.

  • Untailored-section notice — a revert leaves the section reading as approved source wording while the document still reports a supported audit. When that happens the draft editor says so above the sections: which sections lost their tailoring, and the exact terms that were not supported, so the user can add the evidence and regenerate or edit the section by hand. This matters most for the summary, which carries the target title and domain language an ATS reads first — a silently reverted summary drops keyword coverage with no visible cause. The same block also carries the no-op notice above, so the two ways a draft can read as tailored without being tailored — the guard threw the rewrite away, or the model never wrote one — are stated side by side.

  • Live preview pane updates automatically with a 400 ms debounce; Refresh button forces an immediate update.

  • Keyword coverage percentage shown in the page header (color-coded green/yellow/red).

Resume Preview /generated-documents/[id]/preview

Read-only HTML preview of the tailored resume.


Interview transition

When a job’s application status reaches Interviewing or Offer, the job workspace surfaces Interview preparation available with a direct link, and the progress breadcrumb ticks Interview prep. Next best action promotes it above everything else — an advancing opportunity is time-bound in a way earlier stages are not.

Story matching for a job uses the same effective-keyword resolver as resume tailoring, so newly evaluated jobs match on Application Preparation keywords and older ones fall back to their stored evaluation keywords.

Where stories come from. Evaluation used to propose STAR stories as part of its seven-block output; it no longer does. Stories are written in Interview Prep and labelled Interview prep. Older stories keep their original labels — AI evaluation for ones proposed by the retired evaluator, Voice practice for ones captured from practice — and remain filterable under Job suggestions.

Interview Prep /interview-prep

Tools to prepare for interviews using stored experience.

Tabbed Workspace:

  • Header chips are core-story-centric: N core stories · N questions, plus a N to consolidate link (when generated suggestions remain) and a N candidates to review link (when the taxonomy has candidates).
  • Practice: opens with a Coverage panel — a per-category readout of how many questions have at least one linked story or recorded attempt. Categories with no story yet are highlighted as gaps, so it’s obvious where prep is missing. Below it are reusable questions, answer recording, and standalone story capture.
  • Story Bank: saved stories, search, filters, and inline editing live in a separate story-bank workspace. Job evaluations no longer auto-fill this bank (see “Generate but ask first” below); older auto-generated suggestions remain until the one-time consolidation wizard folds them into core stories.
  • Taxonomy: the private local tag tree built from the user’s own jobs, resumes, and stories can be reviewed and managed without changing raw ATS keywords.

Story consolidation wizard (one-time cleanup):

  • When the story bank still contains auto-generated evaluation_suggestion rows from the old Block F behavior, a banner in the Story Bank tab links to /interview-prep/consolidate.
  • The wizard (ConsolidationWizard) uses the active AI provider to cluster the near-duplicate suggestions — which are lightly-reworded copies of the same ~15–25 real experiences — into canonical core stories, then synthesizes one clean STAR+Reflection story per cluster with capability tags. Nothing is written until you review and commit: each proposed story shows its editable title/STAR/tags, the list of job-specific suggestions it was merged from, and an approve checkbox.
  • Committing creates the approved stories as reusable standalone stories, re-homes the merged suggestions’ job links onto them, and removes the suggestion rows. The run is resumable (persisted in story_consolidation_runs) and the banner disappears once the bank is clean. This is a feature, not a one-off script — it appears for any user whose bank holds generated suggestions and never on a fresh install.

Generate but ask first (Block F no longer auto-inserts):

  • Running Evaluate with AI on a job still generates likely interview questions with STAR outlines, shown in the job’s F. Interview plan section — but nothing is written to the story bank automatically. This replaced the previous behavior of silently inserting ~5 stories per evaluation, which made the bank impossible to review.
  • Each suggested question offers three choices: Draft / Record Answer (save it as a new core story via the interactive builder), Link an existing story (the section shows core stories that already match this role’s concepts, with a one-click Link/Unlink toggle backed by getMatchingStoriesForJob / setStoryJobLink), or simply ignore it (drafts are not persisted).

Interactive Story Builder:

  • Type or Record: Toggle between “Type draft” (typing a raw text response or notes) and “Record audio” (spoken practice transcribed by AI).
  • AI STAR Structuring: AI parses the raw text or spoken recording transcript into the structured STAR + Reflection format (Title, Situation, Task, Action, Result, Reflection), identifying 2–8 ATS-style keyword tags (skills, tools, methodologies, domain terms genuinely demonstrated in the story — the same kind of verbatim phrase the job-evaluation pipeline extracts from postings), readiness, and missing details.
  • Preview Before Save: AI-structured drafts are shown for review before they are written to the story bank.
  • Modal Wizard: Practice answers and standalone stories open in a focused modal flow instead of expanding the full page.
  • Position Assignment: Answers can be assigned to multiple active application positions with statuses Applied, Recruiter responded, or Interviewing. Checkboxes save immediately and can be unchecked at any time, regardless of how the link was created.
  • Private Taxonomy: The app ships only the taxonomy schema; new installs have no taxonomy data. Concepts are created locally from the user’s own evaluated jobs, story tags, and interview-prep material. Raw ATS keywords remain unchanged for resume tailoring, while grouped concept tags power search, filtering, and story-job matching.
  • Auto-Matching: Stories are automatically linked to eligible positions (Applied, Recruiter responded, Interviewing) whose local taxonomy concepts overlap with the job’s title, role archetype, or extracted ATS keywords — no manual checkbox needed. Exact raw keyword overlap still helps, but broader parent/child matches also work; for example, a story classified under “User interviews” can match a job asking for “user research.” Auto-matched positions are labeled “Auto-matched” wherever assignments are shown, so it’s always clear whether a link was system-suggested or user-chosen. Matching runs whenever a story is saved and whenever a position’s status changes into the eligible set.
  • Per-question practice history: Each question in the library shows an attempts count and a History & stories button. Every time you practice a question, the transcript, AI-structured STAR, quality rating, and coaching notes are saved as a durable practice attempt — re-practicing appends a new attempt rather than silently creating a duplicate story. The history drawer lists the question’s linked canonical stories plus every past attempt (newest first, each expandable to its STAR and transcript), with a Practice again button. Because a re-practice reuses the question’s existing canonical story id, refining an answer updates that one story while the full rep-by-rep history is preserved in practice_attempts.
  • Section-by-Section Editing: Once structured, the story is displayed as separate sections. Each section can be independently edited and saved directly to the database, ensuring you can refine details piece-by-piece.
  • Writing Voice Integration: Optionally opt-in to update your writing voice style profile with your custom answers, refining future AI-generated drafts.

Practice Questions:

  • Ships with reusable default prompts and lets users add their own custom interview questions.
  • Custom questions can be selected, edited, hidden, and reused for future typed or recorded practice through pop-up flows.

Standalone Stories:

  • Users can capture an accomplishment or proof point without tying it to a specific question.
  • AI structures the story, evaluates whether it is ready, and saves it as a standalone story after user confirmation in a pop-up wizard.

STAR Story Bank:

  • Collates and displays all saved stories with visual badges for S/T/A/R/Reflection components.
  • Shows source and kind labels for answered questions, standalone stories, voice practice entries, and job evaluation suggestions.
  • Includes search and filters by story kind, source, quality/readiness, grouped taxonomy tags, assigned/source position, and updated date.
  • Tags and Position filters are searchable multi-selects (SearchableMultiSelect in src/components/ui/searchable-multi-select.tsx): a button shows the selected count and opens a popover with a search box and checkboxes, so hundreds of tags or dozens of positions stay usable. Tag filters use grouped taxonomy concepts; selecting a parent concept includes its children. Selecting multiple values within one filter is OR’d; filters across different fields are AND’d.
  • Cards are collapsed by default. Each card shows title, badges, a one-line preview, up to 4 tags, and the assigned-position count; clicking the row (or “Show details”) expands it to the full STAR text, all tags, all assigned positions, and quality notes. This keeps the list scannable at the story-bank’s typical scale (100+ stories).
  • Paginated at 20 stories per page with Previous/Next controls, so the page doesn’t render or scroll through the entire story bank at once. Changing any filter or the search box resets to page 1.
  • Support inline editing using the interactive section-by-section editor. Clicking Edit on an expanded card launches the editor immediately.
  • Shows grouped concept tags first and keeps raw keywords in expanded details. User-authored stories normally contribute 2–8 raw keywords; job-evaluation suggestions can contribute up to 12 raw ATS keywords from the source job.

Taxonomy Manager:

  • Lets users review the generated tag tree, search paths and aliases, add tags, rename tags, move tags under another parent, archive/restore tags, add/remove aliases, and merge duplicate tags.
  • Taxonomy changes are logged locally. User edits are treated as authoritative for future classification.
  • The tree supports up to five levels so broad areas can contain specific methods, such as Research / User research / Qualitative research / Contextual inquiry.
  • Tags are collapsed by default and lazily rendered. A tag’s children — and the per-tag “move to parent” / “merge into” dropdowns, which list every other tag — only render once that tag is expanded. Unmatched keywords fall into a single “Other keywords” bucket that can grow into the hundreds as real usage data accumulates; rendering that bucket’s full edit UI unconditionally on page load previously froze the tab. Searching temporarily reveals matching branches regardless of their expanded state, and the match check walks the full subtree so a result at any depth (not just the first level or two) surfaces correctly.
  • Concept lifecycle (active / candidate / archived). The tree shows only the curated active set. Keywords pulled from job evaluations now arrive as candidates rather than cluttering the tree — count chips at the top show active / candidate / archived / alias totals. Candidates still power story↔job matching; they are just held out of the browsing view.
  • Review queue. A dedicated panel lists candidate concepts ranked by how many jobs referenced each, with a filter box, per-row checkboxes, Approve selected / Archive selected bulk actions, and a one-click Archive all unused (candidates with no story links and fewer than 3 jobs). A header chip on the workspace (“N candidates to review”) jumps straight here.
  • Automatic promotion. A candidate becomes active on its own when it is linked to a story or recurs across 3+ distinct jobs — so genuinely relevant tags surface without manual triage while one-off job phrases stay parked.
  • Blocklist. Credentials (degree/certificate phrasing), job titles (seniority-prefix shapes), and the user’s own tracked company names never become concepts. Patterns are role-agnostic, so the diet works for any user’s field, not just design. Blocked phrases still count for resume tailoring and job matching via raw-keyword matching.
  • No resurrection. Archiving a concept sticks: a later job evaluation that re-encounters the same keyword will not silently un-archive it — only an explicit restore does.

Job Evaluation Integration (Section F. Interview plan):

  • Direct entry point from the Job Detail → Analysis page. Next to each suggested question in Section F, clicking "Draft / Record Answer" opens the interactive builder inline.
  • Above the questions, a “You may already have stories for this role” panel lists existing core stories whose taxonomy concepts overlap this job, each with a Link / Linked ✓ toggle — so you can attach an existing story instead of drafting a duplicate. This is the review step that replaced auto-insertion; nothing enters the story bank without an explicit Draft or Link action.

Profile /profile

Career profile editor. The profile is the foundation for all evaluations and resume tailoring. The page is split into six tabs, navigated via URL (?tab=<id>), each with its own save action.

The AI extraction card is always visible on the Overview tab as a 2-step flow. Step 1 shows active (blue) when no PDF has been uploaded, and green ✓ once a PDF is ready. Step 2 (Extract button) is disabled until Step 1 is complete. The Resumes tab shows an upload banner when no extracted resumes exist.

Tab: Overview (?tab=overview)

  • Summary card: name, current search goal, location, portfolio, urgency, direction.
  • AI profile extraction card — 2-step flow: Step 1 (upload) shows active/✓ state; Step 2 (Extract with AI button) is disabled until at least one resume PDF is uploaded. Runs AI extraction on all uploaded resumes and populates skills, role directions, and experience automatically. Clicking Extract with AI opens a blocking ProgressModal (“Analyzing your resume…” + spinner); on success it shows the number of skills extracted; on error it shows the failure message. Extraction merges, never overwrites: existing targetRoles are preserved and new AI-extracted roles are appended (case-insensitive dedup). Positive title filters also merge — a third AI call generates realistic job-board search keywords (industry synonyms, seniority-neutral variants, common abbreviations) from the extracted roles and career direction, and all additions are merged into any filters already saved. The negative filter list is always left untouched.
  • Edit form: current search goal, search direction, urgency (select), career intent, career change interest, confidence level.

Tab: Resumes (?tab=resumes)

  • Upload banner (shown when no extracted resumes exist): instructs the user to upload a PDF, then go to Overview to run extraction.
  • Resume lanes card — each lane is a different resume version. Per-lane actions:
    • Upload PDF (blue solid button): shown when the lane has no PDF; opens file picker, uploads and auto-extracts text.
    • Replace PDF (outlined button): shown when the lane already has content; replaces the file and re-extracts.
    • Edit resume / Edit approved version (text link): always shown for every lane; opens the Resume Builder for that lane.
    • Remove (text link): always shown for every lane; deletes the entire resume lane after inline confirm (“Remove this resume? / Yes, remove / Cancel”). This replaces the old PDF-only removal behavior — the lane itself is deleted.
    • Rename (✎ pencil icon): inline rename with keyboard support (Enter saves, Escape cancels).
  • Add resume (PDF) button at the bottom of the lanes list: creates a new empty lane named “New Resume”. User then renames it and uploads a PDF.
  • Create new resume button: creates a blank lane with starter sections (Contact, Summary, Experience, Skills, Education) and opens the Resume Builder immediately — no PDF required. User types or pastes their content directly.
  • Skill inventory card (shown only after at least one AI extraction): lists extracted skills with category and evidence source.

Tab: Skills & Roles (?tab=skills)

  • Read-only badge displays for: strongest skills (from AI extraction), skills to use more, skills to use less, target roles.
  • Edit form: target roles (one per line), skills to use more (one per line), skills to use less (one per line).

Tab: Preferences (?tab=preferences)

  • Summary cards: location mode, compensation, desired industries, on-site / hybrid locations, remote regions.
  • Edit form: location mode checkboxes (Remote, Hybrid, On-site), on-site / hybrid locations, remote regions, desired industries, compensation needs, and free-form work preferences.
  • Two independent location lists. They answer different questions, and sharing one list made them inexpressible together — widening it to reach remote roles in another country also admitted that country’s on-site offices.
    • On-site / hybrid locations — places you would physically commute to. Matched against hybrid and on-site postings only.
    • Remote regions — countries or regions whose remote roles you can take. Matched against region-restricted remote postings only. Leaving this empty means remote roles from anywhere are accepted.
  • Region groups expand to their member countries. Rather than listing 27 nations, put a group in Remote regions and every member country matches: European Union (or EU), Europe, EMEA, North America, South America, Latin America (LATAM), Americas, APAC (Asia Pacific), Asia, Oceania, Africa, Middle East, Nordics, Scandinavia, Benelux.
    • The picker offers them directly. Typing part of a region name shows the matching groups in a Regions section above the geocoder’s results, each labelled with what it covers (“The 27 member states. Does not include the UK, Switzerland or Norway.”), and Nominatim’s own suggestions follow under Places. This is not cosmetic: Nominatim has never heard of these groups and answers EU with the French commune of Eu, APAC with Apac in Uganda, and EMEA with a village in Indonesia. Before the Regions section existed, the only route in was typing the name and finding Add typed location — a button the geocoder’s own five suggestions could cover outright.
    • Selecting a group writes the catalogue’s spelling, and a typed alias is rewritten to it: eu is saved as European Union, LATAM as Latin America. The matcher accepted the aliases already, but a chip reading eu told the user nothing about what it covered.
    • Groups already on the list are not offered again, matched by group key rather than label text. A profile predating the Regions section can hold a group under any alias — the old hint told users to type EU, LATAM, or Asia Pacific — and comparing labels would have offered the same region back and saved two chips meaning one thing.
    • CARIBBEAN_CODES covers every Caribbean state and territory CLDR names, not the largest eight. Americas and Latin America both advertise Caribbean coverage, so a short list silently rejected remote roles in Antigua, Dominica, or the Cayman Islands.
    • European Union and Europe are deliberately different sets. EU is the 27 member states; Europe additionally covers the UK, Switzerland, Norway and Ukraine. A posting requiring EU work authorization genuinely excludes the UK and Switzerland, so folding them together would accept unreachable roles.
    • Member countries are resolved from the same Intl.DisplayNames CLDR data that supplies the region vocabulary, so the two always agree.
    • Groups also expand on the on-site list, so Europe there matches an office in Berlin. Georgia is excluded from that expansion — it names both a country and a US state, and would otherwise make Atlanta match Europe.
    • The Regions suggestions are nevertheless offered on the remote list only (suggestRegionGroups). The on-site list asks where you would physically commute, and volunteering EMEA as a commute target would be noise. Someone who really would relocate anywhere in Europe can still type it and press Enter — the matcher expands it exactly the same way.
  • Picker interaction. Suggestions are grouped: Regions (resolved locally and instantly from the catalogue) above Places (the debounced Nominatim lookup). Enter adds what is typed — previously it did nothing at all, since the field sits inside the preferences form and the keypress was spent on implicit submission. Add typed location now sits beside the input rather than below the hint paragraph: the suggestion dropdown is absolutely positioned and covered that button outright whenever Nominatim returned a full five rows, which was exactly the case for the region names the hint told users to type there. The dropdown is capped at max-h-80 and scrolls.
  • Both lists use an OpenStreetMap Nominatim lookup that supports city, state/region, and country selections. You can save precise locations such as Nashville, Tennessee, United States, broader targets such as Tennessee, United States, or country-only values such as Canada. Each saved place displays as one label; legacy split values such as Nashville, Tennessee, United States are normalized back into one label. Remote regions are collapsed to their country when matching, so country-level entries are the useful granularity there.
  • Work preferences are reserved for non-location preferences such as small team, async-first, or mission-driven; location modes are stored separately.

Tab: Constraints (?tab=constraints)

  • Read-only list of current constraints and deal breakers.
  • Edit form: constraints (soft limits, one per line), deal breakers (hard-no conditions flagged as red flags in evaluations, one per line), career change interest.

Tab: Writing Voice (?tab=voice)

  • Displays the current tone profile if already extracted (tone, formality, sentence style, style guide).
  • Form to paste 2–5 writing samples (emails, cover letters, LinkedIn posts) separated by ---. Submitting runs AI style extraction and saves the tone profile. Used to match AI-generated content to the user’s authentic voice.

Strategy /strategy

Role-fit map derived from the profile and skill inventory.

  • Direct fit roles: score ≥ 80, strong evidence across the profile.
  • Adjacent roles: score 60–79, achievable with some positioning.
  • Selective roles: score 40–59, situational fit depending on the company.
  • Avoid roles: score < 40, significant gaps or misalignment.

Each archetype shows a score, rationale, and gap list. The user can edit the classification or rationale to correct AI judgments.

The page has two tabs:

  • Strategy (default) — role-fit map, search focus, AI-generated role directions, how-to-use guide, and evaluation corrections. Use Generate with AI when no role directions exist, or Regenerate with AI to rebuild them from the current profile and skill inventory. The app replaces the current role-direction set after generation and keeps the editable correction workflow below each direction.
  • AI Prompts — prompt overrides for resume tailoring, application answers, and outreach. Prompt overrides are stored locally and can be reset to the app defaults; locked resume-safety and organization-first outreach rules remain enforced in code.

Analytics /analytics

Search performance metrics drawn from actual evaluation and application data.

Charts and metrics:

  • Score-to-outcome correlation: do high-score jobs convert to interviews?
  • Archetype performance: which role types get the most responses.
  • Remote policy conversion: how remote / hybrid / onsite jobs track through the funnel.
  • Gap coverage: what percentage of evaluated gaps have been addressed.
  • Application funnel conversion rates.

Top gap patterns is read-only here. It lists the six most frequent gaps with their recurrence count and their bank status (Answered / Needs detail), then links to the Evidence bank to answer them. Editing was moved off this page so a gap is answered once globally rather than per analysis view.


Evidence Bank /evidence

The single place to answer the gaps and red flags evaluations raise. A gap is a fact about the candidate, not about a requisition, so its answer is stored once — keyed on the gap text — and reused by every application that raises the same gap, including future ones.

Reached from Analytics (“Top gap patterns”) and the Dashboard (“Evidence gaps to finish”).

Summary tiles:

  • Needs detail — answers the user started that the AI assessor judged too thin to use.
  • Recurring, unanswered — untouched gaps that two or more roles raised.
  • Answered — complete answers, reused automatically.

Why the list is filtered. Evaluators phrase gaps per requisition, so exact-text matching collapses very little and the raw unanswered pile runs into the hundreds. The default Needs work view therefore shows only answers already started plus gaps raised by 2+ roles (RECURRING_GAP_MIN_ROLES). A gap only one role raised is better answered on that job page. Answered and Every gap (N) filters show the rest.

Each row shows the gap text, the roles that raised it (linked), a status badge, and an expand control labelled Answer / Add detail / Edit by status.

Expanded editor:

  • The gap restated as a plain question.
  • One thing left / N things left — the assessor’s persisted questions, for needs_followup items. Capped at two; see “Only ask what a resume needs” under AI Capabilities for the rules governing what may be asked.
  • Draft with AI — proposes a starting answer built strictly from the user’s resume and previously answered gaps. The draft is never auto-saved: it appears in a bordered preview with a “Based on N items from your evidence” disclosure listing the fragments it drew on, plus Use this draft / Dismiss. When the evidence does not support an answer the model returns no draft and instead lists what the user must supply — it is prompted never to stretch unrelated experience to cover a gap. Accepting a draft leaves a standing “check every claim is true before saving” note above the buttons.
  • Polish wording — rewrites the current text without inventing facts (/api/gaps/polish).
  • Save to profile — assesses and stores the answer. addressed collapses the row; needs_followup keeps it open with the new follow-up question visible.
  • Clear everywhere — deletes the bank record, since it is the single global copy.

Settings /settings

Seven configuration tabs — ai, integrations, sources, scan-sources, cleanup, preferences, and data — selected by the ?tab= search param. The tab strip wraps (flex-wrap) rather than overflowing once the source tabs split the row:

AI Providers

  • Resume writing uses — a select under the priority list: Same as provider priority (default) or any provider that has a credential (Ollama only once switched on and answering — see below). The chosen provider handles Application Preparation, resume tailoring, and ✨ Improve, with the enabled chain behind it as fallbacks; everything else keeps using the priority list. It exists so a user can score jobs on a free local model and still have the resume — the document an employer reads — written by a stronger, faster one. The copy under it states what is sent (resume text, evidence answers, the posting) and that a local model can take several minutes. Stored in ai_settings.resume_writer_provider; only this form writes it, so onboarding cannot clear it. Ollama is offered only once it is switched on in the priority list and not reported unreachable, and resolveWritingCandidates ignores an Ollama writer that is not in the enabled chain — its base URL always has a default, so it is not evidence a local model exists.

  • Out of credits — each provider SDK reports an empty account differently and none names it: OpenAI sends a 429 with code: "insufficient_quota" (previously reported as “rate limit reached — wait a moment”), Anthropic sends a 400 “credit balance is too low” (previously not failover-worthy, so the chain stopped), and Gemini’s daily quota shares a 429 with its per-minute limit. The adapters now raise ProviderCreditsExhaustedError for these (isOpenAICreditsError, isAnthropicCreditsError, isGeminiCreditsError in src/lib/ai/credit-status.ts), and a per-minute limit is still left to the retry policy. The chain fails over on it, never retries it, and exposes FallbackProvider.notice“Anthropic is out of credits — this used your local model (gemma4:12b-mlx) instead.” — which the resume stream shows and evaluation already surfaces through its fall-through note. trackCredits wraps every adapter (single providers too, which are not chains) and records the state in ai_provider_status; orderForCredits moves exhausted providers to the back of every chain so runs stop leading with a failed round-trip, without dropping them. The row status reads Out of credits since YYYY-MM-DD until a Test connection passes. Every page shows AICreditsBanner under the header while a provider in the writer chain is exhausted: amber “OpenAI is out of credits. AI features are using your local model until you add credits.”, or red “No AI provider has credits left…” when nothing else remains. AI API routes answer that last case through aiErrorResponse with HTTP 402 and code: "ai_credits_exhausted", and toUserMessage gives the same one-sentence fix instead of a per-provider list. AI features that already degrade to non-AI output on failure still do; the banner is how those surface it.

  • Provider priority list — enable up to four providers and order them by priority. The first enabled provider in the list is used for every task; the rest act as automatic fallbacks. Reorder with the grip handle or the ↑ / ↓ buttons on each row. Each row shows a short status once enabled — Key needed, Not verified, Verified, or for Ollama Reachable / Not running.

  • ↑ / ↓ buttons sit beside the drag handle, because priority was otherwise drag-only: dnd-kit’s keyboard path existed but was announced only inside its hidden instruction text, and a touch user had no path at all.

  • Order and membership are separate. provider_order_json is the full ranked list of all four; provider_enabled_json is which of them are switched on. One column used to carry both, so switching a provider off erased its rank (it came back at the bottom in constant order on the next load), and emptying the list read as “never configured” — the factory then fell back to trying every provider holding a key, the opposite of what the UI showed. Onboarding refuses to save with nothing enabled — you cannot finish setup without a provider — but Settings allows it, because turning every provider off is a real thing to want (going offline, pausing spend). An empty set is then stored as an empty set, and the dashboard reports that no provider is configured instead of the app quietly carrying on with whichever keys happen to be stored.

  • Each row’s checkbox is wrapped in its own <label>. The provider name is the checkbox’s accessible name and part of its click target; previously the name was a sibling <span> and every box announced only “on”. An unselected row keeps full text contrast — it is a live option, not a disabled control, so the checkbox and border carry the state rather than opacity-50, which put the unselected provider names at 2.08:1.

  • API key cards follow the priority order rather than a fixed anthropic, gemini, openai list, so the #1 provider’s key field is the first one on screen and the “Active” badge is not three cards down. Each key and model input is associated with a label naming its provider (“OpenAI API key”, not a bare “API Key” repeated three times).

  • The drag handles point screen readers at real instructions. Each grip handle carries an aria-describedby pointing at dnd-kit’s hidden “press space bar to start dragging” text. That text is client-only, but the handle’s aria-describedby is server-rendered, and dnd-kit derives the id from a module-global counter that starts fresh in the browser while persisting for the life of the server process. Server and client therefore disagreed (DndDescribedBy-0 vs DndDescribedBy-1), React logged a hydration mismatch on every load, and the attribute that survived hydration referenced an element that did not exist — so a screen-reader user got no keyboard-drag instructions at all. The priority list’s DndContext now carries an explicit id="ai-settings-provider-priority", which dnd-kit uses verbatim instead of counting. The board in Applications → Kanban already sets id="applications-kanban" for the same reason; these are the only two drag-and-drop surfaces in the app.

  • Cloud providers — Anthropic (Claude), OpenAI (GPT), Google (Gemini). Enter an API key and select a default model for each.

  • Ollama (local) — free, runs entirely on your machine; no API key required. Enable in the priority list to reveal the configuration section:

    • Base URL — Ollama server address (default http://localhost:11434).
    • Model picker — a dropdown of the models the running server actually has, fetched on enable. If the saved model is not among them (the stored default llama3.1:8b usually is not), it is switched to one that is. When the list cannot be fetched the field falls back to free text with a “Choose…” button.
    • The connection test checks the model, not just the server. It fails when the configured model is absent, naming it, giving the ollama pull command, and listing what is installed. It used to answer ok: true for any reachable server and display the server’s first model instead — so a keyless-looking setup passed onboarding and then failed with a bare 404 on the first real request.
    • Quality guide — ≥64 GB: qwen2.5:72b / llama3.1:70b (near cloud quality); ≥12 GB: qwen2.5:14b / mistral-nemo; ≥8 GB: llama3.1:8b / qwen2.5:7b.
    • Unreachability warning — when Ollama is in the priority chain and the server is not reachable, an inline warning banner appears with a Retry button.
  • Every provider that was tried is named when the chain fails. The fallback chain used to throw only the last provider’s error, so a chain starting at a local Ollama and ending at Gemini reported “AI quota exceeded — you’ve hit the free-tier limit” — a quota error about a provider the user never meant to reach, with the local model’s actual problem thrown away. A whole-chain failure now lists each attempt in order, with the model that ran and a one-line reason:

    All 3 AI providers failed:
    ollama (gemma4:12b-mlx) — Ollama returned invalid JSON. Try a larger model (14B+)…
    openai (gpt-5.6-sol) — OpenAI rate limit reached. Wait a moment then retry…
    gemini (gemini-3.1-pro-preview) — [429 Too Many Requests] You exceeded your current quota.

    Each provider is bounded by its own deadline (150s cloud, 10 minutes local), so a slow local model hands over to the cloud provider behind it instead of spending the whole run’s budget; a provider that runs out of time is reported as did not finish within 600s — a smaller or faster local model would fit the budget rather than as the mechanism that stopped it. Retry classification follows the same whole-chain rule: a chain is retried only when every attempt failed for a retryable reason. A failure that would repeat on every provider (a malformed request) is still thrown as itself without walking the chain. An Ollama request that waits out Ollama’s own five-minute queue comes back as a bare HTTP 500 (Ollama server error (500)); shouldFailover now matches it, so a busy local model hands over to the cloud provider behind it instead of stopping the chain and leaving the resume untailored.

  • Output budgets are set where the shape is known, not left to each provider’s default of 4096 — see Output budget under Application Preparation. Ollama’s own generateJSON default also moved to 8192 to match Gemini’s, so a direct call cannot truncate either. Its client timeout is a minute past the local generation deadline, so a slow local run is cut by the caller’s deadline — which hands over to the next provider — rather than by the HTTP client at 120s, which the chain used to read as a provider failure while the run still had budget left.

  • Test connection for any provider to verify credentials and measure latency. On failure the panel shows one line — the HTTP status and the sentence that says what to do, e.g. [429 Too Many Requests] You exceeded your current quota, please check your plan and billing details. — with the provider’s full response behind a Full error toggle (scrollable, nothing discarded). Provider SDKs return the entire failure body: Google’s 429 arrives as ~2,200 characters of quota metrics and JSON violation objects, which pasted verbatim buried the actionable sentence. The summarizer (summarizeProviderError in src/lib/ai/provider-error-summary.ts) starts at the bracketed HTTP status when there is one — dropping the SDK’s “Error fetching from ” preamble — stops before any appended JSON payload, and keeps the first sentence, hard-truncating at 180 characters only when the message has no sentence break. A short message is shown as-is with no toggle. A failed test also reports the model that actually ran, so an auto option shows the resolved id rather than the latest-… sentinel.

  • Key masking — every stored secret (provider API keys, Brave, Adzuna) is replaced with •••• plus its last four characters before it reaches the browser, so the full value never enters the RSC payload. Saving an untouched field, or testing a connection without retyping the key, sends the mask back; both the save action and POST /api/ai/test swap it for the stored key via resolveMaskedKey in src/lib/ai/masked-key.ts. (Before this, Test connection handed the mask straight to the provider SDK and failed with a ByteString conversion error on the character.)

  • Model attribution — every AI-generated result (evaluation, research, outreach drafts, application answers) shows the model and provider that produced it.

Integrations

Clay (optional). Connect your own Clay account to find real people around an opportunity. Job Search Terminal works normally without it — evaluation, resumes and applications never depend on Clay, and a Clay outage cannot affect them.

  • Paste your API key and save; saving immediately tests the connection.
  • The test uses Clay’s identity endpoint, not a people search, so checking whether your key works never consumes your search allowance.
  • The key is stored locally and sent only to Clay. The settings page never receives it back — it shows the last four characters and nothing else.
  • Status reads Not connected, Connected, Key rejected (actionable — re-paste it), or Clay unreachable (usually not actionable — Clay is down or the API changed).
  • Disconnect clears the key, the status and any cached metadata.

Which key — use the scoped one. Clay’s profile page offers two, and only one works:

Clay tabKeyResult
API keys (beta)clay_scoped_… with the Public API scope✅ Connects
API keyclay_user_… (personal)❌ 401 “Authentication required”

Clay’s own API reference says the personal key is the one to use. It is not — that key is rejected, verified against a live account on 2026-08-18. Create a scoped key under Profile → API keys (beta) → Add API key with the Public API scope. Use Clay’s copy control rather than selecting the displayed value, which is truncated with an ellipsis.

Contact search itself is not built yet. This phase establishes the credential and connection plumbing only.

Job Sources

Source management is split across three Settings tabs, because the sources table is long enough that everything sharing a page with it was reachable only by scrolling past several hundred rows:

TabQuery stringHolds
Sources?tab=sourcesFresh posting window, Add a company, Job aggregators
Scan sources?tab=scan-sourcesThe sources table and the two discovery buttons
Cleanup?tab=cleanupCleanup review

The Dashboard’s Check sources button links to ?tab=scan-sources.

  • All configured sources appear in a unified table on the Scan sources tab — companies from portals.example.yml and any manually added sources are treated equally.

  • Enable or disable individual sources (disabled sources are skipped on the next scan).

  • Column filters and saved filter presets on the sources table.

  • Sources from portals.example.yml cannot be removed (they reload from the config file); manually added sources have a Remove button.

  • Add any company by pasting its careers page URL on the Sources tab — Greenhouse, Ashby, and Lever are auto-detected.

  • “Crawl for companies” queries the Common Crawl index to discover additional ATS boards automatically. See Source discovery (Common Crawl) below for how the sweep is bounded and why it is incremental.

  • “Search for companies” queries Brave Search API for ATS job boards not in Common Crawl (requires Brave Search API key in AI Provider settings). Merges new findings into data/discovered-sources.json without overwriting existing entries.

    Both labels name their method and their outcome. The previous pair — “Scan for new sources” and “Search discover” — read as if only one of them scanned and left the other’s job ambiguous; in fact both look for companies you are not tracking yet, and neither enables anything. The card description states that shared outcome once (“never turns a source on by itself”) so the labels only have to carry the difference between the two methods. It is worded without counting the buttons, because the Brave one is hidden until a Brave Search API key is saved.

  • Discovered sources stay pending until the user reviews and explicitly selects the validated companies to add.

  • Cleanup review (its own Cleanup tab) lists disabled or malformed user-added sources for explicit removal. Existing sources are never removed automatically. Each row has a Remove source button, and a Remove all (N) control in the card header clears the whole list in one pass. Removal is irreversible from the UI — a removed source has to be re-entered by hand — so “Remove all” expands in place into “Remove N sources? This cannot be undone.” with Yes, remove all / Cancel rather than firing on the first click. The server action recomputes the candidate set from the database rather than trusting the rendered page, so a tab left open since before a source was re-enabled cannot delete something that no longer qualifies. Removing a source also drops its scan_source_overrides row.

  • “Validate sources” opens a modal (progress, then summary counts and a scrollable list of dead/unknown boards — same interaction pattern as Scan for new jobs on the Dashboard). It checks each tracked board’s public ATS JSON URL (same host as CareerOps scans). Results: Live / N jobs when HTTP 200 and JSON parse succeeds, Dead on HTTP 404, Unknown for other HTTP codes, timeouts, or non-JSON. The validator uses a 45s per-source ceiling, browser-like User-Agent / Accept headers, up to three attempts with backoff on transient errors (HTTP 429 / 5xx / network aborts), lower concurrency (5), and longer pauses between batches so Ashby and other hosts are less likely to rate-limit when hundreds of sources are validated at once. Hover an Unknown badge to see the last error string (for example HTTP 429). Re-validate sources re-runs the full check.

    Each completed pass is persisted as a source_check_runs row — timestamp, verdict counts, and the per-source results. The Live column is pre-filled from the last stored pass on the next page load, instead of resetting to “Not validated” on every row; the header line next to the source counts shows checked . This full-list validation history is separate from the Dashboard’s discovery timestamp.

  • Fresh posting window card: select how far back scans accept postings — 24 hours, 72 hours (default), or 7 days. Postings older than the window are skipped as stale. The selected window applies to company career-site (CareerOps), Dice, and Adzuna scans, whether triggered manually or by the six-hour schedule. (This selector previously lived under Data & Backup; it moved to Sources so it sits next to the scan tools it governs.)

Source discovery (Common Crawl)

src/lib/scanner/source-discovery.ts harvests Greenhouse / Ashby / Lever company slugs from the Common Crawl URL index, validates each board’s public ATS JSON endpoint, and writes candidates to data/discovered-sources.json for manual review. Nothing is scanned until the user imports it from Settings → Job Sources.

Resilience. The CC index intermittently answers 502 / 503 / 504 under load. Requests retry with exponential backoff and jitter, honouring Retry-After when present. This matters: a run that hit gateway errors on all four URL patterns previously wrote totalCrawled: 0 with no errors recorded, making a total outage look identical to “nothing new to find”. Query failures are now recorded in the errors array of both the output file and the run summary.

Rate limiting — run discovery sparingly. Common Crawl is a free community service with no published rate limit, and it throttles by refusing connections outright (ECONNREFUSED, not a 429). Repeated full sweeps will get the host blocked for a period; this was observed in practice after several back-to-back runs, at which point every query fails while unrelated hosts stay reachable.

Three deliberate choices follow from that, and they should not be “optimised” away:

  • Attempts are capped at CC_FETCH_ATTEMPTS (3), not more. Retrying hard while the index is throttling is a retry storm that converts a slowdown into a block.
  • Backoff is exponential (CC_RETRY_BASE_MS × CC_RETRY_FACTOR ^ attempt) with jitter, so concurrent queries do not resynchronise their retries.
  • A circuit breaker aborts the whole sweep after CC_MAX_CONSECUTIVE_FAILURES (3) consecutive failed queries. Grinding through the remaining patterns while blocked deepens the block and wastes minutes; the run reports the abort as an error instead.

If a run reports “Common Crawl is rate-limiting or refusing this host”, wait before retrying. Nothing is lost — discovery is incremental, so a later run resumes where this one stopped.

Crawl selection. Indexes resolve from collinfo.json at run time — the CC_INDEX_COUNT (3) most recent crawls, plus CC_ARCHIVE_INDEXES. The implementation previously pinned a single crawl that went 20 months stale. The archival index is coverage, not redundancy: jobs.lever.co/* returns a persistent 504 on every recent index while answering normally on CC-MAIN-2024-51, so a recent-only sweep finds almost no Lever boards (1 slug, versus 90 from the archival crawl). Slugs from older crawls are still validated live, so a stale crawl cannot introduce dead sources.

Pagination. Each pattern is walked page by page via showNumPages. The old implementation fetched a flat limit=1000 slice of page 0, capping patterns that hold tens of thousands of records.

Incremental by design. loadExistingSlugs() skips slugs already in portals.yml, already in the custom-sources table, and already reported by an earlier run. Consulting only portals.yml (31 companies) meant every run re-validated the hundreds of sources already imported plus every candidate it had already reported, so each run redid the last one’s work and buried genuinely new boards. Because prior discoveries are skipped, MAX_NEW_CANDIDATES_PER_RUN (1,500) is a rolling window rather than a permanent ceiling — rerun discovery to walk further through the backlog. A run that hits the cap sets truncated.

Where the cap is applied. After the full sweep, never during it. Sweeping is cheap (parsing index lines); validation and AI classification are what cost time and money. Capping mid-sweep also aborted whole indexes before they were reached — including the archival one carrying essentially all Lever coverage, which silently defeated the Lever fix above. selectBalancedCandidates then draws round-robin across providers: a flat slice would be dominated by Greenhouse, which outnumbers Ashby roughly 2:1 and Lever by orders of magnitude, so Lever boards would never survive the cap.

Since a run only carries newly-found slugs, its output is merged with prior entries before writing; otherwise each run would wipe the pending review list.

Cost control. AI industry classification is capped at MAX_AI_CLASSIFY_ENTRIES (200) per run. The label only decorates the review list, and an uncapped wider sweep would issue roughly 55 model calls per run. Entries past the cap fall back to the existing slug heuristic.

Scale note. A full sweep surfaces on the order of 9,000 candidate slugs, of which roughly 77% have a live endpoint. Importing thousands of sources would multiply CareerOps scan cost proportionally; staggered or incremental scanning is not yet implemented, so import selectively.

Preferences

  • Edit title include / exclude filters.
  • Add profile supplements for gap filling. Supplements are checked for concrete role, project, action, and outcome detail before they are treated as confirmed resume-tailoring context.
  • Adjust other search preferences.

Data & Backup

  • Enable or disable automatic scans every six hours while the local app is running. The schedule card shows the currently selected fresh-posting window and links to Sources → Fresh posting window, where the selector now lives.
  • Create a portable .jst-backup archive with optional password protection.
  • Restore only after archive validation, preview, explicit confirmation, and an automatic rollback backup.

AI Capabilities

The app supports four AI providers interchangeably:

ProviderDefault modelUsed for
OpenAIlatest (auto-resolved)Evaluation, answers, outreach, research, transcription
Anthropicclaude-sonnet-4-6Evaluation, answers, outreach, research
Google Geminigemini-2.5-flashEvaluation, answers, outreach, research, transcription
Ollama (local)user-selectedEvaluation, answers, outreach, research

Providers are configured as an ordered priority chain in Settings → AI Provider. The app tries providers from top to bottom and automatically fails over to the next one when a provider is unavailable or returns a recoverable error. The first provider in the chain that has a credential configured is the active provider. Ollama uses a base URL (default http://localhost:11434) instead of an API key and supports any model installed on the local Ollama server. All AI calls use the src/lib/ai/ provider abstraction with retry and failover logic.

Gap evidence AI helpers (src/lib/gaps/):

ModuleEndpointBehavior without a provider
evidence-context.tsReturns whatever is on file; no AI involved
gap-answer-assessor.ts(internal)Falls back to a keyword heuristic holding the same line
llm-gap-polisher.ts/api/gaps/polishReturns the input unchanged
llm-gap-drafter.ts/api/gaps/draftReturns no draft, plus at most two generic questions

Only ask what a resume needs. The governing rule for every follow-up question is: would the answer change the wording of a resume bullet? If not, it is not asked. Concretely:

  • Employers, job titles, dates, and durations are never asked for. They are already in the database. loadGapEvidenceContext() (evidence-context.ts) assembles the active resume’s text plus an organization — title — dateRange list from the resume builder, and passes it into every assessment. Re-asking for a date that is already on file is treated as a defect, not a thoroughness feature.
  • Nothing the answer already states is re-asked, including loosely. If the answer says the candidate managed people at named companies, that is settled — it is not sent back for confirmation or a re-listing of those companies.
  • At most 2 questions, and one is preferred (MAX_FOLLOW_UPS). Priority order is (1) scale — headcount, users, budget; (2) a concrete outcome or deliverable.
  • Questions are persisted, not regenerated. The list is stored in assessment_json.followUpQuestions and re-read via followUpQuestionsFromJson(), so it does not change between page visits or button clicks. Regenerating produced a different set each time and made the loop feel endless. A new question set is produced only when the answer text itself changes and is re-assessed. When no question is stored — a row cleared by npm run gaps:clear-stale-questions — the UI falls back to a deterministic scale question rather than rendering nothing.
  • Gap sentences are reduced before use. gapSubject() in gap-text.ts strips the evaluator’s framing so questions read about the topic, not the complaint. It handles leading forms (“The available resume evidence does not explicitly document X”, “The job calls for X”, “No X”), trailing forms (“X is not demonstrated”, “X is stated”), dangling conjunctions, and the contrast form “X, but limited evidence of Y” — where the gap is Y, since X is normally a compliment. When a sentence resists reduction it returns "" and callers use generic wording instead of splicing the complaint into a question. Shared by the assessor, the drafter, and the Evidence bank panel so all three phrase a gap identically.
  • One question box. The assessor’s persisted questions are authoritative; when a saved answer exists, Draft with AI no longer prints a competing list of its own.
  • An answer that only restates the gap counts as empty. isEchoOfGap() catches it, so the evaluator’s own complaint can never read as evidence.

The heuristic fallback holds the same bar: where + what + scale-or-outcome is enough (employers/titles/dates explicitly not required, since they are on the resume).

draftGapAnswer() is grounded and refusal-capable by design: it is given only the active resume’s extracted text and previously addressed answers (the gap being drafted is excluded so the model cannot echo it back), it is instructed never to invent employers, titles, dates, metrics, or outcomes, and it is told that returning an empty draft is the correct output when the evidence is silent — stretching unrelated experience to cover a gap is explicitly forbidden. It returns basedOn (the fragments it drew on, surfaced in the UI for verification) and questions (what the user still has to supply). Nothing it produces is persisted until the user reviews and saves it.

Model selection — keeping up with new releases

All three cloud providers work the same way in Settings → AI Provider: the dropdown merges a curated list with whatever the saved key can actually reach right now, fetched from that provider’s own model-list endpoint, and offers auto options that follow new releases without anyone editing the setting. Under each dropdown a line reports what the current auto option resolves to (Resolves to gemini-3.7-flash — rechecked hourly), with a Refresh link that re-queries the provider.

An auto option changes which release runs, never which tier. Tier is the axis that decides price and capability, and that stays the user’s choice:

ProviderAuto optionsHow the tier is held
ClaudeLatest Sonnet (default), Latest Opus, Latest HaikuTier is in the model id, so there is one option per tier
GeminiLatest Flash (default), Latest Pro, Latest Flash-LiteSame — one option per tier
OpenAILatest (default)Tier is a suffix, so one option is enough: cheaper and off-product variants are skipped

Resolution is cached for one hour per key, and every failure path falls back to a known-good pinned model rather than erroring — model discovery must never be able to fail a generation. Selecting a concrete model id instead pins it, and the line under the dropdown says so.

Claude and Gemini

  • Claudelatest-sonnet, latest-opus, latest-haiku resolve against Anthropic’s GET /v1/models. Within a tier the highest version wins numerically (claude-opus-4-10 beats claude-opus-4-8, claude-opus-5 beats both), and the undated alias beats a dated snapshot of the same release because the alias keeps following Anthropic’s own pointer. Ids from the 3.x era (claude-3-5-sonnet-…, which put the version before the tier) are still ranked correctly. Fallbacks: claude-sonnet-5 / claude-opus-5 / claude-haiku-4-5. Curated pins: claude-opus-5, claude-sonnet-5, claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5. Logic in src/lib/ai/anthropic-models.ts, live list from GET /api/ai/anthropic-models.
  • Geminilatest-flash, latest-pro, latest-flash-lite resolve against GET https://generativelanguage.googleapis.com/v1beta/models (the @google/generative-ai SDK exposes no listing call, so REST is used directly), keeping only ids that support generateContent. Stable releases win: thinking variants, dated builds (-001), image/TTS/custom-tool builds and Google’s own -latest aliases never match. One exception — a tier whose newest stable release is a whole generation behind the newest stable generation the key can see falls through to that tier’s newest preview. This is Google’s real behaviour mid-transition: gemini-2.5-pro stays in the list long after it stops serving new keys (“no longer available to new users”), while the current Pro exists only as gemini-3.1-pro-preview. Running a preview is worse than running a current stable model and better than running one that 404s; a tier whose stable release is current is never moved onto a preview, however new that preview is. Fallbacks: gemini-2.5-flash / gemini-2.5-pro / gemini-2.5-flash-lite. Logic in src/lib/ai/gemini-models.ts, live list from GET /api/ai/gemini-models.

Migration 0065_latest_claude_gemini_models moves installs still holding the app’s own old defaults (claude-sonnet-4-6, gemini-2.5-flash, gemini-2.0-flash) onto the matching auto option, keeping the same tier. A model the user picked themselves is left untouched, and switching back to a pinned id is one dropdown change.

OpenAI

The OpenAI model dropdown offers:

  • Latest (auto) — the default. Stored as the sentinel latest and resolved at request time against OpenAI’s /v1/models list. It takes the newest generation the key can actually reach and, within it, the bare generation alias (gpt-5.6, which routes to gpt-5.6-sol) or -sol when the account’s list does not expose the bare alias. Generations that only expose cheaper or off-product variants (-mini, -nano, -terra, -luna, -pro, -codex, -chat-latest, dated snapshots) are skipped, so latest changes the model’s generation but never its tier. The resolved id is cached for one hour per key; if the lookup fails the call falls back to gpt-5.6 rather than erroring. The dropdown shows which id latest currently resolves to, with a Refresh link.
  • Named GPT-5.6 variantsgpt-5.6 (generation alias, routes to gpt-5.6-sol), gpt-5.6-sol (highest capability), gpt-5.6-terra (balanced, lower price), gpt-5.6-luna (fast, high volume).
  • Older pinned modelsgpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano.
  • Anything else the saved key can reach, merged in live from GET /api/ai/openai-models (which lists gpt-* models using the saved key and reports the current latest resolution). All three model-list routes answer in the same shape — { models: string[], latest: Record<sentinel, modelId> } — so the settings form treats the providers identically.

Migration 0057_openai_latest_model moves installs still on the old default gpt-5.4-mini onto latest; explicitly pinned models are left untouched.

The resolution logic lives in src/lib/ai/openai-models.ts (OPENAI_LATEST_SENTINEL, pickLatestFlagship, resolveLatestOpenAIModel) and is applied inside OpenAIProvider for text, JSON, streaming, and connection tests.

AI-powered features:

  • Job fit evaluation (streaming, real-time output)
  • Resume tailoring
  • Application answer generation
  • Company research
  • Outreach message drafting
  • Profile extraction from PDF
  • STAR story transcription and parsing
  • Gap response polishing
  • Writing style extraction

Job Scanning

The scanner discovers new jobs from ATS APIs (Greenhouse, Ashby, Lever) and custom URLs configured in Settings.

How a scan works:

  1. User clicks “Scan for new jobs” on the Dashboard.
  2. The CareerOps ATS scanner queries each enabled Ashby/Greenhouse/Lever source in parallel.
  3. Dice runs in parallel with the ATS scan. If Adzuna credentials are configured (Settings → AI Provider → Discovery & Aggregators), Adzuna runs in parallel too.
  4. Title filters remove irrelevant roles.
  5. Profile location preferences remove listings outside the user’s constraints — the on-site / hybrid list for hybrid and on-site postings, the remote regions list for region-restricted remote postings. This runs on every lane: the CareerOps scanner applies it inline, and every board lane (Dice, Adzuna, Himalayas, browser-board and email-alert imports) applies it at import so out-of-region roles are never written to the database. Jobs whose location the board did not report are kept and labelled No location, since filtering on missing data would discard roles for want of it.
  6. Listings outside the selected fresh-posting window are filtered out.
  7. Duplicates are skipped. A listing at a URL already in the app is always a duplicate. A new URL for a role already in the app (same company + title + location) is a duplicate only while that existing row is still live — the same role arriving through another lane in the current cycle. Once the user has closed the earlier row out (Applied, Rejected, Skipped, Archived), a new URL is treated as a re-post: the company opened a fresh requisition, so the listing is imported and counted in repost_count. This is what keeps a re-opened role from being hidden forever behind a requisition the user already dealt with months ago.
  8. New jobs are written to the jobs table with status = found.
  9. A scan_runs record is created with metrics.
  10. The Dashboard updates with a combined scan summary across company career sites, Dice, and Adzuna when configured.

Scan progress and results dialog (Dashboard “Scan for new jobs” and Settings → Scan sources per-company scan): while the Dashboard scan runs, the modal receives live server progress and shows the actual state of the company career-site, Dice, and Adzuna lanes. Parallel lanes can be marked Scanning now at the same time; each changes to Complete, Skipped, or Stopped as its state changes. The current activity is exposed through a polite, atomic live region for screen readers. Adzuna is shown as skipped when it is not configured.

The results header shows count badges: run status, N new in app, N found at source, N sources scanned, N skipped, N filtered by profile rules, N duplicates skipped, and N re-posts of a closed role (shown only when non-zero — new listings that re-open a role the user had already applied to, rejected, skipped, or archived).

The results view is scrollable when there are many errors or new listings. Each error shows a category badgeDead or missing (404/410, bad URL, unknown host), Timed out (no response within the fetch limit; the board may still be live), or Other error. A summary line counts how many sources reported issues, how many can be disabled as YAML/custom career sources, and a breakdown by category. Select all / Clear selection / Disable selected bulk-update scan_source_overrides; per-row Disable does the same for one company. Aggregator-only rows (e.g. Adzuna) are not disabled as career sources — the UI points to AI Provider settings instead.

The Jobs page can also verify whether saved postings still exist. The liveness check updates liveness_status but does not automatically archive or delete anything. Expired jobs with no user activity are shown for confirmation, and confirming archives them rather than deleting them.

Removal protection (src/lib/jobs/job-protection.ts). A job is protected from automatic removal when any of these hold:

  • it is already archived;
  • it has user activity — reviewed, skipped, resume-generated, or applied;
  • it was discovered within the last day (DISCOVERY_GRACE_DAYS). Boards that challenge bots routinely look expired on one check and active on the next, so a posting a scan found this morning is never swept the same afternoon.

Protected jobs are kept unless the user explicitly selects and deletes them. The maintenance panel reads this decision from the server rather than re-deriving it, so the “kept” and “can be cleaned up” counts always match what the server will do.

Liveness evidence quality (src/lib/scanner/liveness-checker.ts). Only HTTP 404/410 and explicit expiry copy mark a posting expired. Two host lists soften that where unauthenticated checks are unreliable:

  • Session-gated hosts (LinkedIn by default) serve login walls and generic “no longer accepting applications” copy for roles that are still open, so no text-based verdict from them is trusted — only a hard 404/410 counts.
  • Ambiguous hosts (Monster by default) can return HTTP 200 challenge pages, so a pattern-free 200 falls back to uncertain instead of active.

Both lists can be extended locally via config/liveness-hosts.local.json (gitignored; see config/liveness-hosts.example.json).

The Jobs table also re-checks current profile preferences at render time and is refreshed after Preferences or Constraints are saved. Jobs that still fit show Match in the Preference column; jobs that no longer fit show Out of scope.

Location matching uses the selected Location mode checkboxes, and each mode reads a different location list (src/lib/jobs/preference-fit.ts):

  • Remote includes remote opportunities whose region is in the remote regions list. An empty remote list accepts remote roles from anywhere.
  • Hybrid includes hybrid opportunities only when the posting location matches one of the on-site / hybrid locations.
  • On-site includes on-site opportunities only when the posting location matches one of the on-site / hybrid locations.

The two lists are independent by design. Before they were split, one list drove both, so a user who would commute only within Nashville but would take a remote role anywhere in the US or Canada had no way to say so: adding Canada to reach remote-Canada roles also admitted on-site Toronto offices, and omitting it rejected the remote roles.

Three rules make “matches one of the on-site / hybrid locations” behave the way postings are actually written:

  • Multi-location postings are split. ATS boards routinely pack several locations into one field — San Francisco, CA • New York, NY • United States. The field is split on , ·, |, ;, and newlines, and the job is accepted when any listed location qualifies. Commas are not split on, because they separate parts within a single location (San Francisco, CA). Splitting happens on the raw string, before normalization strips the separators.

  • A bare country label means country-wide, not on-site. A posting whose whole location is United States, USA, or US is available across that country and is accepted for any preference inside it. Without this, such postings have no remote token, fall through to the on-site branch, and are rejected as “outside preferred locations” — which silently dropped most US-wide remote roles. Country detection uses a country-only alias list, deliberately separate from LOCATION_ALIAS_GROUPS, whose united states entry folds in all 50 state aliases and would otherwise classify Ohio as country-wide. A trailing country on a city (Chicago, Illinois, United States) is still treated as on-site.

  • The user’s home metro matches a state-level preference. Metro labels such as Nashville Metropolitan Area contain no state token, so a preference of Tennessee, United States misses them. When the profile’s own location already falls inside a preferred region, its city name is added as an accepted alias. The gate matters: someone living outside their target region does not silently pull in local roles.

  • Remote roles restricted to a region the user cannot work in are rejected. A posting limited to Germany (Remote) or Remote - Europe is not accepted merely for being remote. This rule reads the remote regions list only, so it is independent of where the user would commute. The rule is deliberately permissive: it only rejects when the restriction names a recognised region and none of the named regions are in scope. An unrecognised remainder — “Anywhere in the World”, “27 Locations, Remote” — counts as unrestricted, because guessing wrong silently discards good roles, which is the failure this filter has already caused once. An empty remote regions list likewise means “no restriction”. A region inside an accepted country stays in scope, so Remote (California) and Georgia (Remote) are accepted when the remote list contains the United States, even though Georgia is also a country.

    Matching is by overlap, not containment, in both directions. A posting may name something inside an accepted region (Germany within Europe) or something wider than it — someone authorized only in the EU can take a role advertised across Europe or EMEA, and a US-only candidate can take one advertised across North America. Requiring containment discarded both.

    A term that positively asserts no restriction — anywhere, worldwide, world wide, global, distributed — outranks any place names beside it. Postings routinely read “remotely world wide, joining us from offices in San Francisco, Germany, Austria”; those are the company’s offices, not a restriction. remotely and the spaced world wide are recognised alongside remote and worldwide, since neither was matched as a token before.

  • A posting with no reported location is never judged on location. Boards frequently omit the field, and the value arrives as Not specified. Treating that as a location mismatch would discard roles for want of data — the same principle as the unrecognised-remainder rule above. Such jobs are kept at import and shown as No location rather than Out of scope. One helper, isLocationReported, backs both the importer and the render-time label so the two cannot drift.

    The place-name vocabulary comes from Intl.DisplayNames, which supplies ~264 ISO 3166 region names from the runtime’s own CLDR data, plus the supra-national regions ISO omits (europe, emea, apac, latam, …). This avoids hand-maintaining a world list that would rot. Sampled against a live Himalayas feed it recognised all 139 distinct restriction values while correctly ignoring non-geographic text.

One catalogue behind the matcher and the picker. The supra-national groups — their labels, aliases, member ISO codes, and the one-line “covers” text shown in the picker — live in src/lib/profile/region-groups.ts. preference-fit.ts derives REGION_MEMBER_CODES, the group half of LOCATION_ALIAS_GROUPS, and the supra-national additions to WORLD_REGION_NAMES from it; PreferredLocationsInput builds its Regions suggestions from the same list. They used to be separate, with the group names additionally hard-coded a third time into the hint text on both Profile → Preferences and the onboarding wizard — so a group added to the matcher was offered nowhere, and a group named in a hint might not exist. Adding one entry to the catalogue now reaches all three.

The module’s public surface:

ExportPurpose
REGION_GROUPSThe catalogue itself, in matcher-significant order
REGION_GROUP_ALIASESGroup key → aliases, spread into LOCATION_ALIAS_GROUPS
REGION_GROUP_MEMBER_CODESGroup key → member ISO 3166-1 alpha-2 codes
REGION_GROUP_ALIAS_LISTEvery alias, for the WORLD_REGION_NAMES vocabulary
matchRegionGroups(query, limit?)Groups to suggest for a partial query
regionGroupForLabel(value)The group a stored value names, or null

matchRegionGroups matches substrings, not prefixes, so america reaches North, South, and Latin America rather than only Americas; exact and prefix matches still sort first, so eu leads with European Union. Order within the catalogue is load-bearing — supranationalGroupFor resolves a posting’s region to the first matching group.

Covered by src/lib/__tests__/preference-fit.test.ts, src/lib/__tests__/region-groups.test.ts, and src/lib/__tests__/title-filter.test.ts, which use verbatim location strings and job titles from real postings as fixtures. The region-group suite additionally asserts that every catalogue label round-trips through the matcher, so a label that drifted out of its own alias list cannot ship silently accepting nothing.

Configuration:

  • Built-in sources: enable/disable per company in Settings → Job Sources.
  • Custom sources: add any careers page URL.
  • Title filters: positive list (must match) and negative list (exclude if matched). Matching lives in one place, src/lib/jobs/title-filter.ts, shared by the CareerOps, Dice, Adzuna, and Himalayas lanes — it previously existed as four near-identical copies that had to be fixed in lockstep.
    • Positive keywords must start at a word boundary. Plain substring matching made short keywords greedy: ux matched “Linux”, “BENELUX”, and “Luxembourg”, which accounted for 3 of 12 results in one live Himalayas import. Only the start is anchored; the end stays open so product design still matches “Product Designer” and ux research still matches “UX Researcher”. Verified against 140 previously-imported jobs: zero legitimate matches lost.
    • Negative keywords stay plain substrings on purpose — they are meant to be greedy, so intern also catches “Internship”. A check over 2,629 real titles found no case where that greediness rejected a wanted role.
  • Profile filters: selected location modes, on-site / hybrid locations, and remote regions constrain scan inserts.
  • Settings → Scan sources table: Above the table, counts show sources total | enabled (enabled reflects optimistic checkbox toggles until the server round-trip completes). Scan all enabled runs the same full CareerOps job fetch as the Dashboard scan’s ATS leg: every enabled source is queried in parallel, independent of any prior “Validate sources” result — use it after re-enabling boards or when you want a fresh pull without opening the Dashboard. Scan jobs on a single row calls the same scanner with companyExact for that company even when the row is disabled, so you can verify a careers URL before turning the source back on. The Live column uses the same sort/filter header pattern as the other data columns; it opens pre-filled from the most recent stored Validate sources pass, and shows Not validated for each row only when no check has ever been run (or for a source added since the last one).

Performance tuning constants (in src/lib/scanner/careerops-scanner.ts):

  • CONCURRENCY = 20 — parallel ATS API requests.
  • ATS_JOB_LIST_FETCH_MS = 60_000 — per-source ceiling for the full job-list request: HTTP response and response.json() parsing. Large Ashby boards often need far more than a few seconds to download JSON; the previous 12s budget could mark live boards as timed out. (jd-fetcher.ts still uses FETCH_TIMEOUT_MS = 12_000 for individual job-description fetches.)
  • ATS_JOB_LIST_FETCH_RETRIES = 1 — on timeout/abort, one immediate retry after a short pause (transient CDN saturation).

Posted dates per ATS (parseGreenhouse / parseAshby / parseLever): Greenhouse supplies updated_at and Ashby supplies publishedAt; Lever’s public postings API exposes no date, so Lever jobs are always unknown-date and are never filtered as stale. parseAshby previously read a publishedDate field the posting API has never returned, which left every Ashby job undated — so Ashby listings bypassed the fresh-posting window entirely. With the correct field in place, Ashby jobs are now subject to the selected freshness window like every other source; a 72-hour window will drop Ashby postings older than three days that used to slip through as undated.

Pruning dead sources: npx tsx scripts/prune-dead-sources.ts validates every enabled source (YAML + custom) and writes scan_source_overrides rows to disable any that return HTTP 404 in pass 1, plus any that are unreachable in both passes. Disabling is non-destructive — sources can be re-enabled from Settings → Job Sources. Useful after a bulk source-discovery import, which tends to add many slugs that do not actually exist on the ATS host.


Browser Job Board Scanner (Claude and Codex Integration)

An optional feature for users with Claude Desktop or Codex Chrome. An agent discovers job-board results on your behalf and writes them directly into Job Search Terminal — no copy-paste required. Supported sources are LinkedIn, Wellfound, Work at a Startup, Glassdoor, Indeed, Monster, and Dice (MCP-powered, no browser needed).

How it works:

  1. Ask Claude or Codex to scan LinkedIn, Wellfound, Work at a Startup, Glassdoor, Indeed, Monster, or Dice
  2. The agent reads your target roles and location preferences from the JST database
  3. For browser boards: the agent opens the board in Chrome and extracts matching visible postings. For Dice: the agent calls the Dice MCP search_jobs tool directly — no Chrome extension or login needed
  4. A JSON file is written to data/job-board-imports/ (data/linkedin-imports/ remains supported for legacy LinkedIn files)
  5. Job Search Terminal detects the file, imports jobs with duplicate detection, and shows a notification

Dice MCP Scanner (In-App, No Credentials)

Dice is a tech-focused job board. JST integrates with Dice via Dice’s free, public MCP server (https://mcp.dice.com/mcp) — no API keys, no browser extension, and no login required.

How it works:

  1. Open Settings → Sources → Job aggregators
  2. Click Scan with Dice
  3. JST calls the Dice MCP search_jobs tool over HTTP with your target roles, on-site / hybrid locations, and remote preference from your profile. The remote regions list does not shape the query — it is applied as a filter at import.
  4. Results are filtered by your title filters, written to data/job-board-imports/dice-jobs-<timestamp>.json, and imported automatically. Jobs outside your location preferences are dropped at import rather than written and labelled
  5. New jobs appear in the Jobs table with a Dice source badge

What it covers: Tech roles on Dice including software engineering, data, DevOps, security, and product. Results are filtered to the past 7 days and up to 50 jobs per scan.

Scan type recorded: dice-mcp-scan. Jobs appear in the Jobs table with a Dice source badge.

Implementation: src/lib/scanner/dice-scanner.ts — contains a minimal MCP streamable-HTTP client and the scan/import orchestration.


Himalayas Remote Board Scanner (In-App, No Credentials)

Himalayas is a remote-only job board with a large public feed (~97,000 live postings, ~2,900 added per day). No key or login is required. It runs automatically as a lane of the Dashboard scan alongside CareerOps, Dice, and Adzuna.

Two API constraints shape the implementation (src/lib/scanner/himalayas-scanner.ts):

  • No server-side filtering. search, category, and similar parameters are accepted and then ignored — every query returns the same feed. Verified directly: ?search=designer and ?category=design return results identical to an unfiltered call. Titles are therefore filtered client-side with the same positive/negative lists the other lanes use.
  • limit is hard-capped at 20 regardless of the value requested.

Why it is still viable: the feed is strictly newest-first, so a scan reads the newest pages and stops instead of walking all ~4,800. MAX_PAGES (60) covers the newest ~1,200 postings — roughly ten hours at the observed rate, comfortably ahead of the six-hour schedule.

Only genuinely thin sweeps are reported. In practice the page cap, not the freshness cutoff, ends the walk: a 72-hour window would need ~435 pages. Hitting the cap is therefore the normal ending and is no longer recorded as an error — doing so put an “Other error” row (with a Disable prompt) on the Himalayas lane after runs that had just delivered new jobs. Instead the run measures how many hours of postings it actually read, and records the cap only when that span is under MIN_COVERAGE_HOURS (6) — the gap between scheduled scans, and the only case where postings could have slipped past unseen. Wider sweeps report the covered span as scan progress and finish clean. Three consecutive page failures abort the walk so a degraded API is not hammered.

Test coverage: src/lib/__tests__/himalayas-scanner.test.ts covers the pure helpers (payload sanitising, date and salary formatting, location mapping, title and preference filtering). src/lib/__tests__/himalayas-scan-run.test.ts covers runHimalayasScan itself with the network and importer mocked and the file write redirected to a temp directory: both sides of the page-cap reporting rule, the freshness-cutoff stop, the consecutive-failure abort, the two-step scan-file write, and an importer failure surfacing as an error result.

Data handling:

  • pubDate is UNIX epoch seconds, not milliseconds.
  • The API emits raw control characters inside JSON strings, which JSON.parse rejects. parseHimalayasPayload sanitises them before parsing.
  • locationRestrictions is a country array. Empty means unrestricted and maps to Remote; otherwise each country is emitted as <Country> (Remote) joined with ; so the preference filter’s multi-location splitting evaluates each one. Those countries are matched against the profile’s remote regions list at import, so a country outside it is dropped rather than written to the database. This lane sends no location to the API — it cannot filter server-side — which makes it the one most dependent on that import-time check.

Measured yield: a live run read 1,158 recent postings in ~28 seconds and matched 12. Three were ux substring false positives (“Lin-ux”, “BENEL-ux”) and two were EU-restricted remote roles; both classes are now filtered out upstream, leaving 7 genuine design roles.

Scan type recorded: himalayas-api-scan. Jobs appear with a Himalayas source badge.

Sources evaluated and rejected: Remotive and RemoteOK were tested and are not viable. Remotive’s entire public feed is 34 jobs spanning twelve unrelated categories with its filters ignored, of which one is Design. RemoteOK’s feed is 100 jobs, of which two match a design pattern and neither is a product/UX role. Both also carry link-back terms aimed at republishing sites. Neither is worth a lane.


Adzuna Job Aggregator (Direct API Scanner)

Adzuna is a job aggregator that indexes listings from many sources including Indeed, CareerBuilder, and direct employer feeds. Unlike browser-board scanning, Adzuna requires no browser or logged-in session — the app queries its public API directly.

How it works:

  1. Register at developer.adzuna.com for a free App ID and API Key (free tier: 2,000 queries/month)
  2. Paste both keys in Settings → AI Provider → Discovery & Aggregators
  3. Open Settings → Sources — the Job aggregators card appears at the bottom
  4. Click Scan with Adzuna; clicking opens a blocking ProgressModal (“Scanning Adzuna” + spinner). On completion the modal shows “Found N listings — X new, Y duplicates.” and a View N found jobs action that opens the refreshed Jobs page. The modal can also be dismissed with Close.
  5. New jobs enter the same import pipeline as browser-board scans — duplicate detection, title filtering, and source badges all apply

What it covers: Adzuna aggregates from multiple sources and covers roles that may not appear in direct ATS portals or browser-board searches. It is best used alongside browser-board and CareerOps ATS scans.

What it searches with: the positive title-filter keywords (getTitleFilters().positive), not the profile’s target roles. Adzuna’s title_only parameter ANDs every word of the query against the job title, so a target role written the way a person says it — “VP of User Experience and Web Management” — matches no posting anywhere. This is not hypothetical: the lane returned zero for three weeks in August–September 2026 because every configured role was a phrase of that shape. Short keywords (“product design”, “ux”) are the right input, and precision is not lost by querying broadly because buildTitleFilter re-applies the same positives and negatives to every result immediately afterwards. buildAdzunaSearchTerms derives the list: it prefers the positives, falls back to targetRoles when there is no positive filter at all, drops a keyword that only stems to one already kept (Adzuna stems, so product design and product designer return an identical result set), and caps the list at ADZUNA_MAX_SEARCH_TERMS.

Two lanes per term. buildAdzunaLanes returns the where values a term is searched under:

  • remote-only[""] — nationwide only. This branch previously sent where: "remote", which Adzuna geocodes to nowhere and answers with zero results, so the one preference that most needed a nationwide search was the one guaranteed to find nothing.
  • everything else → [<first preferred location>, ""] — the commute location and a nationwide pass.

The nationwide lane exists because Adzuna publishes no remote signal at all: there is no flag on a result, and location.display_name is always a geographic path, never “Remote”. Searching without a where and letting buildJobPreferenceFilter judge what comes back is the only route to a role open across a whole country. It is safe to import from because that filter already rejects out-of-state on-site postings while accepting a country-wide location: "US" via countryWideLocationGroup.

No distance parameter is sent, deliberately. Widening the radius looks like a fix — distance=500 turns 2 Tennessee results into 32 — but the extra 30 are in Michigan and Ohio. where=Tennessee already resolves the state correctly; the state is not mis-geocoded, the market is simply thin. A wider radius only fills the 50-result page with jobs the preference filter then discards, spending quota to import nothing.

Limits: ADZUNA_MAX_SEARCH_TERMS (4) × 2 lanes = 8 queries per scan, 50 results per query, and the selected fresh-posting window (24 hours, 72 hours by default, or 7 days). The term cap is set by the free tier, not by taste: 2,000 queries a month against roughly 210 scans (~7 a day) leaves a ceiling near 9.5 queries per scan, and 8 fits with room for a busy day. Raising the cap silently overruns the tier, and Adzuna answers an exhausted quota with a Retry-After measured in hours that ends the sweep. Adzuna’s coverage varies by country (default: us).

Telemetry: the onProgress callback is passed at all three call sites, so each query, its result count, and the lane it ran under are visible in the discovery progress modal and in the server log for the two Settings/API entry points. A scan that imports nothing still writes a scan_runs row (recordEmptyAdzunaScanRun) — both empty paths return before the importer, which is where that row is normally written, so a lane returning zero previously left no trace anywhere and “Adzuna found nothing” was indistinguishable from “Adzuna never ran”. AggregatorScanResult.preferenceFiltered carries the importer’s location-filter count out to ScanJobResultSummary.preferenceFilteredCount, shown on the scan summary as an N outside your locations badge.

Reliability: Adzuna searches retry transient gateway failures (429, 502, 503, 504) up to 3 times with exponential backoff, so a brief blip on Adzuna’s side no longer drops a whole search. Each attempt is bounded by a 15-second deadline — a hung request can no longer stall the rest of the scan. If three consecutive searches fail, the scan stops early and reports that Adzuna stopped responding rather than working through the remaining searches. If Adzuna asks for a long wait — either rate-limiting the account (its free tier is quota-based, so this can be hours) or reporting itself temporarily unavailable — the scan stops the whole sweep at that point and reports the wait, rather than stalling for it or spending further requests against a limit it already knows about. A search that fails on timeout is reported as Timed out on the scan results dialog; one that fails on a gateway error is reported as Other error.

Scan type recorded: adzuna-api-scan. Jobs appear in the Jobs table with an Adzuna source badge.

UI indicators on the Jobs table:

  • LinkedIn, Wellfound, Work at a Startup, Glassdoor, Indeed, Monster, Dice, Adzuna, or Himalayas badge (neutral gray) — source column — identifies jobs discovered via browser-board scans, MCP scans, or aggregator API scans
  • Manual badge (neutral gray) — source column — identifies jobs added manually via the Add Job modal
  • Duplicate badge (amber, clickable) — flagged jobs whose URL or company+title already existed in the database. Clicking the badge instantly filters the table to show only duplicate-flagged jobs. Clicking again clears the filter.
  • Source column — filterable and sortable; options are “LinkedIn”, “Wellfound”, “Work at a Startup”, “Glassdoor”, “Indeed”, “Monster”, “Dice”, “Adzuna”, “Himalayas”, “Manual”, and “Scanner”

URL behavior: Browser-board imports prefer a visible job-specific employer/ATS apply URL. If one is not available, the platform job URL is used and preserved as provenance.

Duplicate detection: Jobs are marked as possible duplicates (not dropped) when their original posting key, URL, or company+title+location matches an existing record. The user can review and act on flagged jobs normally.

Adzuna URL stability: Adzuna’s API returns session-scoped redirect URLs that include tracking tokens which change on every API call. The importer normalises these to a stable canonical URL (https://www.adzuna.com/land/ad/<id>) so the same job always maps to the same database record across scan runs, preventing previously-imported Adzuna jobs from appearing as new. The same stable ID is also used for within-scan deduplication when the same listing appears under multiple search queries.

Import notification: A fixed-bottom green alert appears on the Jobs page within 30 seconds of a completed import, showing the count of new jobs and duplicates. Auto-dismissed after 5 minutes.

Requirements: Claude Desktop with Claude in Chrome, or Codex with the Codex Chrome Extension. The user must already be logged into boards that require a session. Dice is the exception — it uses an MCP server (https://mcp.dice.com/mcp) and requires no browser extension or login.

Full documentation:

  • User guide: docs/linkedin-scanner-guide.md
  • Technical reference: docs/browser-board-scanner-technical.md
  • Agent instructions: CLAUDE.md and AGENTS.md

Shared UI Patterns

A reusable dialog shell used for form-entry and action-confirmation dialogs. It provides the overlay, dialog frame, header, scrollable body, optional sticky footer, Escape-key handling, and accessibility attributes so individual components don’t repeat that boilerplate.

Props: open, onClose?, title, description?, size? (“sm”|“md”|“lg”), sheet? (bottom-sheet on mobile, centered on desktop), children, footer?.

  • When onClose is provided, the X button appears in the header and Escape closes the modal. When omitted (e.g. during a pending submission) neither is active.
  • sheet produces items-end sm:items-center alignment with no padding on mobile and rounded-t-panel sm:rounded-panel corners — the standard bottom-drawer pattern.
  • Non-sheet modals are always centered with rounded-2xl.

Used by:

ComponentsizesheetPurpose
AddJobModallgAdd job manually form
EditJobModallgEdit job details form
GapAddressingPanelmdAddress gap / add detail (two-phase modal)
ProfileSupplementsEditormdFollow-up evidence detail per supplement

ProgressModal (src/components/ui/progress-modal.tsx)

A reusable blocking progress dialog used by all AI generation and long-running data-fetch actions. It wraps the same visual pattern established by the “Scan for new jobs” modal.

States:

  • Running (phase="running"): spinning border circle + title + primary message + optional statusLine (animated pulse, used for streaming labels like current research axis) + optional subtitle (smaller muted text). The backdrop is not clickable; the modal cannot be dismissed.
  • Done (phase="done"): shows children (success content) or an error string in a danger callout. An X button appears in the header and a Close button appears in the footer; clicking either or the backdrop closes the modal.

Props: open, phase, title, message, subtitle?, statusLine?, error?, children?, onClose.

Used by:

ActionTitleSuccess message
Evaluate N selected jobs”Evaluating N jobs""{N} evaluated successfully”
Verify active postings”Verifying active postings”Badge summary + “close to take action”
Extract profile with AI”Extracting profile with AI""{N} skills extracted”
Scan with Adzuna”Scanning Adzuna""Found N listings — X new, Y duplicates”
Start/Re-research”Researching company""Research complete — scroll down…”
Generate/Regenerate outreach”Generating outreach messages""3 messages ready — scroll down…”
Prepare application answers”Preparing application answers""Answers prepared — scroll down…”

Data and Privacy

  • All data is stored locally in data/job-search-terminal.sqlite on the user’s machine.
  • No data is sent to any server except AI provider API calls (evaluation, generation, etc.), job-source and liveness fetches, and the daily update check.
  • The update check is the only unprompted outbound request the app makes. Every other network call happens because the user pressed something. It sends one commit SHA and nothing else — no identifier, no usage data, no job or resume content. That SHA is the merge base of HEAD and the fetched remote branch, chosen precisely so the claim holds: it is provably a commit on the remote, never an unpushed local one. It runs at most once per 24 hours, caches its answer in data/update-check.json so restarts do not re-request, and never blocks a render: a page always draws the cached answer and refreshes in the background for the next load. Setting JST_UPDATE_CHECK=off (also false, 0, no) disables it completely; the version still renders, only the GitHub call stops. The footer states all of this in place rather than burying it here.
  • The database file is excluded from git.
  • Portable account backup: Account → Settings → Data & Backup creates a .jst-backup archive with the database, database-referenced resume lane files, generated documents, source configuration, and scanner history. Optional password protection encrypts the complete payload. Creation streams files into the archive and shows a ProgressModal (cycling through three phase labels) while the local snapshot is packaged. Other files under assets/ are always ignored.
  • Restore: Account → Settings → Data & Backup validates the archive, previews its contents in a bounded disk staging area, creates a rollback backup, then replaces the managed local snapshot after explicit confirmation.
  • Database-only backup: npm run data:backup writes a SQLite snapshot to output/backups/.
  • Export: npm run data:export writes a JSON snapshot to output/exports/.
  • To reset all data: npm run db:reset (drops local data and initializes an empty profile).

Ready to try it?

Job Search Terminal is free for non-commercial use and runs entirely on your own computer.