Skip to Content
ProjectArchitecture

Architecture

One rule of thumb runs through all of it: each package should be independently substitutable. The boundaries below are enforced by what a package is allowed to import.

The flow

nine sources | async fetch, per-host semaphore, tenacity retry v Job (pydantic) content hash + canonical hash | v pre_filter() deterministic, no model | role -> seniority -> geo -> remote -> comp -> skills -> score | +-- failed: reasons recorded, counted in the pass stats | v Postgres jobs table (in-memory fallback is a real path) | v qualify_pending() the LLM tier; idempotent per resume hash | v QualificationResult fit score, verdict, strengths, gaps, positioning | +----------------+----------------+ v v v digest draft console | | v v drafts/*.md one HTML file | | +-------> you press submit | v status / stage / outcome | v conversion ledger -> funnel, variants | v past verdicts -> the next qualify prompt

Package boundaries

ingestion

Everything before “does this row deserve model budget?” No LLM imports at all — no provider SDK, no client. If ingestion seems to need a model decision, the boundary is in the wrong place.

Public surface: the fetcher registry, the orchestrator, pre_filter, Storage, and the Job model with its enums.

qualification

Everything about “does this row match the candidate?” The only place model calls happen for filtering.

Public surface: get_qualifier(provider, model), the batch runner, single-turn and agent qualification, the QualificationResult model, and the tool registry.

drafting

Everything about “produce something to send.” Also model-backed.

Beyond the pack factory: deterministic ATS keyword-gap scoring, tailoring as a selection over profile records with a verified provenance trail, and dossier-hooked follow-up notes.

coach

Interview prep, fired by the state machine rather than by a verb. Half the pack never passes through a model: the comp figures are derived from the posted band, the declared floor and comparable postings already ingested, and the STAR stories are mined from the profile core and the career transcript with every citation verified before it ships (ADR 0024).

intel

Cached company dossiers from public probes, with a 14-day TTL — 3 days when a prep pack asks, because an interview wants fresher signal. One fetch, many consumers. Probes are tolerant: a dead probe degrades the dossier and never raises.

slackbridge

Two halves with different dependencies. The push half is httpx only and works from cron; the listener needs an extra. The command mapper is a third thin adapter onto the same storage and runners (ADR 0010).

notionsync

A one-way mirror. karani owns the schema. Page identity is a column on the job row: create once, patch after, recreate on a 404 (ADR 0011).

autopilot

One pass: pick candidates above the fit floor that are unreviewed and not already in the state machine, draft a pack for each, post a review card with buttons. A drafted posting leaves the pool, so nothing is billed twice.

watcher

Read-only Gmail. The scope is gmail.readonly and nothing else, and a test asserts no module in the package names a write endpoint. Inbound mail is data and never instructions (the watcher guide has the three structural defences).

analytics

The conversion ledger, interval-reported statistics, named variants with a promotion rule that refuses on a small sample, and a derived weekly review (ADR 0022).

submission

The last mile: ATS form introspection, a deterministic answer sheet, one static HTML file, and the write-back (ADR 0023).

mcp_server

The interactive interface. A thin adapter: tools call the same runners and storage methods the CLI calls and hold no logic of their own. Storage is a lazily-connected process-wide singleton so the in-memory fallback keeps state across tool calls (ADR 0008).

The cross-package contract

Job and QualificationResult are the only data structures shared across packages. A new field on either goes through both packages plus a storage change.

Storage

One main table, jobs, with columns grouped by concern:

GroupColumns
Provenancesource, source id, content hash, canonical hash
Postingcompany, title, department, location, remote status, description, apply URL, posted at, tags, raw
Compmin, max, disclosed, original currency
Pre-filterthe result as JSON, passed, score, role category, seniority
Qualificationthe result as JSON, verdict, fit score, qualified at, resume hash
Feedbackuser verdict, when
State machinestatus, applied at, stages, outcome, draft path
Lifecycleactive, closed at, first seen, last seen, updated

Plus auxiliary tables for discovered companies, the memory ledger and the watcher’s message log.

Three rules:

  • JSONB for anything that would otherwise need a shape-change migration: raw, the filter result, the qualification, the stages log.
  • ADD COLUMN IF NOT EXISTS on every addition. Never a destructive migration (CLAUDE.md 4.7 ).
  • No ORM. Hand-written SQL on an async driver. Storage is small enough that an ORM would be the larger thing to learn.

The stages log is a JSONB append log rather than a normalized table, and the state machine is columns rather than its own table (ADR 0005). Both look wrong and are deliberate.

Extension points

An LLM provider

Providers implement an informal protocol:

class QualifierClient(Protocol): model_name: str async def complete(self, system: str, user: str) -> str: ... # optional, for agent mode: async def chat_turn(self, messages, tools=None) -> dict: ...

Subclass the OpenAI-compatible base if the wire format matches, implement from scratch if not, then register_provider("name", builder). Third-party code can do that without forking. Guard any SDK import at construction time, so the module loads whether or not the SDK is installed (ADR 0017).

An ingestion source

Subclass the fetcher base. Three rules that matter more than the code:

  • Use the shared retrying fetch helper. A fetcher that calls the HTTP client directly escapes the per-host limit, the backoff and the 404 fast fail.
  • Call .finalize() on every posting, or both hashes are empty and dedup silently stops working.
  • Register it, and add either slugs to [[targets]] or the enum to the feed list.

An agent tool

A dataclass with a name, a description, a JSON-schema parameter block and an async function. Return text and never raise — wrap errors as text. Respect the byte cap. Then append it to the default set, update the agent system prompt, and bump the agent prompt version.

A state-machine transition

The status set is one frozen set, which is the source of truth. Add to it, extend the setter if the transition needs side effects, and test it. Most transitions need no new verb — karani status covers them.

Note that a transition can spend money: screen and interview dispatch to the coach. Any future listener on a state change inherits all three of the coach’s obligations — be idempotent on the stages log, record its marker even on failure so a retry cannot double-spend, and obey the kill switch (CLAUDE.md 4.14 ).

Runtime characteristics

StageCostWall time
IngestionFree30-60s for all nine sources
Qualification, single-turn~$0.01/row hosted, $0 local75-170s/row local
Qualification, agent mode~$0.05-0.15/rowSlower; concurrency capped lower
Drafting~$0.03/draftOn demand
ConsoleFreeOne request per Greenhouse job, then cached

Failure modes it is hardened against

  • A 404 on a slug that changed ATS: logged per source, the pass continues.
  • A feed schema drifting: raw preserves the original for a reparser.
  • A malformed model response: the row downgrades to maybe with an explanation; the batch continues.
  • The database being down: the in-memory path runs.
  • Overlapping qualification runs: idempotent per resume and content hash.
  • An agent loop running away: iteration and tool-call caps, with a nudge toward a final answer when the budget runs down.
  • A wedged pass: the hard pass deadline (ADR 0025).

Failure modes it is not

Honestly listed, because the roadmap is where they go:

  • No persistent per-source health metrics. Pass statistics are ephemeral.
  • No per-run cost attribution beyond the provider’s own dashboard.
  • A failed qualification is re-selected on the next run but not retried with any backoff, so a model having a bad day fails the same rows again.
Last updated on