Autonomous Job Application Platform
An end-to-end platform that automates every stage of the job application process, from discovering relevant positions and tailoring application documents to tracking status and follow-ups. Built around a hub-and-spoke microservices architecture with LLM-powered Python agents at its core.
Overview
A production-grade automation platform that manages the full job application lifecycle: sourcing, document generation, form submission, and status tracking. The system uses a hub-and-spoke architecture where a central orchestrator coordinates specialised microservices for each stage of the pipeline.
Architecture
The platform consists of ASP.NET Core services for orchestration and storage, PostgreSQL for relational state, and MinIO for document storage. Python agents built with LangChain handle the reasoning-intensive tasks: tailoring cover letters and CVs to specific job descriptions, extracting structured data from unstructured postings, and driving browser automation for form submission.
Orchestrator (ASP.NET Core)
├── Job Sourcing Agent (Python / LangChain)
├── Document Generation Agent (Python / LangChain)
├── Form Filling Agent (Playwright + LangChain)
│ └── Candidate Representation Agent (Python / LangChain)
├── Status Tracker (PostgreSQL)
└── Document Store (MinIO)
Clerk handles machine-to-machine authentication between services. Laminar provides end-to-end tracing across all agent calls, capturing inputs, outputs, and latency at every step of the multi-agent pipeline.
Agent Role Separation
One of the central design decisions was how to handle form submission. The naive approach is to give a single agent the job description, the candidate profile, and control of a browser, and ask it to fill out the application. In practice this breaks down quickly.
Browser automation already places enormous demands on an LLM. A job application form is a wall of DOM text: labels, placeholders, validation messages, hidden fields, and navigation elements all compete for the model’s attention. Asking that same agent to simultaneously decide how to represent the candidate, what to write in open-ended fields, and which element to interact with next distributes its attention across two fundamentally different tasks and degrades both.
The solution was to split the responsibility. The form-filling agent is responsible only for navigating the browser and populating fields. When it needs content for an open-ended field, it calls out to a dedicated Candidate Representation agent, which has no awareness of the browser and no DOM to contend with. It receives a clear question and returns a clear answer. This separation has two practical benefits: the form-filling agent can focus entirely on the mechanics of navigation, and the quality of the candidate representation can be evaluated and improved independently, without needing a live browser session to test it.
What I Learned
- Clearly separating agent roles is one of the most important levers for reliable performance in multi-agent systems. An agent that tries to do too much at once will do several things poorly. Giving each agent a narrow, well-defined responsibility makes the system easier to test, easier to debug, and easier to improve in isolation.
- Laminar’s tracing was indispensable for understanding multi-agent pipelines in production. Seeing the full sequence of LLM calls, tool invocations, and latency figures in a single trace made it possible to identify where the pipeline was slow or where outputs were drifting, in ways that logs alone would not have surfaced.
- The hub-and-spoke model earns its value over time. Adding a new specialised agent requires no changes to the orchestration logic; the orchestrator treats each spoke as a black box behind a stable interface. The cost of that discipline is paid once, upfront; the benefit compounds with every new agent added.