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:
- Reset password β set a known password, then log in via /login.html with their email + that password.
- Impersonate β sets your browser's session cookie to theirs. Open /app.html in the same tab and you're them. To switch back, log out and log in as yourself.
- Grant / Revoke premium β toggles the subscriptions row without going through Stripe. For local testing only.
- Delete β hard delete (refuses admins and yourself).
| ID | Name | Admin | Premium | Created | Actions | |
|---|---|---|---|---|---|---|
| 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)
- Plesk Panel β Mail β Create email address β
[email protected]with a strong password. (Optional: also create[email protected]if you want Pattern B.) - Find SMTP host: Plesk Panel β Tools & Settings β Mail Server Settings (usually the server's hostname or
localhost). - 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] - If port 25 is blocked, try 587 with STARTTLS or 465 with TLS.
- 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.
- Enable 2-Step Verification on your Google account.
- Visit myaccount.google.com/apppasswords and create an app password labelled "JobMatcha". Google gives you a 16-character password β that's the SMTP password.
- 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]> - 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
- Register a fresh account at /register.html.
- If you get the verification email β done. Reply to it from a different account β confirm it lands in your support inbox.
- If no email arrives, tail the uvicorn err log:
Look for lines starting withGet-Content 'C:\inetpub\vhosts\jobmatcha.ai\app\logs\uvicorn-*.err.log' -Tail 40[mailer].SMTP send failedtells you the underlying error.
Common errors
- Connection refused / timed out β your provider's port is blocked (often port 25 from cloud hosts). Try 587 or 465.
- Authentication failed β wrong user/password. Gmail needs an app password, not your normal Google password.
- Relay denied β Plesk Mail requires
SMTP_USERto be an actual mailbox on this server, and theFromdomain to match. - Emails land in spam β set up SPF + DKIM + DMARC for jobmatcha.ai. Plesk has one-click toggles under Mail β Mail Server Settings β Outgoing Mail Control.
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
- Sign in to dashboard.stripe.com.
- Toggle the Test mode switch (top right) so you don't charge real cards.
- Go to Developers β API keys β copy the
Secret key(starts withsk_test_...).
Step 2 β create a product + price
- Products β Add product β name
JobMatcha Premium. - Add a price:
$10.00monthly recurring (or whatever you charge). Click Add price. - Copy the Price ID (looks like
price_1Nxyz...). - 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.
- Developers β Webhooks β Add endpoint.
- URL:
https://jobmatcha.ai/api/stripe/webhook - Events to listen for:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deleted
- 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
- Log in as a non-premium user.
- Trigger an upgrade prompt (try to add a 2nd resume) β click upgrade β you should land on a real Stripe Checkout page.
- Use Stripe's test card:
4242 4242 4242 4242, any future expiry, any CVC, any postcode. - After paying, Stripe sends the webhook to
/api/stripe/webhookβ yoursubscriptionsrow flips tostatus='active'β you're premium.
Other test cards
4000 0000 0000 9995β declined (insufficient funds)4000 0025 0000 3155β requires 3D Secure authentication- Full list: stripe.com/docs/testing
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:
- Wait 15 minutes.
- OR add
$env:JM_DISABLE_RATELIMIT = '1'tostart_uvicorn.ps1and runrestart-api.ps1elevated.
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."
}
| Key | What it controls |
|---|---|
free_track_limit | Max number of resumes (tracks) a free user can create. Premium = unlimited. |
free_interview_limit_per_month | Mock interviews per calendar month for free users. |
free_monthly_llm_actions | Combined 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?
- Query-generation call: ~AUD $0.001 per click (1 Haiku call, ~500 tokens in, ~50 out). Negligible.
- If the user types their own terms, this call is skipped entirely β saves the LLM roundtrip.
- The expensive part is the batch-scoring step (Step 5 below) regardless of how the query was generated. That dominates cost at ~AUD $0.12-0.18 per click.
Total cost per "Find me jobs" click: ~AUD $0.13 - $0.20 regardless of inference vs override.
The pipeline (6 steps)
- Pick resume β user picks which of their resumes to match against (or default).
- 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").
- 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). - Dedupe β by source-prefixed ID first, then by (title, company) pair to catch the same role syndicated across multiple feeds.
- 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.
- 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
| Source | Region | Type | Setup | Coverage strength |
|---|---|---|---|---|
| Adzuna (aggregator) | AU national | Paid API + free tier | ADZUNA_APP_ID + ADZUNA_APP_KEY in .env | Broad commercial; aggregates from ~70-80% of public AU boards (Seek-sourced indirectly, Indeed, JobsBank, niche boards) |
| ACT Government | Canberra | Public RSS | None β out of the box | Every ACT public-service vacancy. Genuinely under-aggregated by commercial feeds. |
| Workday β universities | AU national | Public CXS JSON | Default 4 unis; add more via config.json | Direct 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.
| Source | Status | Notes |
|---|---|---|
| NSW / Federal Government | ||
| iworkfor.nsw.gov.au | Partnership-needed | Cloudflare 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.au | Partnership-needed | Salesforce-rendered, no public read API. Approach APSC as a data-sharing partner with attribution. |
| jobs.education.nsw.gov.au "JobFeed" | Future scrape | Weekly HTML page, no feed. Could be scraped politely once Stuart's NSW Gov customer angle goes B2B. |
| tafensw.edu.au/careers | Future scrape | No public feed found. HTML scrapeable. |
| workforceaustralia.gov.au | Partnership-needed | Has POSTING API for employers; no public READING API. DEWR data-sharing path possible. |
| Other AU state portals | ||
| jobs.act.gov.au | Wired | Free RSS. β |
| careers.vic.gov.au | Adzuna-covered | No public feed. Adzuna's crawl picks up most cross-posts. |
| smartjobs.qld.gov.au | Adzuna-covered | NGA.NET-hosted, no feed. Email alerts only. |
| search.jobs.wa.gov.au | Adzuna-covered | BigRedSky, no feed. |
| iworkfor.sa.gov.au | Adzuna-covered | BigRedSky, no feed. |
| jobs.tas.gov.au | Adzuna-covered | PageUp, no feed. |
| jobs.nt.gov.au | Adzuna-covered | Custom, no feed. |
| Sydney universities | ||
| USYD (sydney.edu.au) | Wired | Workday CXS. β |
| Macquarie (mq.edu.au) | Wired | Workday CXS. β |
| Western Sydney (westernsydney.edu.au) | Wired | Workday CXS. β |
| UNSW (unsw.edu.au) | Future scrape | PageUp, no public feed. |
| UTS (uts.edu.au) | Future scrape | Likely PageUp. |
| Other AU universities | ||
| Melbourne (unimelb.edu.au) | Wired | Workday CXS. β |
| ANU (anu.edu.au) | Future scrape | PageUp. |
| Monash (monash.edu) | Verify | Could not confirm ATS. Visit URL, look for Workday/PageUp redirect. |
| Wollongong, Newcastle, La Trobe, etc. | Add to config | Mostly Workday β add slug to workday_tenants in config.json once verified. |
| Global free sources (footnote β focus on AU first) | ||
| Greenhouse / Workable / Lever / Ashby | Future | Every 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:
- Find Me Jobs uses Haiku in a single batched prompt where 20 job descriptions compete for attention. It's calibrated as a fast triage ("is this worth a closer look?").
- Score Job uses Sonnet with a single job description and full attention. It produces gaps, application strategy, and interview prep β and a more considered score.
- When the report side panel detects a previous Find score (via sessionStorage from the click-through), both numbers are shown side-by-side with a one-line explanation. The Score Job result is the considered opinion.
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:
- Session cache β the most recent search payload is cached in browser sessionStorage. Navigate away from Find Me Jobs and back β results restore instantly with no API call. Lost on browser-close.
- "Past searches" dropdown β collapsible panel above the search form. Lists all 50 persisted searches with query, location, result count, source mix, and timestamp. Each row has a Reload button that re-renders the saved snapshot (no re-run, no LLM cost) and a π to delete from history.
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
- Sign up at developer.adzuna.com.
- Register an app β receive an
app_id+app_key. - Paste both into
.env; restart elevated; done. - Free tier: 250 calls/month, 1000/day. For production volume, contact Adzuna sales.
Get your API credentials
- Sign up at developer.adzuna.com.
- Register an app. You'll receive an
app_id+app_keypair. - 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.)
- 1 Adzuna search call: free within their tier; check live limits on dashboard
- 1 Claude Haiku 4.5 call to extract search query: ~AUD $0.001
- 1 Claude Haiku 4.5 call to batch-score top 20 jobs: ~AUD $0.08-0.15 (scales with resume length)
- Total: ~AUD $0.10-0.15 per "Find me jobs" click
How the pipeline works
- Haiku reads the user's resume β returns a short search query (role + skills).
- Adzuna search hits up to 50 fresh AU job ads matching that query.
- Cheap keyword-overlap pre-filter cuts to the top 20 candidates.
- Haiku scores those 20 in a single batched call:
{ idx, score, why }per job. - 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
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
- 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).
- 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.)
- 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
| Approach | Verdict | Why |
|---|---|---|
| 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 scraper | No | 2-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 2 | Better UX (no clipboard step) but needs Chrome Web Store approval + Manifest V3 work. Defer until we have paying users. |
| Bookmarklet (this) | Shipped | 1 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
- The bookmarklet runs entirely in your browser. The page you're on does not know you clicked it.
- The JSON payload (description text + title + URL + timestamp) goes to your clipboard only. Nothing is sent to a server during the capture step.
- The payload is sent to JobMatcha's API only when you click Score Job on the
#/jd-clipboardpage β same data flow as any other Score Job action. - The clipboard JSON includes
source_url(the page you captured from). It's stored on your match report so you can re-open the original posting later.
π 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:
- General / opener ("Walk me through your background")
- Behavioural / STAR ("Tell me about a time youβ¦") β biased toward situations the resume hints at
- Technical / domain β drawn from JD-listed skills (e.g. Python, SQL, project management, classroom management)
- JD-specific / gap-probing β targeting the resume gaps the match report flagged, so the user gets to rehearse the hardest questions an interviewer would ask
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.
- Browse lottiefiles.com. Search "interviewer", "avatar talking", "person at desk". Filter to Free. Download a
.jsonfile (Lottie JSON is small β usually 20β80 KB). - Drop it into
webapp/static/avatars/interviewer.json. - Add the Lottie web player to
app.html:<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/player/lottie.min.js"></script> - In the interview page (around
#iv-stageinapp.html) replace the π€ emoji with a 200Γ200<div id="iv-avatar"></div>. Mount withlottie.loadAnimation({ container, path: '/static/avatars/interviewer.json', loop: true, autoplay: true }). - Wire to TTS state: pause the lottie when TTS is silent, play when speaking. The existing
_ivSpeak()inapp.jshas the start/end hooks you need β it already callsonDoneafter 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.
- Record (or download) three 5-second clips:
idle.mp4β interviewer looking at notes, occasional glance upthinking.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)
- Save them under
webapp/static/avatars/. - Add a
<video>element withautoplay muted loop playsinline. JS swapssrcbetween 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.
- Sign up at HeyGen (free trial gives ~3 min, then ~USD $24/month for 15 min/month).
- Pick an avatar (their stock library has neutral interviewers; or pay extra for a custom avatar trained from a photo of you).
- 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. - 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.
- Sign up at D-ID or use HeyGen's streaming API.
- 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. - Browser plays the MP4 inline. Answer phase shows a held-frame from a separate "listening" clip.
- 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
- This week (demo prep): Tier 1 β Lottie. Half a day's work, looks intentional, costs nothing, ships now.
- 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.
- 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
| What | Where |
|---|---|
| Avatar mount point (currently the π€ emoji) | app.html → #iv-stage top & #iv-intro top |
| TTS start/end hooks | app.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 change | app.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.
| Var | Required? | What it does |
|---|---|---|
| ANTHROPIC_API_KEY | Yes | Claude API key β powers AI parsing, tailoring, Smart Onboarding. |
| PUBLIC_BASE_URL | Yes | https://jobmatcha.ai β used in email links. |
| AUTO_VERIFY_USERS | No (defaults 0) | 1 skips email verification at register. Useful before SMTP is configured. |
| SMTP_HOST / PORT / USER / PASSWORD / FROM | No | Outbound 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_TO | No | Optional. Use with a noreply@ SMTP_FROM to route user replies to support@ instead. See the Email section. |
| STRIPE_SECRET_KEY | For billing | sk_test_* or sk_live_* β enables the upgrade flow. |
| STRIPE_PRICE_ID_MONTHLY | For billing | Stripe Price ID for the monthly plan. |
| STRIPE_PRICE_ID_ANNUAL | Optional | Stripe Price ID for the annual plan (toggle on pricing page). |
| STRIPE_WEBHOOK_SECRET | For billing | whsec_* β verifies Stripe β JobMatcha webhook signatures. |
| JM_DISABLE_RATELIMIT | No | 1 turns off the per-IP login/register limiter. Set for test runs only. |
| ADZUNA_APP_ID / ADZUNA_APP_KEY | For π Find Me Jobs | Adzuna API credentials from developer.adzuna.com. Without these, the Find Me Jobs page shows a configure message. |