A job search is not a writing problem. Almost everyone assumes it is, and so almost every tool built for it is a better editor. The actual constraint is decision volume: hundreds of postings, of which perhaps eight deserve real effort, and no reliable way to tell which eight before you have already spent the evening.
So the system I built for my own search is not a writing tool with some automation bolted on. It is a decision pipeline that happens to end in documents. That single framing decided almost every technical choice that followed, which is the part worth writing down.
The shape, in one direction
Postings come in. They are reduced to one common shape, so that a listing from anywhere is comparable with a listing from anywhere else. Each is scored against a profile that I maintain deliberately rather than scrape. The ones worth the effort get a tailored draft. Nothing is sent until I have read it. What goes out is recorded, and what came back is the only feedback the system ever gets.
Six stages, one direction of travel, and every stage able to run on its own with the previous stage's output fed in from a file. That last property is not an aesthetic preference. It is what lets me debug a bad recommendation a week later by replaying the exact input, instead of reasoning about what the system might have been thinking.
The stack, named
Layer by layer, this is what it actually runs on.
- Runtime: Python 3, standard library only. There is no dependency manifest in the project, because there are no dependencies to declare.
- Web layer: the standard library's own HTTP server, serving HTML rendered on the server. No framework, no application server, no reverse-proxy-to-a-worker-pool.
- Store: SQLite, which ships inside Python, plus a few plain JSON documents for things I edit by hand and want to read in a text editor.
- Front end: hand-written HTML, CSS and plain JavaScript. No build step, no bundler, no transpiler, no node_modules directory anywhere near what ships.
- Word documents: the Office XML format written directly into a zip container. That format is a documented spec, and a zip writer and an XML writer are both in the standard library.
- PDF: LaTeX does the typesetting, preferring a single-binary engine and falling back through the traditional ones if it is not installed.
- Generation: hosted models reached over plain HTTP through a router, on a free tier, with an ordered fallback list and a hard timeout. If every model in the list fails, the pipeline continues without it.
- Notifications: a chat bot API, so that something arriving in the queue reaches my phone without me building a notification system.
- Hosting: containers behind a reverse proxy that terminates TLS and renews certificates automatically.
- Tests: a headless browser driven end to end, deliberately installed outside the application, so the zero-dependency rule still holds for the thing that actually ships.
The rule that keeps that list honest is enforced rather than intended: the test suite asserts that the application imports nothing outside the standard library. Discipline that is not checked is just a preference, and preferences erode on the evening you are in a hurry.
The choices inside that list worth defending
Some of those lines are obvious once you accept the constraint. Four were genuine arguments.
LaTeX for the PDF, rather than a PDF library or a headless browser printing a page. A PDF library means describing a document in code and re-solving typesetting problems that were solved decades ago; a headless browser means shipping an entire browser to render one sheet, and inheriting its print quirks. LaTeX is a large dependency, but it is an external binary rather than a library in the process, it does not change under me, and it produces a typographically better page than either alternative. The cost is honest: if it is not installed, that one format is unavailable, and the system says so rather than pretending.
Writing the Word format by hand, rather than using the obvious library for it. This one I expected to regret and did not. The subset of the spec needed for a resume is small, and writing it directly meant total control over the markup, which matters more than it sounds: parsers on the receiving end choke on constructs a general-purpose library emits happily. It also kept the dependency count at zero for the single most important output format. If I needed tables, images and complex styling, I would use the library without hesitating.
Free-tier models behind a fallback chain, rather than one paid provider. Drafting is the only step that is not deterministic, and it is also the only step a person reads before anything happens. That combination makes reliability cheap to buy with a fallback list instead of money: if the first model is rate-limited, try the next, and if they all fail, hand over the deterministic draft. A paid single provider would be more reliable per call and would still need the fallback path written, because the interesting failure is not the provider being down, it is the answer being unusable.
SQLite rather than files alone. Plain JSON files were tempting given everything else here, and I use them where the data is something I edit. But the moment two things write at once, even two scheduled tasks overlapping by a second, files start losing writes silently. SQLite gives transactions and a real query language, costs nothing to run because it is not a server, and it is one file I can copy to back up the whole system.
Boring on purpose
The runtime is Python's standard library and a single file on disk for state. No framework, no database server, no build step, no package manifest with forty transitive dependencies. That is unusual enough now that it reads as a stunt, so here is the reasoning.
The load is one user. The write rate is a few dozen records a day. Anything that survives a laptop sleeping is sufficient. Against that, a database server is not a capability, it is a second thing to run, back up, upgrade and be woken by. The moment concurrency or multi-tenancy is real, that calculus flips completely, and I would flip with it without sentiment. It simply is not real here.
A web framework would have saved me perhaps two days of routing and form handling. It would also have committed me to its upgrade cadence, its security advisories, and its opinion about how a request becomes a response, for a project I intend to still be able to run in five years without archaeology. Two days is cheap. A dependency you must keep feeding is not.
A single-page front end was the closest call. Rendering on the server and shipping plain pages means no build, no bundler, no framework version to chase, and pages that stay readable when scripting fails. What I gave up is the rich interactivity that a component framework makes easy, and there are two screens where I feel that loss every time I use them. That is a real cost, and I would take the trade again, but I would not pretend I did not pay it.
I did not use a vector database, because retrieval over a few hundred documents I control is not a similarity-search problem. Ordinary text matching with a curated vocabulary beat embeddings in my own comparison, and it has the enormous advantage that I can explain any individual result. When the corpus is small and you own it, semantic search is often solving a problem you do not have.
I did not use an agent framework either. The orchestration here is six steps in a fixed order with a human gate. Expressing that as a graph of autonomous agents would add a scheduler, a state machine and a class of failure I would then have to debug, in exchange for flexibility I do not want. Frameworks earn their keep when the control flow is genuinely dynamic. Mine is a line.
Where the model is allowed to touch the work
This is the decision I would defend hardest. The scoring is deterministic and the drafting is not.
Scoring has to be explainable, because I act on it. If a posting is rated poorly I need to see which factor dropped and why, and I need the same posting to score the same way tomorrow. A language model is a bad fit for that: it is fluent, it is confident, and it will produce a slightly different justification each time you ask. So the ranking is ordinary code with weights I can read, and every score can be decomposed into the factors that produced it.
Drafting is the opposite. Turning the same set of facts into prose aimed at a particular reader is exactly what these models are good at, and there is no single correct output to be reproducible about. So that is where the model sits: one clearly marked step in the middle of the line, with deterministic stages on both sides of it. If the model is unavailable the pipeline still runs and still ranks; it just hands me a plainer draft.
The constraint I put on it is that the model may rephrase what is already true, and may never introduce a fact. Numbers, dates, titles and claims come from a store I maintain by hand. This is not caution for its own sake. An invented metric on a resume is not a bug report, it is a conversation in an interview that you lose.
The gate is the product
Nothing leaves without a person reading it. Not as a temporary safety measure until the model gets better, but as the design.
Every application carries my name, and the cost of a bad one is asymmetric in a way automation metrics never capture: a hundred sent applications are worth less than one that gets a real reply, and one embarrassing one can cost a referral I would never learn I had lost. When the downside is reputational and unmeasurable, throughput is the wrong thing to optimise. So the system optimises for how many good decisions I can make in an hour, and the last step is always me.
The honest version of this claim is that the gate is also what makes the automation safe to trust. Because I know a person reads everything, I am comfortable letting the earlier stages be aggressive.
When this architecture stops being right
If this were for more than one person, the single-file store goes first, then the assumption of one process, and the ranking would need to be tuned per user rather than by me editing weights. If postings arrived by the thousand, the matching would need an index and probably would need embeddings after all. If the drafting step became several dependent model calls with retries, the case for real orchestration would become genuine.
None of those are true today. An architecture that anticipates all of them is not more mature, it is more expensive, and the cost is paid immediately in exchange for a benefit that may never arrive.
What this taught me about product decisions
Three things carry over well beyond this project.
The first is that the technology choice is downstream of the constraint, and that most arguments about stacks are really unexamined disagreements about what the constraint is. Once I accepted that decision volume rather than writing quality was the bottleneck, the stack more or less chose itself.
The second is that reproducibility is a product feature, not an engineering nicety. The reason I trust this system is that I can replay any decision it made. That trust is the entire reason I use it.
The third is that the boundary between deterministic and generative is the most important line in any AI-shaped product. Put it in the wrong place and you get a system that is either too rigid to be useful or too confident to be trusted. Almost everything I got right here came from arguing about where that line goes, and almost everything I had to redo came from putting it in the wrong place first.