JobMatcha admin manual

One place for everything you've asked "how do I…?" about. Live tools at the top, written guides below. All endpoints here are admin-gated β€” your account must have users.is_admin = 1.

Test users + login as them

List of users. The Test only filter shows the throwaway users the test suite creates (test_*, uitest_*, dbg* at @example.com). For any user you can:

IDEmailNameAdminPremiumCreatedActions
Loading…

Regenerate Career Explorer reports

Rebuild stored premium reports in place from their saved answers β€” so testers see new report content (ventures, expanded recommendations, the tabbed visual report) without re-taking the quiz. Target a report share token or an email (or regenerate everything). A deterministic rebuild is instant and free; tick Use LLM prose to also re-polish the narrative (slower, uses tokens).

πŸ§ͺ Review batch β€” seeded reports for read-throughs

Seeded test people (@seed.jobmatcha.test) with full LLM reports. Links open the complete report without any login β€” send the lot to Vicky. Reseed/extend via tools/seed_review_batch.py.

Activation codes β€” free licences for partners & early users

Mint codes that unlock full access (same as an A$49.95 annual-pass purchase; codes grant 12 months) with no payment. Give one single-use code per person, or one multi-use code per partner with max uses = their allocation. Users redeem at /pricing.html (they need a free account first). Disable a code any time β€” already-redeemed users keep access.

Database browser read-only

Browse the live SQLite tables. Read-only β€” modifications need an explicit endpoint (use the user tools above for safe edits). Sensitive columns (password_hash, stripe_*_id) are truncated.

Email (SMTP) setup

JobMatcha sends two kinds of email: verify-email links at registration and password-reset links. Both are currently disabled β€” AUTO_VERIFY_USERS=1 in .env short-circuits the verify step, and resets only log to uvicorn-*.err.log instead of sending.

Step 1 β€” pick your sender pattern (support@ vs noreply@)

Pattern A β€” single mailbox support@ recommended for now

Send from [email protected]. Users who hit "Reply" land in the same inbox you read for support questions. Simpler β€” one mailbox to monitor, friendlier than noreply@ (which trains users to never reply, then they have nowhere to turn when stuck).

JobMatcha's verification + reset emails already include a footer that says "Questions? Just reply to this email" β€” so this pattern works out of the box.

Pattern B β€” noreply@ with Reply-To: support@

Looks more "official": From line reads noreply@, but if a user does reply, the mail still routes to support@ (via the Reply-To header). Best of both worlds, but requires two mailboxes. Set SMTP_FROM=...<[email protected]> and [email protected].

All the setup steps below use Pattern A. To switch to B, just add SMTP_REPLY_TO after changing SMTP_FROM.

Step 2 β€” pick an SMTP provider

