Artemis
A personalized job-search assistant: tracking, networking, tailored résumés and interview prep, built because I was applying to hundreds of roles and tracking none of them.
- Role
- Solo
- Period
- Mar 2026 – present
- Stack
- FastAPI, Next.js, PostgreSQL (Supabase), pgvector, Procrastinate, vLLM, Model Context Protocol, Plasmo (Chrome MV3), Expo
Where it came from
Most people applying widely track it in a spreadsheet, and the very organized few use Notion. Either way, past a few dozen applications the upkeep of changing statuses and re-sorting gets tedious enough that the tracker is abandoned, and the first reminder that you applied somewhere becomes the rejection email.
So the first version was a tracker that does what a spreadsheet can’t. Paste a posting’s URL and the entry fills itself from the posting: company, role, location, pay, requirements and the full text, so you can always reconstruct what the job was.
Networking came next and turned out to be where most of the value is. The tracker is the hook; the contacts are what keep people using it. Someone met at a career fair is saved with every way to reach them and the dates that matter to them, and every interaction is logged, so Artemis can summarize the relationship and draft the follow-up.
Then the applications themselves. Every application asks for the same information, sometimes in a different format and often in exactly the same one, and typing it in again took around ten minutes each. Applying widely is the only way to have a chance in this market, so that time adds up fast. With the browser extension, almost every field is filled in seconds. I check what it entered and sign, and the résumé and cover letter are tailored to that posting and attached in the same pass. An application now takes a fraction of the time and effort, and each one fits the role better than what I could write by hand at that volume.
What it does
A user uploads their résumé. Artemis breaks it into a structured pool of their actual experience, scores live postings against it, rewrites the résumé and drafts a cover letter for a specific role, tracks the application to an outcome, and reads their mailbox for status changes and new contacts along the way.
- The résumé pool. Every uploaded résumé is broken into individually addressable entries and bullets that remember which document they came from. Uploads from design tools with no text layer fall back to OCR. Repeated uploads merge rather than duplicate. Everything downstream draws from this pool.
- Matching. A fit score for any posting, a Jobs tab ranked against the user’s profile, a free estimate on any posting opened in the browser, and story suggestions for interview prep.
- Résumé and cover letter tailoring, generated from the pool and rendered through the open-source renderer.
- Application tracker, as a table or a board.
- Networking CRM. Contacts saved with every way to reach them, from a business card at a career fair to a LinkedIn profile. The dates that matter to each person, with reminders. A log of every interaction, which Artemis summarizes and uses to draft follow-ups, individually or in bulk, and to send calendar invites.
- Inbox pipeline. Gmail and Outlook push notifications, read in tiers.
- Interview prep. Behavioural and role-specific questions for a given application, with the user’s own stories suggested against the role.
- Command bar. Answers questions from the user’s own data. Its response schema has no action fields, so no prompt can make it take an action.
- Chrome extension. Assisted apply: it fills an application in seconds across nine ATS form mappers and attaches a résumé and cover letter tailored to that posting, and the user reviews and signs. Bulk runs survive the browser unloading the extension mid-run by checkpointing progress.
One developer across a web app, a FastAPI backend, a background worker, a Chrome extension and a mobile client.
Matching
Fit scoring. Skills, industry and experience are scored deterministically in code. The model writes the rationale and a headline score from 0 to 5, and a check compares that headline with the weighted factors (45% skills, 35% experience, 20% industry) and logs any gap over 0.35. The one place the server changes a score is a missing required degree: the experience factor is capped, and the headline can go no higher than the capped formula allows. The band the user sees is computed from the score in code. Results are cached per user and posting.
The first version was badly calibrated. Replaying its 23 production calls showed the headline tracking skills and experience almost alone (r = 0.969), running half a point below what its own factors implied, and landing on a half-point in 22 of 23 cases, so only 8 distinct scores ever appeared. A published weighting, worked examples and the check above fixed it. Across 123 calls under the current version, the bias against the formula is −0.08 and scores take 30 distinct values.
Embeddings. Profiles, postings and stories are embedded from fixed templates: role, team, industry, skills and a summary capped at 600 characters. For a posting, the summary is the one written during nightly enrichment, not the raw text. The full posting body is left out: its median is about 5,400 characters, most of it benefits and legal boilerplate that would pull every posting toward every other. The cap almost never applies — posting summaries run a median of 154 characters — because overnight parsing has already condensed them. Summarizing long text before embedding it would add latency and a new way to fail, to fix one row.
The model was chosen by measurement, not by default. On the platform’s own labelled pairs, 142 fit scores plus 500 random postings, three small multilingual models were compared:
| Model | Agreement with fit score | Good vs random (AUC) | Gap |
|---|---|---|---|
| Qwen3-Embedding-0.6B | 0.33 | 0.906 | 0.126 |
| multilingual-e5-large-instruct | 0.22 | 0.855 | 0.041 |
| bge-m3 | 0.18 | 0.690 | 0.037 |
It won on every measure, and its separation between good matches and random postings was three times the others’. The labels are thin and one account dominates them, so this ranks the candidates; it doesn’t prove the winner is good enough. That’s what the tests below are for.
The templates have no fields for seniority or degree requirements. Those are hard facts with sharp edges, and an embedding smooths edges away: a senior and a junior version of the same role sit close together in embedding space, so similarity can’t tell someone they’re a level short. Deterministic filters and score terms handle them instead. Industry and skills are the opposite case. Exact matching misses near-equivalents and loosely worded requirements, so they go into the embedded text, where similarity catches them, and they’re also scored deterministically where an exact match exists.
A profile’s industry comes from what the user chose at onboarding, or failing that, from their own employers through the same employer table used for postings. Adding it raised agreement with fit scores from 0.336 to 0.389; the 95% interval on that change runs from +0.015 to +0.098 (n = 141).
Every vector carries the ID of the model that produced it, and vectors from different models are never compared. The model embeds the side doing the searching differently from the side being searched: a profile looking for postings goes in with an instruction describing the search, and postings go in as they are. That matters for the three places embeddings are used.
- Jobs tab. Hard filters for level, degree and, when the user has set one, location narrow the pool first. When few postings survive, similarity is computed exactly against all of them. When many survive, an approximate nearest-neighbour index supplies the pending: k closest to the user’s profile, and a second source adds postings at companies and in industries the user is already pursuing, so a strong match on history is not cut for being semantically further away.
- Chrome extension. When the user opens a posting on an ATS page, a banner shows a free similarity estimate before they spend a paid fit analysis on it. A posting the index hasn’t seen yet is embedded on the spot.
- Interview prep. The user’s stories are ranked against the posting and the top three are suggested. Here the posting is the thing searching, so it needs a different vector from the one stored for the Jobs tab. It is computed on request rather than stored twice.
How big the shortlist has to be. Each posting’s score depends only on the posting and the user, never on what else made the shortlist. That makes the cut testable: rank every candidate with no cut at all, then check how much of the top of that ranking survives the cut. At pending: k, the top 100 for the worst account measured matches the uncut ranking pending: overlap of the time. Vectors are stored at 1,024 dimensions; the index covers the first pending: dim, and the shortlist is re-ranked on the full vector.
Whether it’s better. The obvious test leaks. Checking where a user’s tracked postings rank fails because one of the feed’s own terms rewards companies the user is already talking to, so the posting being predicted raises its own score. Instead, each account’s most recent applications were hidden, the history terms recomputed without them, and the hidden postings ranked. pending: leave-out result. Most of those labels come from one account, mine, so the result says more about whether this works for one person than for users in general.
Grounded generation
A tailored résumé is where a language model most wants to invent things. The model is only given the user’s pool to work from, and there are two modes.
- Review mode uses a more assertive prompt and assumes a person reads the result.
- Auto mode, for unattended bulk use, uses a conservative extractive prompt.
Auto mode exists because of an adversarial test: the assertive prompt fabricated in 6 of 6 cases. The conservative prompt cut that to 2 of 6. It did not eliminate it, which is why there is also a second check.
Every generated document is audited by a second model call. The auditor sees the source material and the draft, both still in placeholder form, and it is deliberately not shown the user’s own free-text instruction. A request like “say I led a team of five” therefore gets flagged instead of laundered into the résumé. In auto mode a major flag triggers one regeneration. The auditor’s own output is capped in length so one model cannot pass instructions to the next.
The audit flagged 25 of 76 tailored drafts in July. After the tailoring prompt was fixed, it flagged 2 of 17 in August.
Privacy
User-facing generation goes to an external model, and a résumé is one of the most identifying documents a person owns. So identifying values are swapped for placeholders before a request leaves the server, and swapped back after it returns.
What gets replaced
- Values the system already knows: the user’s name, taken from the first line of their résumé and from their profile, plus their contact details, recipients’ names and organizations. Matched exactly, case-insensitively, longest first, after Unicode normalization, including names written in Korean, Chinese and Japanese.
- Anything shaped like an email address or a phone number, including international formats.
- In mail, names in greeting, From, To and sign-off positions.
These rules catch what the system knows about and what has a recognizable shape. A name it has never seen, mentioned in passing in free text, is not caught.
Placeholders look like ⟦NAME_1_a3f9c2⟧. The suffix is regenerated on every call, and any such bracket already present in the input is neutralized first, so text inside a résumé cannot forge one. The same value maps to the same placeholder within a call, so the model can still refer to it consistently.
The per-call suffix has a price. External models discount a prompt prefix they’ve already seen, and a placeholder that changes on every call breaks the match. Of the three prompts that carry a whole résumé, the two that never restore values replace them with a fixed marker and keep the discount. The one that must restore the name it opens with loses it.
| Applies to | What happens | |
|---|---|---|
| Restored | Anything shown back to the user: fit rationales, drafts, résumés | Real values are put back on the server as soon as the model responds. If a placeholder comes back broken or missing, the request fails instead of returning a document with a hole in it |
| One-way | Text that goes into shared stores: the posting index, command bar queries | Replaced and never restored |
| Never sent | The user’s name on their own drafts, the name and contact block of a tailored résumé, mail bodies, demographic data | Attached or filled in on the server after the model is done |
Embedding text never leaves the platform. The embedding model runs on hardware the platform operates. Free-text fields, a posting’s summary or a user’s story, are scrubbed of email addresses and phone numbers before they’re embedded, and a test fails if any template’s output carries the user’s name or contact details.
A fit request
Profile, as stored Minji Park, minji.park@example.com
Four years building batch data pipelines.
Sent to the model ⟦NAME_1_a3f9c2⟧, ⟦EMAIL_1_a3f9c2⟧
Four years building batch data pipelines.
Model returns ⟦NAME_1_a3f9c2⟧’s pipeline work matches the posting’s
emphasis on batch ingestion.
Shown to the user Minji Park’s pipeline work matches the posting’s
emphasis on batch ingestion.If the model had returned ⟦NAME_1_a3f9c⟧, one character short, the check that runs after restoring would find a leftover bracket and the request would fail.
Tailoring a résumé
Reading the inbox
Would be sent at tier 3 sender domain careers.example.com
subject Next steps for your application
excerpt ...invite you to a first round
interview next week... (≤ 400 chars)
Never sent at any tier the full body, the sender’s name, attachmentsAn adversarial test feeds tier 3 a 40 KB subject line and checks that the outgoing request stays under 1,500 characters.
Isolation, storage and deletion
- The database enforces isolation, not the application. Row-level security is enabled on every table, including the embedding tables. User data is queried through a per-request session that carries the signed-in user’s identity, so a wrongly written query still cannot return someone else’s rows. A lint step and a live isolation test with a positive control run before changes merge.
- Credentials and sensitive fields are encrypted in the database, including the mail and calendar access tokens and demographic fields.
- Model calls are logged as sent, which means in placeholder form, for evaluation and regression testing. They are kept after an account is deleted, unlinked from the user. The privacy policy says so.
- Deletion starts a seven-day grace period, then a scheduled purge removes the user’s files, their auth record, their rows and their entries in the job queue. The queue has no foreign key back to the user and would otherwise be missed.
- Background work runs one job at a time. That is a privacy decision, not a throughput one: redaction state is process-wide, and concurrent jobs could let one job’s state affect another’s request.
The next step is an assertion on the outgoing request itself, so a pattern the matcher misses fails at the boundary instead of passing through it.
Models and routing
Three models, split on whether anyone is waiting.
- Batch parsing runs on a self-hosted Qwen3.5-9B, quantized to 4-bit AWQ, working through each day’s new postings overnight. Nobody waits on it, so a retry, a slow response or an engine restart costs only time.
- Generation a user waits on runs on an external model. Fit scoring, tailoring, drafting, the command bar. Reliability there is visible to a person, and one consumer GPU cannot promise it. Processed one at a time, a posting takes 6.42 s locally and 4.16 s externally.
- Embeddings use Qwen3-Embedding-0.6B, split the same way. Anything on demand goes to a small CPU service the platform keeps running; the bulk backfill runs on the local GPU. Both use identical weights and settings, because vectors made under different setups can’t be compared.
Only batch enrichment reads the self-host setting, so turning the local model on cannot move a user-facing call onto the GPU. More than 318,000 calls have gone through the local parsing path at no marginal cost. The same volume on the external model would have cost roughly $312 to $513, depending on cache hits. Outside batch enrichment, the product’s entire external model bill to date is under two dollars.
The nightly run
- At 03:00 US Eastern the job checks that it is inside its allowed hours and that at least 3 GB of RAM is free. The RAM check matters because Eureka’s database runs on the same machine.
- It starts the model server, waits until it answers, then works through the backlog eight postings at a time. Recent nights parsed 7,200 and 6,376 postings, at about 35 a minute.
- No new batch starts once three hours have passed. The server is stopped by ending its whole process group, because the engine’s worker process, not the launcher, holds the GPU memory.
- A transient failure is retried up to five times. Anything else is set aside for the rest of that night’s run. The embedding backfill runs in its own window, because the parsing server holds the GPU for the whole nightly run.
- Raising --max-num-seqs to 32 crashed the engine. Most of this model’s layers keep a recurrent state for every sequence, allocated up front, so that flag is not a queue depth. It is a VRAM budget.
- Prefix caching matched 58.1% of tokens and made throughput slightly worse, because the workload is bound by decoding, not by reading the prompt.
- Two request details were found by hand. Tool-calling mode made the server reject requests, so structured output uses plain JSON mode. And without thinking disabled, the model spent its whole token budget reasoning and returned nothing. Neither is covered by an automated test yet.
The average request is 4,225 tokens in and 429 out. Throughput levels off at eight concurrent sequences, around 36 postings a minute; the initial backfill sustained 38.8 to 39.5 a minute and parsed 211,230 postings in a week. The environment is vLLM 0.26.0 and PyTorch 2.11 on CUDA 13, entirely from prebuilt packages. Only 8 of the model’s 32 layers use standard attention, and those run on FlashInfer; the other 24 are Gated DeltaNet linear-attention layers on Triton kernels.
Access for AI assistants
People are starting to hand job-search tasks to AI assistants. An assistant that can read someone’s tracker and act on it, with their permission, is more useful than one that asks them to paste things in. So Artemis runs a Model Context Protocol server: one endpoint, following the protocol’s 2026-07-28 revision, with a compatibility path for clients still on the older one. It’s built to be safe before it’s useful.
- Access is by personal token, with scopes. A user mints a token, grants it specific scopes and can revoke it. A scope exists only once a tool that needs it exists, and a test fails if one appears without a tool, so no one can grant authority for nothing.
- Every request passes one chain, in a fixed order: origin, parsing, protocol version, header check, token, capability, rate ceiling, scope, and only then the call. Nine reorderings of that chain were injected as mutations, and each one failed a test.
- Isolation is tested directly. The tool layer filters by user on its own, so tool-level tests would still pass if row-level security bound nothing. One test runs an unfiltered query as two different users; breaking the per-request identity fails exactly that test.
- The audit log keeps what’s worth keeping. Refusals after authentication are audited, such as a token probing for a scope it wasn’t granted. Refusals before it go to the structured log, so an unauthenticated caller can’t fill the audit table.
- It ships off. The server is disabled by default and limited to administrators even when enabled. One tool exists so far: a summary of the user’s own application statistics.
The posting index
Six applicant-tracking-system APIs, around 830 company boards seeded every night. Getting from there to a clean index took most of the engineering hours in the project.
- Deduplication uses three keys in order: the ATS’s own posting ID, then a normalized URL, then a hash of the posting text. The text hash is salted with the ATS requisition ID, so identical postings for different locations stay separate. One employer served identical text under three different job IDs, so the hash is also salted with the URL when that pattern appears.
- Expiry usually costs no extra requests. A posting that disappears from its board’s listing is marked expired using the listing the nightly job already fetched. The rule refuses to run if fewer than half the stored postings appear, so a broken API response cannot expire a whole company.
- A posting is parsed once, for everyone. Every URL a posting has been seen at is recorded, and URLs are normalized first. When a second user adds a posting someone else already saved, from a different link, it resolves to the existing entry and both applications point at that one row. A posting new to the index goes through a fixed order of steps, with a model call only as the last resort.
- Nothing a user depends on disappears. An expired posting is kept for 30 days, then removed only if no application or tailored résumé points to it and a final check confirms it’s gone. The check matters: in a sample of 20 postings due for deletion, 16 turned out to be live.
- Vendors behave in undocumented ways. One caps its reported total at 2,000 on large tenants. Another returns page one for an out-of-range offset instead of an empty page. A third silently clamps its page size.
- Salaries and locations are normalized. Pay is kept as published and also annualized and converted with weekly ECB rates, with a floor that catches hourly figures mislabelled as annual and a ceiling that drops impossible hourly bands. Locations resolve against a gazetteer and stay empty when ambiguous rather than guessing.
- Extraction is checked, not trusted. An unrecognized value costs one field, never the whole extraction. The model kept inventing team names, so a team is only kept if every word of it appears in the job title. 32% of the teams one model emitted, and 42% of the other’s, did not.
| System | Industry exact | Defensible |
|---|---|---|
| Self-hosted Qwen3.5-9B alone | 71.9% | 88.5% |
| External model alone | 88.1% | 97.9% |
| Employer lookup, then self-hosted | 90.7% | 98.7% |
| Employer lookup, then external model | 90.3% | 99.0% |
A 16-point gap between the two models closes to within half a point once a lookup table of 1,652 employers answers first. Industry is a property of the employer, not of the job posting, so asking a model to infer it from prose is asking it to do lookup work badly. Move the lookup to a table and the size of the model stops mattering.
The database
- A column widening that filled the disk. Changing a column’s type in place duplicated 482 MB of stored data and restarted the shared database. It is now done by adding a new column, backfilling in batches and swapping, at no net storage.
- 74 prompts stored 333,633 times. System prompts were written out in full with every call and had grown to 22% of the database. Storing each distinct prompt once and having every call point to it took the whole database from 6,231 MB to 4,246 MB, about a third smaller.
- A load test with 60 concurrent requests. Before, 31 failed with server errors over 32.7 s. After, 45 succeeded and 15 were turned away cleanly, in 5.2 s. One board lookup called 22,571 times dropped from 1,214 ms to 1 ms.
- Vectors only where they’re used. Only active postings are embedded, which keeps posting vectors under 400 MB instead of over 600. When a posting expires its vector is dropped, unless someone is still pursuing it.
- Re-embedding follows content, not a clock. Each posting vector stores a fingerprint of the text it was made from, so when overnight parsing rewrites a posting’s summary or skills, it re-enters the queue on its own.
What’s next
- Tailoring that emits the structured format directly. Tailored résumés already render through the open-source renderer, and the web app downloads PDF bytes rendered on the server. The generation step still produces text that is converted on the way. The adapter to emit the structured format directly exists and waits on a recorded decision.
- A dedicated server, with funding or investment. A larger model fine-tuned with QLoRA on the platform’s own data, hosted around the clock. That would bring the user-facing path in house as well, so no request would need to leave the platform.
Limits
- Not launched. A few dozen accounts, most of them mine or friends testing it. Ten have tracked an application. Tailoring has been used by one person. Submissions and fill times through the extension are recorded from late September 2026, not yet long enough to quote.
- Development and production share one database. The API and the background worker each run as a single process, and the parsing model runs on my own machine.
- Feed latency. pending: p50 median and pending: p95 at the 95th percentile with the new ranking, from 0.8 s and 2.3 s on the old path’s database query, and 17 to 20 s on its worst plans in August.
- The assistant rate ceiling is per process. It lives in one worker’s memory, so it holds only while the API runs as a single instance.
- Search is unscaled. A query on a common word takes anywhere from 40 ms to 12.7 s depending on the query plan.
- Accuracy is judged by models, not people. There is no human-labelled test set for extraction, and the recommendation labels are mostly one account.
- No usage telemetry. There are no view, click or dismiss events, so recommendation quality has never been measured against what people actually do.
- Nothing watches the batch path for absence. In late August a working directory was recreated without two environment variables, and nightly enrichment produced zero rows for six nights. Every visible signal stayed healthy. A code audit found it, not monitoring. It is the same kind of blind spot that shaped Eureka’s freshness checks, and Artemis does not have an equivalent yet.