Option A: Plesk Mail (already on this server β€” recommended)
  1. Plesk Panel β†’ Mail β†’ Create email address β†’ [email protected] with a strong password. (Optional: also create [email protected] if you want Pattern B.)
  2. Find SMTP host: Plesk Panel β†’ Tools & Settings β†’ Mail Server Settings (usually the server's hostname or localhost).
  3. Add to .env:
    SMTP_HOST=localhost
    SMTP_PORT=25
    [email protected]
    SMTP_PASSWORD=<the password you set in Plesk>
    SMTP_FROM=JobMatcha <[email protected]>
    
    # (Optional) Pattern B β€” keep SMTP_USER as the authenticated mailbox,
    # but show noreply@ as the visible From, with Reply-To routing back:
    # SMTP_FROM=JobMatcha <[email protected]>
    # [email protected]
  4. If port 25 is blocked, try 587 with STARTTLS or 465 with TLS.
  5. Read your inbox at jobmatcha.ai/webmail (or whichever path Plesk's mail UI uses on your install).
Option B: Gmail (easiest, free, 500/day limit)

Best when you don't have a domain mailbox yet. Replies go to your Gmail inbox.

  1. Enable 2-Step Verification on your Google account.
  2. Visit myaccount.google.com/apppasswords and create an app password labelled "JobMatcha". Google gives you a 16-character password β€” that's the SMTP password.
  3. Add to .env:
    SMTP_HOST=smtp.gmail.com
    SMTP_PORT=587
    [email protected]
    SMTP_PASSWORD=<the 16-char app password>
    SMTP_FROM=JobMatcha Support <[email protected]>
  4. Note: Gmail won't let you send as [email protected] from a personal address unless you set up "Send mail as" + DNS records first.
Option C: SendGrid / Mailgun / AWS SES (for production scale)

Sign up, verify your sending domain (jobmatcha.ai) with the DNS records they provide. Inbound replies still need a mailbox β€” typically you'd combine this with Plesk Mail for support@.

# SendGrid example
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=<your SendGrid API key>
SMTP_FROM=JobMatcha <[email protected]>
[email protected]

Step 3 β€” add to .env and restart

Edit C:\inetpub\vhosts\jobmatcha.ai\app\.env and add the SMTP_* lines from your chosen option. Then turn off auto-verify so registration actually requires email confirmation:

AUTO_VERIFY_USERS=0

Restart the API (elevated PowerShell):

& 'C:\inetpub\vhosts\jobmatcha.ai\app\restart-api.ps1'

Step 4 β€” test it

  1. Register a fresh account at /register.html.
  2. If you get the verification email β€” done. Reply to it from a different account β†’ confirm it lands in your support inbox.
  3. If no email arrives, tail the uvicorn err log:
    Get-Content 'C:\inetpub\vhosts\jobmatcha.ai\app\logs\uvicorn-*.err.log' -Tail 40
    Look for lines starting with [mailer]. SMTP send failed tells you the underlying error.

Common errors

Stripe + premium subscriptions

Two ways to make a user premium:

Quick (no Stripe) testing

Use the Grant premium button in the user list above. It writes status='active' into the subscriptions table. No money, no Stripe, no webhooks. Revoke any time.

Proper Stripe flow production

For end-to-end testing of the real upgrade flow (checkout page, webhook, cancellation). Setup below.

Step 1 β€” get your Stripe test keys

  1. Sign in to dashboard.stripe.com.
  2. Toggle the Test mode switch (top right) so you don't charge real cards.
  3. Go to Developers β†’ API keys β†’ copy the Secret key (starts with sk_test_...).

Step 2 β€” create a product + price

  1. Products β†’ Add product β†’ name JobMatcha Premium.
  2. Add a price: $10.00 monthly recurring (or whatever you charge). Click Add price.
  3. Copy the Price ID (looks like price_1Nxyz...).
  4. Optionally add a second price for annual β€” copy that ID too.

Step 3 β€” set up the webhook

Stripe needs to tell JobMatcha when a subscription starts/ends.

  1. Developers β†’ Webhooks β†’ Add endpoint.
  2. URL: https://jobmatcha.ai/api/stripe/webhook
  3. Events to listen for:
    • checkout.session.completed
    • customer.subscription.updated
    • customer.subscription.deleted
  4. Reveal the Signing secret (starts with whsec_...) β€” copy it.

Step 4 β€” add to .env and restart

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx
STRIPE_PRICE_ID_MONTHLY=price_1Nxyzxxxxx
STRIPE_PRICE_ID_ANNUAL=price_1Nabcxxxxx   # optional
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx

Restart the API: & 'C:\inetpub\vhosts\jobmatcha.ai\app\restart-api.ps1'

Step 5 β€” test the upgrade flow

  1. Log in as a non-premium user.
  2. Trigger an upgrade prompt (try to add a 2nd resume) β†’ click upgrade β†’ you should land on a real Stripe Checkout page.
  3. Use Stripe's test card: 4242 4242 4242 4242, any future expiry, any CVC, any postcode.
  4. After paying, Stripe sends the webhook to /api/stripe/webhook β†’ your subscriptions row flips to status='active' β†’ you're premium.

Other test cards

Local webhook delivery (optional)

If you're developing on this server but want Stripe events without exposing it publicly, install the Stripe CLI and forward:

stripe login
stripe listen --forward-to https://jobmatcha.ai/api/stripe/webhook

Running the test suite

19 tests (13 API + 6 UI Playwright). All against loopback uvicorn (http://127.0.0.1:8765).

# Both suites
.\run_tests.ps1

# API only (~1s)
.\run_tests.ps1 api

# UI only (~6s with Chromium)
.\run_tests.ps1 ui

# Single test
python -m pytest webapp/tests/test_api_smoke.py::test_application_status_patch_round_trip -v

First time: pip install -r webapp/tests/requirements-test.txt. Chromium is already installed for PDF rendering. Full docs: webapp/tests/README.md.

If tests skip with "login rate-limited"

You burned the 60/15min login limit (probably from repeated runs). Two fixes:

Deploy & service ops

Deploy from dev to production

cd C:\Development\JobHunter\AI4EMPLOYMENT
.\deploy.ps1

Copies static β†’ httpdocs\, Python β†’ app\webapp\, restarts the JobMatchaAPI scheduled task, smoke-tests /health.

Restart the API (without deploying)

# Normal (no elevation needed if no orphan)
Stop-ScheduledTask -TaskName JobMatchaAPI
Start-ScheduledTask -TaskName JobMatchaAPI

# If port 8765 is held by an orphan (deploy will warn): elevated
& 'C:\inetpub\vhosts\jobmatcha.ai\app\restart-api.ps1'

Check the API is up

curl http://127.0.0.1:8765/health
# OR via the public URL (Cloudflare):
# Just open https://jobmatcha.ai/api/health in a browser.

Logs

Get-ChildItem 'C:\inetpub\vhosts\jobmatcha.ai\app\logs' | Sort-Object LastWriteTime -Desc | Select -First 4
Get-Content 'C:\inetpub\vhosts\jobmatcha.ai\app\logs\uvicorn-*.err.log' -Tail 60

Plan limits (editable JSON)

Free-plan caps live in a JSON file you can edit any time β€” no restart, no deploy. The file is in data/ so it's preserved across deploys.

Location: C:\inetpub\vhosts\jobmatcha.ai\app\webapp\data\config.json

Default contents (auto-created if missing):

{
  "free_track_limit": 3,
  "free_interview_limit_per_month": 1,
  "free_monthly_llm_actions": 5,
  "_help": "Edit this file then refresh β€” changes apply on the next request, no restart needed."
}
KeyWhat it controls
free_track_limitMax number of resumes (tracks) a free user can create. Premium = unlimited.
free_interview_limit_per_monthMock interviews per calendar month for free users.
free_monthly_llm_actionsCombined match + tailor + onboarding-finish actions per month for free users.

Changes are picked up on the next API request (file mtime is checked per call). If the JSON is malformed, the API falls back to defaults and logs a warning β€” it won't crash.

Find Me Jobs β€” multi-source aggregator

The πŸ” Find Me Jobs feature queries multiple AU job-data sources in parallel, dedupes, and AI-ranks the union against the user's resume. Every result keeps its original source and apply URL so the user always follows the link to the real apply page β€” JobMatcha never wraps or proxies applications.

How the search-term inference works FAQ

Q: Does the user have to type a search term?

A: No β€” but they can. If the search-terms field is left blank, the system makes one cheap Claude Haiku call to read the resume and extract 3-5 distinctive keywords (role title + key skills). The user can override this by typing their own search terms β€” useful for "I want to pivot from Software Engineer to Data Analyst, find me Data Analyst jobs" where their resume isn't the right signal.

Will inference blow out the API budget?

Total cost per "Find me jobs" click: ~AUD $0.13 - $0.20 regardless of inference vs override.

The pipeline (6 steps)

  1. Pick resume β€” user picks which of their resumes to match against (or default).
  2. Build search query β€” either the user's typed terms OR one Haiku call to infer from the resume. Resolves to 3-5 keywords + a location (default "Australia").
  3. Fan out to sources β€” all configured sources queried in parallel. ACT RSS pulls the full feed; Workday pulls per-tenant CXS JSON; Adzuna does keyword search with what_or (any keyword matches).
  4. Dedupe β€” by source-prefixed ID first, then by (title, company) pair to catch the same role syndicated across multiple feeds.
  5. Cheap pre-rank β€” count how many resume keywords appear in each job description. Pure Python, no LLM. Per-source quotas guarantee diversity: max 20 from Adzuna + min 5 reserved each for ACT Gov + Workday so smaller feeds don't get crowded out.
  6. AI batch score β€” ONE Haiku call evaluates all 30 jobs in a single prompt and returns [{idx, score, why}]. Anything above the user's min match % is returned, sorted desc.

Sources currently wired live

SourceRegionTypeSetupCoverage strength
Adzuna (aggregator)AU nationalPaid API + free tierADZUNA_APP_ID + ADZUNA_APP_KEY in .envBroad commercial; aggregates from ~70-80% of public AU boards (Seek-sourced indirectly, Indeed, JobsBank, niche boards)
ACT GovernmentCanberraPublic RSSNone β€” out of the boxEvery ACT public-service vacancy. Genuinely under-aggregated by commercial feeds.
Workday β€” universitiesAU nationalPublic CXS JSONDefault 4 unis; add more via config.jsonDirect from each uni's ATS β€” fresher than Adzuna's crawl. Default tenants: USYD, Macquarie, UMelb, WSU.

Adding a new Workday university tenant

Most AU universities run Workday. Add to webapp/data/config.json (hot-reloads, no restart):

"workday_tenants": [
  {"slug": "usyd",    "host": "wd105", "site": "USYD_EXTERNAL_CAREER_SITE", "label": "The University of Sydney"},
  {"slug": "mq",      "host": "wd3",   "site": "CareersatMQ",               "label": "Macquarie University"},
  {"slug": "unimelb", "host": "wd105", "site": "UoM_External_Career",       "label": "The University of Melbourne"},
  {"slug": "wsu",     "host": "wd3",   "site": "WSUCareers",                "label": "Western Sydney University"}
]

Find slug/host/site from any Workday URL: https://{slug}.{host}.myworkdayjobs.com/{site}. Test in browser first β€” paste the URL, confirm jobs load.

Sources researched β€” full inventory

Honest map of every AU career site we evaluated. Wired = working today. Adzuna-covered = duplicated by Adzuna's crawl, so wiring directly would be redundant. Partnership-needed = path forward exists but requires a real conversation with the platform.

SourceStatusNotes
NSW / Federal Government
iworkfor.nsw.gov.auPartnership-neededCloudflare bot-blocked. Backend (Oracle Taleo) can serve RSS, NSW PSC hasn't enabled it. This is the big one for the funding pitch β€” get a data-sharing letter from NSW PSC.
apsjobs.gov.auPartnership-neededSalesforce-rendered, no public read API. Approach APSC as a data-sharing partner with attribution.
jobs.education.nsw.gov.au "JobFeed"Future scrapeWeekly HTML page, no feed. Could be scraped politely once Stuart's NSW Gov customer angle goes B2B.
tafensw.edu.au/careersFuture scrapeNo public feed found. HTML scrapeable.
workforceaustralia.gov.auPartnership-neededHas POSTING API for employers; no public READING API. DEWR data-sharing path possible.
Other AU state portals
jobs.act.gov.auWiredFree RSS. βœ…
careers.vic.gov.auAdzuna-coveredNo public feed. Adzuna's crawl picks up most cross-posts.
smartjobs.qld.gov.auAdzuna-coveredNGA.NET-hosted, no feed. Email alerts only.
search.jobs.wa.gov.auAdzuna-coveredBigRedSky, no feed.
iworkfor.sa.gov.auAdzuna-coveredBigRedSky, no feed.
jobs.tas.gov.auAdzuna-coveredPageUp, no feed.
jobs.nt.gov.auAdzuna-coveredCustom, no feed.
Sydney universities
USYD (sydney.edu.au)WiredWorkday CXS. βœ…
Macquarie (mq.edu.au)WiredWorkday CXS. βœ…
Western Sydney (westernsydney.edu.au)WiredWorkday CXS. βœ…
UNSW (unsw.edu.au)Future scrapePageUp, no public feed.
UTS (uts.edu.au)Future scrapeLikely PageUp.
Other AU universities
Melbourne (unimelb.edu.au)WiredWorkday CXS. βœ…
ANU (anu.edu.au)Future scrapePageUp.
Monash (monash.edu)VerifyCould not confirm ATS. Visit URL, look for Workday/PageUp redirect.
Wollongong, Newcastle, La Trobe, etc.Add to configMostly Workday β€” add slug to workday_tenants in config.json once verified.
Global free sources (footnote β€” focus on AU first)
Greenhouse / Workable / Lever / AshbyFutureEvery company on these ATSes has a free public JSON feed. Great for "I want to work at these 20 specific companies" curation. Not aggregator-friendly without a manual company list.
JSearch (RapidAPI)Future~$10/mo wraps Google for Jobs β€” covers LinkedIn/Indeed indirectly. Add if Adzuna coverage feels thin.

Funding pitch summary: Tier 1 (wired) covers ~80% of AU public jobs legally. Tier 2 (NSW PSC + APSC partnerships) is the next 15%. Tier 3 (state portal scraping with permission) is the final 5%. The story: "JobMatcha is the only AU job-seeker tool that combines AI-tailored matching with legally-aggregated public + government feeds, with active partnership conversations underway."

The unified job-hunt pipeline

Find Me Jobs, Match Reports, Tailor and Applications are now wired into one flow. The user starts at πŸ” Find Me Jobs with a cheap broad scan and progressively deepens engagement as they identify the role worth pursuing.

πŸ” Find Me Jobs         πŸ“Š Score Job         ✨ Tailor              πŸ“ Application
─────────────────  β†’  ─────────────  β†’  ─────────────────  β†’  ─────────────
~AUD $0.001/job        ~AUD $0.10           ~AUD $0.05            $0
Haiku batch score      Sonnet full          Sonnet tailored       saved row
+ "why" 1-sentence     match report:        resume + cover        + cover letter
                       gaps,                + tailoring notes     + description copy
                       interview prep,                            + match-report ref
                       application
                       strategy

Each step is opt-in per row. A user can scan 20 jobs cheaply, run Score Job on the 3 most interesting, tailor the 1 they're going to apply to. No wasted LLM spend on jobs the user wouldn't have applied to anyway.

Why the two scores differ

The Find Me Jobs score (Haiku) and the full Score Job score (Sonnet) often disagree by 5-15 points. That's expected:

Job description truncation β€” Adzuna's free-tier gotcha

Adzuna's free tier truncates job descriptions at ~1500 chars (ends with "..."). Without intervention, this propagates through the whole chain β€” the Match Report uses the excerpt, and the Tailor uses the excerpt. The output is still useful but misses keywords the full posting asks for.

The fix: the Match Report side panel detects truncation and shows a yellow callout with a "✏ Paste full description" button. The user opens the original posting in another tab, copies the full description, pastes it in. The endpoint POST /me/match-reports/{folder}/jd overwrites the saved jd.txt. The Tailor flow reads it on click-through, so the update propagates downstream automatically β€” no re-analysis required. The Tailor page also shows a truncation warning so the user gets a second chance to upgrade before tailoring. Better still: install the πŸ“‹ Bookmarklet (below) to capture the full posting on the source site in one click.

The data lineage

Find Me Jobs row            Match Report folder          Tailored folder        applications.csv
────────────────────  β†’  ──────────────────────────  β†’  ───────────────────  β†’  ─────────────────
job.description           MATCH_REPORTS//      JOB_SPECIFIC_RESUMES/    user_id + folder
(truncated)                 β”œ jd.txt    ← editable      /                + from_match_report
job.apply_url               β”œ match_result.json         β”œ resume_tailored.md       column links back
job.score (Haiku)           β”œ match_report.md           β”œ cover_letter_tailored
job.source/source_label     β”œ match_report.pdf          β”œ jd.txt
                            ↓                           β”œ tailoring_notes.md
                  saved into match_history.csv          β”œ _match_report.md (copy)
                  (user_id, folder, score, engine)      ↓
                                                  saved into applications.csv
                                                  (from_match_report = folder)

Any downstream tool can walk back the chain: an application row points at the match report folder it was tailored from; the match report folder points at the resume track used; the user owns all three via user_id.

Find Me Jobs β€” search history

Every search is persisted to a new job_searches table (most-recent 50 per user; older auto-trimmed). Two UI surfaces:

The dropdown is also the per-user audit log β€” if a user complains "I had 12 matches yesterday and now I have 4", they can reload yesterday's search to compare.

Get your Adzuna credentials

  1. Sign up at developer.adzuna.com.
  2. Register an app β†’ receive an app_id + app_key.
  3. Paste both into .env; restart elevated; done.
  4. Free tier: 250 calls/month, 1000/day. For production volume, contact Adzuna sales.

Get your API credentials

  1. Sign up at developer.adzuna.com.
  2. Register an app. You'll receive an app_id + app_key pair.
  3. Note the free tier rate limits (Adzuna doesn't publish them β€” they vary by application). For production volume, contact their sales.

Add to .env

ADZUNA_APP_ID=your_app_id_here
ADZUNA_APP_KEY=your_app_key_here

Restart the API: & 'C:\inetpub\vhosts\jobmatcha.ai\app\restart-api.ps1' (elevated).

Cost per click (approx.)

How the pipeline works

  1. Haiku reads the user's resume β†’ returns a short search query (role + skills).
  2. Adzuna search hits up to 50 fresh AU job ads matching that query.
  3. Cheap keyword-overlap pre-filter cuts to the top 20 candidates.
  4. Haiku scores those 20 in a single batched call: { idx, score, why } per job.
  5. Results above the user's min match % threshold are shown, ranked.

Each "Find me jobs" click counts as one match action against the user's free-tier monthly quota (free_monthly_llm_actions in config.json).

πŸ“‹ Bookmarklet β€” capture full job descriptions in one click

End-users: the same drag-installable link is shown directly on the user app at /app.html#/jd-clipboard (sidebar β†’ πŸ“‹ Capture a posting). This admin section is the reference doc.

The bookmarklet solves the truncated job description problem end-to-end. Adzuna's free tier caps job descriptions at ~1,500 characters, and server-side scraping (Seek, LinkedIn, Indeed) is blocked by Cloudflare ~30-40% of the time and is legally murky anyway. The bookmarklet runs in your browser on a page you're already viewing β€” so it's the user, not JobMatcha, doing the read. Zero ToS risk, zero scraping cost, and it works on every site including Cloudflare-protected ones.

How it works (one paragraph)

You drag the link below into your browser's bookmarks bar. When you're on any job posting page β€” Seek, LinkedIn, Indeed, a careers page, anywhere β€” click the bookmark. It extracts the visible text, packages it with the page title, source URL, and timestamp, writes that JSON to your clipboard, and opens JobMatcha at #/jd-clipboard. JobMatcha asks for clipboard permission, parses the payload, shows you a preview, and runs Score Job against your chosen resume β€” full posting, no truncation, no scraping.

Install β€” drag this to your bookmarks bar

πŸ“‹ Send to JobMatcha

Drag the green button up to your bookmarks bar.
If the bar isn't visible: press Ctrl+Shift+B (Windows/Linux) or ⌘+Shift+B (Mac).

Use it in 3 steps

  1. Open a job posting in your browser β€” Seek, LinkedIn, Indeed, a company careers page, anywhere. Make sure the full description is visible on the page (some sites hide it behind a "Show more" button β€” click that first).
  2. Click the Send to JobMatcha bookmark in your bookmarks bar. The page won't visibly change but you'll briefly see a new tab open to JobMatcha. (If a permission prompt appears, "Allow" β€” it needs to write the job description to your clipboard.)
  3. In the new JobMatcha tab at #/jd-clipboard, click Read from clipboard. You'll see the captured title, company, and full job description text. Pick the resume you want to match against and hit Score Job. A new Match Report opens with gap analysis, interview questions, and a strategy β€” all from the full posting, not a truncated excerpt.

Why we built this (vs. the alternatives) FAQ

ApproachVerdictWhy
Paid scraper API (ScrapingBee, Bright Data)No~AUD $0.05-0.15 per job. Same Cloudflare blocks server-to-server. Ongoing litigation around scraping at-will. We'd be the legal actor.
Build our own headless-Chromium scraperNo2-3 weeks of work, ongoing maintenance as sites change, same legal exposure, and we'd be paying the egress + RAM.
Browser extension (Chrome Web Store)Tier 2Better UX (no clipboard step) but needs Chrome Web Store approval + Manifest V3 work. Defer until we have paying users.
Bookmarklet (this)Shipped1 day of work, zero cost, works on every site (Cloudflare can't see it β€” it runs in your already-authenticated browser session). User is the actor, no ToS exposure for JobMatcha.

Troubleshooting

The bookmarklet does nothing when I click it

Most likely you're on a page that has very little text (a search results page, not a job-detail page). The bookmarklet refuses to fire below 150 chars and will alert you. Open an actual job posting and try again.

JobMatcha opens but says "Clipboard read denied"

Your browser is blocking clipboard reads. Click the πŸ”’ padlock in the address bar β†’ Site settings β†’ set Clipboard to "Allow". Reload and click "Read from clipboard" again. Alternatively, open the Manual paste fallback and paste the job description into the textarea.

The captured text has extra navigation/footer junk in it

The bookmarklet prefers <main> / <article> / .job-details selectors first; only if none of those exist does it fall back to the whole <body>. Some sites (Seek, LinkedIn) wrap their content well; smaller sites may not. Claude is fine ignoring nav/footer noise β€” it adds a few cents to the LLM call at most.

I want to change where the bookmarklet sends the job description

The target URL is hardcoded to https://jobmatcha.ai/app.html#/jd-clipboard?from=bookmarklet inside the bookmarklet's window.open call. To point at a staging server, edit the link's href in this admin.html file and re-drag. The bookmarklet doesn't update itself β€” if you change it, anyone using the old one keeps using the old URL until they drag the new one.

Privacy & data flow

🎭 Mock-Interview AI Avatar β€” build guide

Step-by-step plan for replacing the existing 🎀 icon at the top of the Mock Interview page with an animated avatar that looks like an interviewer. Goes from "free + ships today" through to "polished + lip-synced". You don't need all four β€” pick the tier that fits your demo timeline and budget.

How the question mix already works live

Before the avatar, what's the conversation: the question generator (/me/interviews/{id}/questions, see webapp/api.py_INTERVIEW_QUESTIONS_SYSTEM) calls Sonnet with the job description + the user's resume + the match-report gaps. It returns 5–8 questions across four flavours:

The avatar work below is presentation, not content. The questions don't change.

Tier 1 β€” Lottie animation + existing TTS Recommended start

Cost: AUD $0 Β· Build time: half a day Β· Looks like: a clean cartoon-ish animated character that idles, blinks, occasionally tilts head. Speech via browser TTS.

  1. Browse lottiefiles.com. Search "interviewer", "avatar talking", "person at desk". Filter to Free. Download a .json file (Lottie JSON is small β€” usually 20–80 KB).
  2. Drop it into webapp/static/avatars/interviewer.json.
  3. Add the Lottie web player to app.html: <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/player/lottie.min.js"></script>
  4. In the interview page (around #iv-stage in app.html) replace the 🎀 emoji with a 200Γ—200 <div id="iv-avatar"></div>. Mount with lottie.loadAnimation({ container, path: '/static/avatars/interviewer.json', loop: true, autoplay: true }).
  5. Wire to TTS state: pause the lottie when TTS is silent, play when speaking. The existing _ivSpeak() in app.js has the start/end hooks you need β€” it already calls onDone after speech finishes.

Trade-off: no real lip-sync β€” the mouth moves to the animation loop, not to the syllables. Most demo audiences don't notice; close-up users will. Ship this first, upgrade later.

Tier 2 β€” Pre-recorded human video loops + TTS

Cost: AUD $0 if you record yourself, or ~$5 stock-footage one-off Β· Build time: half a day + recording Β· Looks like: a real human interviewer at a desk, blinking, looking around. Speech still via TTS so lip-sync is faked.

  1. Record (or download) three 5-second clips:
    • idle.mp4 β€” interviewer looking at notes, occasional glance up
    • thinking.mp4 β€” slight nod, eyebrow raise (plays during your answer)
    • speaking.mp4 β€” talking, looking at camera (plays while TTS reads the question β€” looks vaguely lip-synced because mouth moves)
    Free stock-footage sources: Pexels, Pixabay (Pexels licence is broad; Pixabay is permissive for commercial use).
  2. Save them under webapp/static/avatars/.
  3. Add a <video> element with autoplay muted loop playsinline. JS swaps src between the three clips based on interview state (asking question / waiting for answer / processing).

Trade-off: looks dramatically more human than Lottie but the seam between clips is visible on slower devices. Pre-load all three with video.preload="auto".

Tier 3 β€” Pre-rendered HeyGen / D-ID question bank

Cost: AUD $80–150 one-off (50 generic questions Γ— ~$2 each) Β· Build time: 2–3 days (build + curation) Β· Looks like: polished avatar with real lip-sync on a curated set of questions.

  1. Sign up at HeyGen (free trial gives ~3 min, then ~USD $24/month for 15 min/month).
  2. Pick an avatar (their stock library has neutral interviewers; or pay extra for a custom avatar trained from a photo of you).
  3. Pre-generate 50 of the most common interview questions across the four flavours above. Save each as q_001.mp4, q_002.mp4, etc. with a JSON index mapping each video to its text.
  4. At runtime, the LLM still generates per-user questions; for each question, do a cheap cosine-similarity match against the question-bank index (use OpenAI embeddings or the small Voyage-Lite model β€” ~$0.0001 per match). Play the closest pre-rendered video. Fall back to Tier 1/2 if no match scores above 0.75.

Trade-off: every demo-day question that matches the bank looks magical; out-of-bank questions revert to non-polished. Coverage feels uneven. Best as a hybrid with Tier 1.

Tier 4 β€” Live HeyGen / D-ID API at session-time

Cost: ~AUD $0.30–1.00 per minute of generated video (D-ID is cheaper; HeyGen is glossier) Β· Build time: 1 day for API integration, ongoing maintenance Β· Looks like: indistinguishable from a Zoom call with a real person.

  1. Sign up at D-ID or use HeyGen's streaming API.
  2. When the LLM generates a question, POST it to D-ID with { text, voice, avatar_id }. D-ID returns a streaming MP4 URL within 3–8s.
  3. Browser plays the MP4 inline. Answer phase shows a held-frame from a separate "listening" clip.
  4. Add a backend cache: same question text β†’ re-use the same MP4 (saves money for repeat users).

Trade-off: real money per session. For a free-tier user doing 5 questions Γ— ~20s each = 100s of video = ~AUD $0.50–1.50 per session. Sustainable only behind a paywall.

Recommended path forward

  1. This week (demo prep): Tier 1 β€” Lottie. Half a day's work, looks intentional, costs nothing, ships now.
  2. After demo (early users): Tier 2 β€” pre-recorded human loops. Free upgrade if you record yourself; or ~$5 stock-footage if you'd rather not.
  3. When you have ~50 paying users: Tier 4 β€” live D-ID. Gate behind premium tier. The polish becomes a paywall feature.

Skip Tier 3 unless you find it ships faster than 4 in your context β€” the hybrid approach adds complexity and the out-of-bank fallback breaks the illusion.

Where the avatar lives in the codebase

WhatWhere
Avatar mount point (currently the 🎀 emoji)app.html#iv-stage top & #iv-intro top
TTS start/end hooksapp.js_ivSpeak(text, onDone)
Question generation (Sonnet)api.py_INTERVIEW_QUESTIONS_SYSTEM + /me/interviews/{id}/questions
Static avatar assets (drop new files here)webapp/static/avatars/  create this folder
Cache-buster bump after assets changeapp.html → search for v= & increment

Env vars & secrets reference

Single file: C:\inetpub\vhosts\jobmatcha.ai\app\.env. After editing, restart the API or the new values won't be picked up.

VarRequired?What it does
ANTHROPIC_API_KEYYesClaude API key β€” powers AI parsing, tailoring, Smart Onboarding.
PUBLIC_BASE_URLYeshttps://jobmatcha.ai β€” used in email links.
AUTO_VERIFY_USERSNo (defaults 0)1 skips email verification at register. Useful before SMTP is configured.
SMTP_HOST / PORT / USER / PASSWORD / FROMNoOutbound email. If SMTP_HOST unset, messages are logged instead. SMTP_USER is the authenticated mailbox; SMTP_FROM is the visible sender (defaults to SMTP_USER).
SMTP_REPLY_TONoOptional. Use with a noreply@ SMTP_FROM to route user replies to support@ instead. See the Email section.
STRIPE_SECRET_KEYFor billingsk_test_* or sk_live_* β€” enables the upgrade flow.
STRIPE_PRICE_ID_MONTHLYFor billingStripe Price ID for the monthly plan.
STRIPE_PRICE_ID_ANNUALOptionalStripe Price ID for the annual plan (toggle on pricing page).
STRIPE_WEBHOOK_SECRETFor billingwhsec_* β€” verifies Stripe β†’ JobMatcha webhook signatures.
JM_DISABLE_RATELIMITNo1 turns off the per-IP login/register limiter. Set for test runs only.
ADZUNA_APP_ID / ADZUNA_APP_KEYFor πŸ” Find Me JobsAdzuna API credentials from developer.adzuna.com. Without these, the Find Me Jobs page shows a configure message.