Skip to content

Latest commit

 

History

273 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OSApplyTrack

Your job hunt, self-hosted and on autopilot.

Live demo instance. Sign in with a magic link — each visitor gets their own tenant.

CI Release GHCR License: Apache-2.0 .NET 10 C# ASP.NET Core Python 3.12+ PostgreSQL 17 Docker JavaScript Multi-tenant Self-hosted PRs Welcome

Open-source, multi-tenant, self-hostable job-application tracker. Track every application through its pipeline (lead → applied → screen → onsite → offer), keep per-user search criteria and a company blacklist, and let a background poller discover fresh remote roles from public job boards and stage them as leads — so your pipeline refills itself while you sleep.

Run it on your laptop with one docker compose up, or self-host it for your whole job search. Your data is yours: one-click export to a single JSON snapshot you can import into any other instance, one-call account deletion, no lock-in, no telemetry, no SaaS.

OSApplyTrack — the multi-lane pipeline dashboard with auto-discovered leads

OSApplyTrack application detail view on a phone
The responsive list and detail view support touch, keyboard, and screen readers.


Table of contents


Why OSApplyTrack

  • It tracks the whole funnel. Every application is a row with a status lane, company, role, link, location, salary, source, contacts, applied/follow-up dates, a relevance score, and free-form Markdown notes.
  • It finds work for you. A Python poller fetches listings from public job boards — plus any Greenhouse/Lever/Paylocity board or custom RSS/Atom feed you point it at — scores them against your saved criteria, drops anything from a blacklisted company, dedupes against what you've already seen, and stages the survivors as fresh leads.
  • It shows the right lead first. Sort the list by fit score, date posted, or company name — on top of the default pipeline order and the lane/status filters.
  • It drafts your cover letters. Point it at any OpenAI-compatible LLM — a local Ollama/vLLM model (so your résumé never leaves the box, $0 per draft) or a hosted provider — and generate a letter per application, tailored from your uploaded résumé. Set a reusable multi-line signature once in Settings · AI; it is appended exactly to every new or regenerated letter. Download the result as Markdown or PDF. Keys are yours, encrypted at rest.
  • It's genuinely multi-tenant. Every row is owned by a tenant; every query in both runtimes unconditionally filters WHERE tenant_id. One deployment cleanly serves many users with hard data isolation.
  • It's yours to keep. Export your whole account as one JSON snapshot and import it into another instance any time — applications, criteria, and blacklist travel together, so you're never locked in. Delete your account and every row it owns cascades away in a single statement.
  • It's a single-binary-feeling deploy. Postgres + a .NET API that also serves the SPA + a Python cron worker — three containers, one docker compose up.

Architecture

A polyglot backend behind one dependency-free vanilla-JS single-page app:

  • API — ASP.NET Core (.NET 10): magic-link auth, opaque server-side sessions, CRUD, and it serves the SPA. Dapper + Npgsql over Postgres; DbUp migrations run on startup. Minimal APIs on Kestrel.
  • Poller — Python: a cron worker that fetches and scores job listings and writes new leads. Reuses the original applytrack fetchers (httpx + psycopg3).
  • Postgres: the two runtimes never call each other — the database schema is the contract. The .NET API owns auth/sessions + CRUD and migrates the schema; the poller writes leads and reads profiles/seen/users. Both filter tenant_id.

OSApplyTrack architecture — the browser SPA hits the ASP.NET Core API and a cron-driven Python poller, both sharing one Postgres schema

The decoupling is deliberate: the API can answer "Poll now" instantly by enqueuing a request, while the poller drains that queue out of band. Neither runtime blocks on the other; the only thing they share is the database.

Quickstart (Docker)

cp .env.example .env        # optional: edit the Postgres credentials / API port
docker compose up --build   # brings up db + api + poller

Open http://localhost:8080.

Contested port? 8080 is popular (vLLM, llama.cpp, and plenty of dev tools default to it). If it's already taken, Docker refuses the bind loudly (address already in use) instead of silently winning the race — relocate by setting API_PORT in .env. The quadlet units add an explicit ExecStartPre listener check for the same reason.

Prefer prebuilt images? Each release publishes both runtimes to the GitHub Container Registry, so you can skip the local build:

docker pull ghcr.io/cryptojones/osapplytrack-api:latest
docker pull ghcr.io/cryptojones/osapplytrack-poller:latest

To sign in, enter your email. In the default configuration the magic link is printed to the API logs instead of being mailed (zero email setup needed):

docker compose logs api | grep magic-link

Open that link and you're in. The first account created is tenant 1. To mail the link instead, set the Email__* variables (see Configuration).

Tip: the poller is the third service (poller). docker compose up starts all three; if you only bring up db + api, no leads will ever be discovered because nothing drains the queue or runs the scheduled poll.

Production containers

Use the separate hardened stack for self-hosting. It consumes versioned release images, keeps Postgres on an internal Docker network with no host port, and binds Kestrel to host loopback for a same-host TLS reverse proxy:

cp .env.production.example .env.production
# Replace every CHANGE-ME value and set your real HTTPS origin/hostname.
docker compose --env-file .env.production \
  -f docker-compose.production.yml up -d

Production startup deliberately fails if OSAPPLYTRACK_VERSION, POSTGRES_PASSWORD, APP_PUBLIC_BASE_URL, or ALLOWED_HOSTS is missing. Pin OSAPPLYTRACK_VERSION to a released version rather than latest; generate a unique database password, and generate an independent APPLYTRACK_SECRETS_KEY if tenants may store LLM API keys. Keep .env.production out of source control. openssl rand -hex 32 produces a connection-string-safe value for either secret.

The API and poller images run as unprivileged users. In the production stack they also have read-only root filesystems, all Linux capabilities dropped, no-new-privileges, and only a bounded in-memory /tmp; neither runtime receives a host or named writable volume. Postgres alone owns the persistent pgdata volume. Front 127.0.0.1:${API_PORT:-8080} with Caddy, nginx, or another TLS-terminating reverse proxy—do not expose Kestrel or the database directly.

How it works

Sign-in (magic link). POST /api/auth/request always returns 200 {ok:true} — whether or not the address exists — so the surface can't be used to enumerate accounts. Behind that uniform response, a known/valid address gets a single-use, 15-minute token (only its SHA-256 is stored). GET /api/auth/verify consumes the token, mints a 30-day server-side session (not a JWT — so logout is instant revocation), sets an HttpOnly cookie, and redirects to / so the token leaves the URL and browser history.

The tenancy choke-point. A middleware resolves the session cookie to a TenantContext and is the only thing that lets /api/* through. Repositories are injected from DI already scoped to the caller's tenant, so endpoint code physically can't query another tenant's rows.

Optimistic concurrency. Each application carries a version. Writes accept ?expected_version= and answer 409 Conflict on a mismatch, driving the SPA's overwrite-confirm flow — two tabs can't silently clobber each other.

Discovery. The poller fetches sources once per pass, scores each listing against the tenant's criteria, drops blacklisted companies, dedupes against the seen ledger, and inserts the rest as lead-status applications.

Sorting. The sidebar defaults to pipeline order (still-open roles first, passed ones last) and can reorder by fit score, date posted, or company name from the Sort by control. Sorting is a client-side view over the same list payload — it composes with the search box and the lane/status filters, and your choice is remembered per browser (never sent to the server).

Autofill. When entering a lead by hand, paste the posting link and hit ⤓ Autofill: the server fetches the page (POST /api/scrape — SSRF-guarded, rate-limited) and fills the still-empty fields from the page's schema.org JobPosting JSON-LD, falling back to OpenGraph/<title> heuristics. Fields you already typed are never overwritten.

Pipeline queue inspection. Click the PIPELINE label in the dashboard status strip at any time to open the Pipeline view. It inspects the live submit queue in the exact FIFO claim order the worker uses, previews what the worker will do (submit, dry_run, prepare, or drop) and why, highlights Ready packets that are ready for auto-promotion, and offers an immediate Submit all clean action. It live-refreshes every 15 seconds while open.

Configuration

All configuration is environment variables (see .env.example):

Variable Default Purpose
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB applytrack / JanewayDidNothingWrong / applytrack Postgres credentials, shared by db, api, and poller. Override the password for any deployment exposed beyond local development.
API_PORT 8080 Host port the API publishes (the container always listens on 8080).
DRAIN_INTERVAL 60 Seconds between drains of the on-demand poll queue (the SPA's "Poll now" button).
POLL_INTERVAL 3600 Seconds between full multi-tenant polls.
ConnectionStrings__Postgres (compose default) Override to point the API at an external Postgres.
DATABASE_URL (compose default) Override to point the poller at an external Postgres (libpq URL).
FORWARDED_HEADERS_KNOWN_PROXY / FORWARDED_HEADERS_KNOWN_NETWORK (empty) Source IP or CIDR of a trusted reverse proxy when it is not on loopback.
APPLYTRACK_DIR ./applications Default folder the import-md command reads when --dir is omitted.
Llm__BaseUrl / Llm__Model / Llm__ApiKey (empty) Instance-default cover-letter LLM — any OpenAI-compatible endpoint (a local Ollama/vLLM/LM Studio model or a hosted provider). ApiKey is blank for a keyless local model. Each tenant can override these in Settings · AI, including a reusable multi-line signature. See Cover letters.
APPLYTRACK_SECRETS_KEY (generated) Master key (AES-256-GCM) for encryption at rest: the résumé PDF, cover letters, packet answers and posting text, evidence screenshots, and each tenant's own LLM API key and Telegram bot token. Unset, the api generates one on first run into APPLYTRACK_SECRETS_KEY_FILE and reuses it; the hardened production stack requires it set. Back it up with the database — losing it loses what it encrypts.
APPLYTRACK_SECRETS_KEY_FILE /var/lib/applytrack/secrets.key in a container, else ~/.local/share/applytrack/secrets.key Where the generated key is kept when APPLYTRACK_SECRETS_KEY is unset. The compose files and quadlets mount a secrets volume there, shared by the api and agent containers.
APPLYTRACK_SECRETS_KEY_PREVIOUS (empty) The old key(s), ;-separated, during a key rotation: decrypt-only; the startup sweep re-keys every row to the current key. Remove once the sweep has run.
Agent__Enabled / Agent__IntervalSeconds false / 300 Turns a container from this image into the agent worker (see The agent). The compose files run one as the agent service; the API container itself leaves it off. Per tenant the agent is still off until enabled in Settings · Agent.
Browser__Endpoint / Browser__Proxy (empty) The Playwright server the agent drives (ws://browser:3000/) and the forward proxy it must use (http://proxy:3128). Unset = no browser: packets are still prepared and you apply via copy-and-open. Set on the agent service only: the worker stamps a heartbeat row (agent_workers) saying it has a browser, and the api lets Submit and packet/prepare queue a run while that heartbeat is fresh (3 min). See The agent, step 4.
AGENT_DB_PASSWORD (required in production) Creates the least-privilege applytrack_agent Postgres role on first init; the agent container connects as it with Migrations__Mode=wait (it cannot migrate, so it waits for the api to).
Email__Host / Email__Port / Email__Username / Email__Password / Email__From / Email__FromName Host empty, Port 587, FromName OSApplyTrack SMTP relay for magic-link login emails. Leave Email__Host unset to log links to the console instead of sending (zero email config). Set it to relay through any SMTP provider — a local relay, your mail provider, or a transactional service (Resend/SendGrid/Mailgun/SES). Port 465 = implicit TLS, else STARTTLS; blank username = unauthenticated. Deliverability to Gmail/Outlook needs a relay whose IP has PTR + SPF/DKIM/DMARC.

API reference

All /api/* routes except the auth handshake require a valid session cookie; unauthenticated calls get 401 with a {"detail": "..."} body. The /health and /health/ready probes are open. Error bodies are uniform {"detail": "..."} across 400/404/409/500.

Health & probes

Two unauthenticated endpoints, split so a database hiccup drains traffic without killing the process:

Method Path Notes
GET /health Liveness. Cheap and static — 200 {"status":"ok"}. Never touches the database, so a transient DB blip can't trip it into a restart loop. Point your orchestrator's liveness probe here.
GET /health/ready Readiness. Opens Postgres and runs SELECT 1. 200 {"status":"ready","database":"connected"} when the DB is reachable (and startup migrations have run); 503 {"status":"unavailable","database":"disconnected"} otherwise. Point load balancers / deploy gates here so traffic only arrives once the DB is up.

Auth

Method Path Notes
POST /api/auth/request Body {email}. Always 200 {ok:true} (no account enumeration). Per-IP rate-limited.
GET /api/auth/verify?token=… Consumes a single-use token, sets the session cookie, 302 → /.
POST /api/auth/logout Drops the session row (instant revocation) and clears the cookie.
GET /api/auth/me {email} for the current session, else 401.

Applications

Method Path Notes
GET /api/apps List the tenant's applications. Returns a tenant-scoped ETag; send it as If-None-Match for a cheap 304 when unchanged. Clients without validators still receive the original bare JSON array.
GET /api/stats Counts by {status, lane}.
GET /api/apps/{name} One application: {filename, raw, fields, version, material, agent_verdict, packet}.
POST /api/apps Create from structured fields → 201 {filename}.
PUT /api/apps/{name}?expected_version=… Update structured fields (409 on version mismatch).
PUT /api/apps/{name}/raw?expected_version=… Replace the full Markdown document.
DELETE /api/apps/{name} Delete → 204.
POST /api/apps/{name}/draft Draft a tailored cover letter via the configured LLM; saves it and returns {ok, material}. Rate-limited.
GET /api/apps/{name}/cover-letter.pdf Download the saved cover letter as a PDF.
POST /api/poll Enqueue an on-demand poll → {count:0}. Rate-limited; the worker drains it.
POST /api/scrape Body {url}. Fetch a posting page server-side (SSRF-guarded) and extract {company, role, location, salary, source, description} for the editor's Autofill. Rate-limited; 502 when the page can't be read.

Criteria & blacklist

Method Path Notes
GET /api/criteria The tenant's discovery criteria (defaults when unset), including ats_boards and rss_feeds.
PUT /api/criteria Normalize + store posted criteria (junk dropped, score clamped, non-http(s) feed URLs discarded).
GET /api/blacklist List blacklisted companies.
POST /api/blacklist Add a company; flips its open leads to passed.
POST /api/apps/{name}/blacklist Blacklist the company on a given application.
DELETE /api/blacklist/{company} Remove a company.

Account

Method Path Notes
GET /api/account/export One JSON snapshot: every application + criteria + blacklist.
GET /api/account/export/shared Anonymized opportunity list for a peer (format: applytrack-shared): slug, company, role, link, location, source — no personal state.
POST /api/account/import Load a snapshot (upsert by slug, one transaction) — or a shared list: every entry lands as a fresh lead, slugs you already track are skipped.
DELETE /api/account Delete the account; every owned row cascades away.

Materials (cover letters)

Method Path Notes
GET /api/resume The tenant's résumé brief — the only facts the drafter may assert.
PUT /api/resume Compatibility endpoint for structured résumé JSON.
POST /api/resume/upload Upload a text-based PDF résumé (multipart/form-data, resume file, max 5 MB) and store the extracted text as the model brief.
GET /api/llm-settings The tenant's endpoint override, model, reusable cover_letter_signature, and the instance default. The API key is write-only — never returned, only a has_api_key flag.
PUT /api/llm-settings Set base_url / model / api_key / cover_letter_signature (omit api_key or the signature to leave it untouched; blank clears either) and cover_letters_enabled (omit to keep; false disables all drafting for the tenant).
DELETE /api/apps/{name}/cover-letter Discard a generated letter → 204.

Agent

Method Path Notes
GET /api/agent-settings What the agent may do for this tenant: allowed (whether the operator has put this account on the auto-apply allowlist — see The agent), enabled (default false), dry_run, min_fit_score (default 70), max_per_run, max_per_day, and the standing answers (work_authorization, needs_sponsorship, clearance_ok, salary_expectation with salary_period (annual / monthly / hourly, or blank) and salary_currency (USD, EUR, …) — with both stated the drafter converts a form's per month / per hour ask within the same currency and still refuses across currencies; phone, country — the country a form's picker should get; blank infers it from the résumé's location). worker_running says whether a worker runs on this instance (in this process, or an agent container whose heartbeat is fresh), worker_last_seen when one last checked in (fresh or stale, so a wedged worker is visible), and browser_available whether a browser can fill forms (here, or on that worker).
PUT /api/agent-settings Save the same shape; numbers are clamped, unknown keys ignored. Turning dry_run off queues the real click for every Ready packet whose latest dry run was clean (nothing unmapped, nothing errored, nothing left to review) and lists them in requeued.
GET /api/agent-events?limit=50 The audit trail, newest first: verdict and error rows with their detail.
GET /api/apps/{name}/packet The prepared packet: provider, questions[] (id, label, required, type, options, kind), answers{}, needs_review[] (id, reason), posting_excerpt, verdict, version. Also rides along as packet on GET /api/apps/{name}.
PUT /api/apps/{name}/packet?expected_version=… Save edited answers{}; 409 on a version mismatch (the packet's own version). Unknown ids are dropped; the review list is recomputed.
POST /api/apps/{name}/packet/prepare?force= Judge (reusing a recorded verdict unless force=true), and on proceed build the packet, draft the letter, park the application in ready, and moo → {ok, packet}. 400 on a skip verdict (with the rationale) or with no LLM endpoint. When the browser is on a worker rather than in this process, the whole build is handed to that worker instead — 202 {queued, pending, prepare: true} — so form discovery and drafting happen where the browser is and the dry run follows in the same claim.
POST /api/ready/actions The Ready lane in bulk: {action: "prepare" | "submit" | "pass", names: [...]} queues (or passes) the batch server-side in one rate-limited request → {action, dry_run, done[], skipped[{name, reason}]} (202 for the queued actions, 200 for pass). {action: "submit", all_clean: true} queues the real click for every Ready packet whose latest dry run was clean; 400 while Dry run only is still on.
GET /api/pipeline The submit queue as the worker will drain it (oldest request first), each row labelled with what the worker will do when it claims it — phase (queued / running / awaiting_code / stale), will (submit / dry_run / prepare / drop) with its reason, then_submit for a dry run that queues the real click when clean — plus ready[], the Ready packets not queued and what is holding each (promotable when a dry-run flip or Submit all clean would queue it), the agent's switches and a summary. The strip's Pipeline button opens it.
DELETE /api/apps/{name}/packet Discard the packet → 204.
GET /api/notifications telegram_enabled, has_bot_token (the token is write-only), telegram_chat_id, secrets_available.
PUT /api/notifications Any of telegram_enabled, telegram_chat_id, telegram_bot_token (omit to keep; blank clears). 400 without APPLYTRACK_SECRETS_KEY when a token is sent.
GET /api/answers The answer bank: every screening question the agent has met on a form (key, label, help, type, options), the answer it gave, whose it is (source: agent or human), how many forms asked it and when.
PUT /api/answers/{key} {answer} — make it your answer: the drafter uses it verbatim on every later form that asks this question (for a fixed list, it must name an option). Blank hands the question back to the drafter. 404 for a question never met.
POST /api/answers/{key}/apply Write your saved answer into every Ready packet that asks this question, no model → {updated, packets[]}. A packet whose fixed option list does not carry your answer is left for you to pick on. 400 unless the answer is yours (source: human).
DELETE /api/answers/{key} Forget the question; it returns the next time a form asks it.
POST /api/notifications/test Send 🐮 moo — test message to the saved chat (ignores the on/off switch) → {ok}; 502 when Telegram refuses. Rate-limited.
POST /api/notifications/mailbox/test Opens the saved mailbox over IMAP and counts the inbox; 400 with the reason when it will not open.
POST /api/apps/{name}/submit Queue a browser run: {dry_run} (default true; a real submit also needs Dry run only off in Settings · Agent and nothing left to review) → 202 {queued, dry_run}; 200 queued:false while one is already queued; 400 without a browser or a packet.
GET /api/apps/{name}/submit The queued request: pending (false with nulls when there is none), dry_run, prepare (rebuild first), timestamps.
GET /api/apps/{name}/evidence What the browser saw, newest first: kind (dry_run / submitted / failed / awaiting_code), url, confirmation, detail (a dry run's carries needs_you[] — the required questions the person still has to answer, by label; empty means clean; an awaiting_code row carries recipient and sign_in — true when the run is parked on a board's email sign-in rather than Greenhouse's security code), has_screenshot.
GET /api/board-accounts The candidate's own sign-ins on ATSs that only take applications from a signed-in account (SAP SuccessFactors): host, username, has_password, updated_at — never the password.
PUT /api/board-accounts {host, username, password} — save one; the host is normalised (career4.successfactors.com), the password is write-only and sealed with the secrets key (omit to keep, blank to clear). Returns the list.
DELETE /api/board-accounts/{host} Forget one. 204, or 404 when there was none.
POST /api/apps/{name}/security-code {code} — the security code the board emailed you, for the browser run parked on it (Greenhouse's captcha fallback); or, for a run parked on a board's email sign-in (join.com), the code or the whole link the board emailed — a link must be http(s), and the browser follows it only onto the board's own site. 202 when a run is waiting, 409 when none is; the run types the code in (or opens the link) and carries on. Replying to the 🔐 Telegram moo with the code or the link does the same thing without the app.
GET /api/apps/{name}/evidence/{id}/screenshot.png The screenshot.
POST /api/apps/{name}/verdict Judge this lead now, exactly as the worker would → {ok, verdict}; 400 with no LLM endpoint, 502 when the model can't produce a usable verdict (recorded as an error event). The latest verdict also rides along on GET /api/apps/{name} as agent_verdict.

Not in v1

GET /api/apps/{name}/check-link answers 501 with a {detail} body the SPA surfaces as a clean toast (see Roadmap). Cover-letter drafting (POST /api/apps/{name}/draft) is implemented — see Cover letters.

Data model

The schema is migrated by DbUp from idempotent .sql scripts under api/ApplyTrack.Api/Migrations/, run automatically on API startup:

Table Holds
users Accounts. A user's id is its tenant_id (tenants are users).
applications The tracked applications. UNIQUE (tenant_id, name); version for optimistic locking.
search_profiles Per-tenant discovery criteria the poller reads — keywords, filters, enabled sources, ATS boards, and custom RSS feeds.
blacklist Per-tenant blocked companies.
magic_tokens SHA-256 of issued login tokens, with expiry. Single-use.
sessions Opaque server-side sessions (instant revocation on logout).
seen The dedupe ledger — listings already surfaced, so leads don't repeat.
poll_requests The on-demand "Poll now" queue the worker drains.
resume_profiles Per-tenant résumé brief — the facts the cover-letter drafter feeds the LLM.
llm_settings Per-tenant LLM endpoint, model, cover-letter toggle, and multi-line signature; a tenant's own API key is stored AES-256-GCM-encrypted at rest.
cover_letters Generated cover letters, one per application (FK → applications ON DELETE CASCADE).
agent_settings Per-tenant agent automation configuration: on/off toggle, dry_run switch, fit-score floor, run/day caps, standing eligibility answers, salary period/currency, and preferred country.
agent_events Append-only audit log of agent fit judgements, run outcomes, and errors, keyed to the tenant (tenant_id) so deleting an application preserves the audit trail.
agent_packets Prepared application packets: detected ATS provider, discovered questions, drafted answers, human review checklist, posting excerpt, and packet version.
submit_requests FIFO queue for browser execution jobs (submit, dry_run, prepare) claimed by worker containers with FOR UPDATE SKIP LOCKED.
agent_evidence Browser execution artifacts: screenshots, page confirmation text, validation issues, labels for questions needing human intervention (needs_you), and parked states (awaiting_code).
notification_settings Per-tenant notification credentials: encrypted Telegram bot token, chat ID, and encrypted IMAP mailbox credentials for automated security-code retrieval.
agent_allowlist Operator-managed database table allowlisting tenant accounts permitted to use auto-apply automation.
agent_workers Dedicated heartbeat registry tracking active agent worker containers, browser capabilities, and heartbeat freshness (seen_at).
answer_bank Reusable repository of screening questions and answers across job forms, tracking human vs agent source, occurrence counts, and timestamps.

Account deletion relies on ON DELETE CASCADE foreign keys (migrations 0005/0006/0009): one DELETE FROM users removes every dependent row.

The discovery poller

The poller is a single container running two loops with no cron daemon (see docker/poller-entrypoint.sh):

  • Fast lane — drains the on-demand queue every DRAIN_INTERVAL seconds, so the "Poll now" button doesn't wait for the hourly pass.
  • Slow lane — a full multi-tenant poll every POLL_INTERVAL seconds.

A transient board/DB failure can't kill either loop; the next tick retries. Prefer host cron or a systemd timer? Run the CLI directly and drop the service:

Command What it does
applytrack poll Full poll across every active tenant (the hourly cron).
applytrack poll --drain Service only the on-demand poll queue (the fast cron).
applytrack poll --tenant <id> Poll a single tenant.
applytrack poll --limit <n> Cap results scanned per source (default 40).
applytrack import-md --dir <path> --tenant <id> One-shot Markdown import.

Each accepts --database-url (a libpq URL), falling back to DATABASE_URL / the POSTGRES_* env vars.

Following a company's ATS board

Settings · Criteria · ATS boards follows one company's public job board, with no key and no account. Three providers, each addressed by what its board URL already contains:

Provider What to paste Where to find it
greenhouse company slug, e.g. stripe boards.greenhouse.io/stripe
lever company slug, e.g. netflix jobs.lever.co/netflix
paylocity the board URL, or the company GUID inside it recruiting.paylocity.com/recruiting/jobs/All/<guid>/Acme

Paylocity renders its board as a client-side app, so there is no JSON API to call — but the page ships the whole job list in a window.pageData blob, which is what the poller reads. Postings flagged internal-only are skipped, and a role the board marks remote is labeled as such even when its location is a headquarters address, so the remote-only filter sees it.

Leads land with source: auto:<provider>:<slug>.

Custom RSS feeds

Beyond the built-in sources and the ATS board followers, Settings · Criteria takes any RSS 2.0 or Atom feed URL — a company's careers feed, a niche board, a saved search that publishes one. Up to 25 per account. Items are scored against the same keywords and minimum fit as every other source, and land with source: auto:rss:<host>.

The company name is read from the item title where the feed provides one (Company: Role or Role at Company); otherwise it falls back to the feed's own title, so a single-company careers feed works without any per-feed configuration.

A feed URL is user-supplied and fetched server-side, so it goes through the same SSRF guard as the link prober: http(s) on a default port only, every redirect hop re-validated, connections pinned to public addresses, a 2 MB response cap, and XXE/entity-expansion-safe parsing (defusedxml). A feed that fails is logged and skipped — it never aborts the poll. When several tenants follow the same feed, the multi-tenant pass fetches it once.

Cover letters

OSApplyTrack drafts a tailored cover letter per application from an uploaded résumé you control — provider-agnostic, and built so your data can stay on-prem.

⚠ Any LLM — or none at all. The backend is hard-required to work with any OpenAI-compatible endpoint; no vendor is baked in. And the whole engine is optional: untick Enable cover-letter drafting in Settings · AI and the app hides every drafting affordance and never calls a model for your account.

  • Bring your own model. The drafter calls an OpenAI-compatible POST {base_url}/chat/completions, so the same code points at a free local model (Ollama, vLLM, LM Studio) or any hosted provider (OpenAI, OpenRouter, Together, Groq, …). A local model means $0 per draft and the résumé never leaves the box.
  • Operator default + per-tenant override. The instance sets a default endpoint via Llm__BaseUrl / Llm__Model / Llm__ApiKey; each tenant can override any field in the Settings · AI tab (override just the model, keep the URL, set the cover-letter toggle, or save a multi-line signature).
  • Your résumé is the only source of truth. The Settings · Résumé tab uploads a text-based PDF and stores the extracted résumé text as the model brief. The LLM is told these are the only facts it may assert, so it can't invent employers or metrics.
  • It reads the posting first. When the application has a link, the server fetches the job description through the same SSRF-hardened fetcher as Autofill and puts it in the prompt, so the letter answers what the role actually asks for instead of guessing from the job title. Best-effort by design: no link, a dead page, or a JS-only posting simply drafts without it, and the model is told the posting was unavailable rather than left to invent requirements.
  • Your signature is deterministic. The model is instructed to stop after the body paragraphs. The saved signature is appended server-side exactly as entered, so drafts do not fall back to placeholders such as “The Candidate.”
  • Keys encrypted at rest. A tenant's own API key is write-only: sealed with AES-256-GCM under APPLYTRACK_SECRETS_KEY and never echoed back. Without that master key the per-tenant-key path is disabled (the instance default still works).
  • Generate from the application sheet. Each app gets a Generate cover letter action; the result renders inline with copy / download .md or PDF / regenerate / discard. PDF output is generated server-side from the saved letter. Letters are stored per application and are excluded from the export snapshot by design.

The agent

Off for everyone until the operator says otherwise. Auto-apply is gated per account by an allowlist in the database, agent_allowlist, with no API on purpose. An account not in it sees the agent switch disabled, and PUT /api/agent-settings with enabled, packet/prepare, submit and security-code answer 403; the worker never fans out over it and never claims its queue rows. To allow an account:

INSERT INTO agent_allowlist (tenant_id, note) VALUES (1, 'the operator');

Delete the row to take it away again. Standing answers and hand-run verdicts stay available to every account.

The agent is the opt-in, step-by-step automation of the application itself. It uses the same any-OpenAI-compatible endpoint as cover letters (Settings · AI) and is off by default per tenant: it spends your model budget unattended, so it must never switch itself on at a version bump.

Step 2 (this release) — a fit verdict, nothing staged. The poller's keyword score is a count, not a judgement — it is exactly what mis-scores a VB.NET title or a benefits blurb that "rages on". So before anything outward-facing can happen, the agent re-reads the posting and forms its own verdict, and can veto a keyword-inflated score:

  • Deterministic disqualifiers first, no tokens spent. A clearance you can't get, a sponsorship the employer won't give, an excluded location, an on-site role against a remote-only profile — checked in code, and a hallucinating model can never un-veto them.
  • Then the model, judging strictly against your résumé brief and your standing eligibility facts. It must answer in JSON; a reply that doesn't parse is re-prompted once with the error, and a second failure is recorded as an error — never as "proceed".
  • Every verdict is on the record. agent_events is keyed to your account, not the application, so deleting an application never erases what the agent did. A skip lands with its reason and shows on the application sheet — a veto is visible, not silent.
  • Its own bar. Min fit score (default 70) is separate from discovery's (55): "worth showing me" and "worth spending tokens on" are different questions. Plus per-pass and per-day caps.
  • Evaluate by hand. Any lead's sheet has Evaluate fit once the agent is enabled; it runs the identical judgement the worker would, right now.

Step 3 — the packet and the Ready queue. A proceed verdict becomes a prepared packet, and the application moves to ready — the queue of things waiting for you to submit:

  • The form. Greenhouse publishes its application form without an employer key (boards-api.greenhouse.io/v1/boards/{board}/jobs/{id}?questions=true), so a Greenhouse posting gets its real questions, types and options with no browser. Every other ATS gets the standard set (name, email, phone, LinkedIn, résumé, letter) and the copy-and-open path.
  • The answers. Name, email, phone, links, work authorization, sponsorship, clearance and salary come straight from your résumé and Settings · Agent and never reach the model. Salary expectations include an explicit period (salary_period: annual / monthly / hourly) and currency (salary_currency: USD, EUR, …); the drafter converts within the same currency (e.g. annual ÷ 12 for monthly, ÷ 2080 for hourly) and strictly refuses cross-currency asks. Standard profile fields (name, email, phone, links) are refreshed dynamically from your profile at form-fill time, so editing your contact info updates all pending applications without needing a rebuild. The screening questions go to the model in one call, grounded strictly in your résumé brief and the posting; anything it can't answer from those facts is left blank and flagged for you, never invented. EEO / demographic questions (gender, race, veteran and disability status) are never guessed: answer each once — on any packet, or in Settings · Answers — and it goes on every form that offers the same option; a form that words it differently stays blank. CAPTCHA boxes are never answered at all.
  • The cover letter is drafted (if you allow it) and stored as usual.
  • Review, then apply. The application sheet shows the packet with every answer editable, an alert listing what still needs you (Submit stays blocked until it's empty), the posting excerpt the agent judged, and Copy answers and open the posting — one click puts every answer on your clipboard and opens the job, which works for every ATS and turns a 15-minute application into a 1-minute one.
  • Prepare by hand from any lead's sheet, or let the worker do it unattended.
  • Settings · Answers is the answer bank: every screening question the agent has met on a form, with the answer it gave, in one place. Edit one and save it as yours and the agent uses your words on every later form that asks the same question — no model call, and a wrong answer is corrected once instead of per application. New questions land there as packets are built, blank when the agent had no answer.
    • Editable first/last names: Name splitting keeps middle initials out of the last name ("Aaron K. Clark" → First: "Aaron", Last: "Clark"). First Name and Last Name are first-class rows in the Answer Bank, seeded from your résumé, pinnable as human source, and honored on every subsequent form.
    • Apply to Ready packets: Clicking Apply to Ready packets (POST /api/answers/{key}/apply) immediately writes your saved answer into every existing Ready packet asking that question, updating them without a model call.
    • Web component & label discovery: On complex forms (e.g. Zoho Recruit), discovery inspects up to 12 DOM ancestor levels for component attributes (label, data-label, *-prop-label) and row labels, detects required asterisks (*), and disambiguates controls that share generic IDs. Questions whose label is merely an opaque ID are never sent to the model (required ones become human-review items, optional ones stay blank), and social profile fields (LinkedIn, Facebook, X) only take matching links from your profile.

The moo. Settings · Notifications takes your own Telegram bot token (write-only, encrypted with APPLYTRACK_SECRETS_KEY like the LLM key) and chat id. When a packet lands in ready you get one message — 🐮 moo — Acme · Engineer is ready to submit — with a link that opens that application (App__PublicBaseUrl must be set for the link). Exactly one per packet, recorded in the agent log; a failed send never blocks the packet. Send test message checks the wiring first. The api container needs outbound HTTPS to api.telegram.org. The bot is two-way in one case: when a run is parked on Greenhouse's emailed security code and there is no mailbox to read it from, the 🔐 moo asks for the code and the worker reads the bot's inbox (getUpdates, no webhook) until you reply to the moo with it — from the configured chat only, 4–16 letters and digits, the same shape the app's paste box takes. The same park serves a board that signs you in by email before it shows its form (join.com): a real Submit types your address, presses Continue, and the 🔐 moo asks for the code — or the link — the board emailed; either one, replied or pasted, takes the run on to the form in the same browser session. A link is followed only onto the board's own site. Nothing else sent to the bot is acted on.

Step 4 — the browser fills it in; you click Apply. With a browser container configured, a prepared packet gets a dry run automatically: the browser opens the posting, fills every mapped answer, attaches your résumé PDF from memory, takes a screenshot, and stops. The moo then says filled in and ready for you to click Apply — or, when the fill stopped on questions only you can answer, filled in — 2 questions need you: expected monthly salary, …, and the evidence row reads needs you (2) rather than clean — the sheet shows the screenshot, and Submit application queues the real thing — only when nothing is left to review, and only once you have untied Dry run only in Settings · Agent (on by default: you can watch it correctly fill thirty real postings without applying to one). Flipping that switch off queues the real click for every Ready packet whose dry run was already clean, and the worker's pass does the same on every tick, so nothing proven sits in Ready waiting to be revisited. A submission is recognised by its confirmation text, recorded with a screenshot, marks the application applied, and moos ✅.

  • Bulk Ready lane: Filter the list to Ready and each card gets a checkbox, with Prepare selected, Submit selected, Pass selected and Submit all clean in a top toolbar — one request per action (POST /api/ready/actions), so rate limits apply to the batch rather than per application.
  • Queued prepare: POST /api/apps/{name}/packet/prepare delegates to the worker (prepare: true in submit_requests) when the browser is on the agent container, running form discovery, drafting, and dry run in a single claim.
  • Consent banner dismissal: Cookie-consent overlays (OneTrust, Cookiebot, Zoho, Workable, and generic "Accept all" boxes) are clicked away before Apply, on new tabs opened by Apply, and before filling the form.
  • Attach verification & error recovery: With a PDF on hand, a failed attach always marks the field unmapped and refuses the click. The submitter monitors for 2.5 seconds after attach to catch asynchronous uploader errors (e.g. Greenhouse uploadFile exceptions), sweeps [role=group][aria-required] containers, and falls back to the "Enter manually" textarea if the form still requires a résumé.
  • Worker heartbeat: The agent worker runs a dedicated 30-second heartbeat timer updating agent_workers, exposing worker_running and worker_last_seen in Settings · Agent so wedged or disconnected workers are immediately visible.

The pipeline view

Click the PIPELINE label in the dashboard status strip to open the Pipeline view modal. It provides full transparency into the background automation queue and worker decision logic:

  • Queue in claim order: Lists every pending submit request for the tenant in the exact FIFO order the worker claims them (oldest first).
  • Pre-evaluated gates: Previews what the worker will do for each request (submit, dry_run, prepare, or drop) and why (e.g. Dry run only is on in Settings · Agent, missing answers, allowlist state), along with its current phase (queued, running, awaiting_code, stale) and then_submit auto-promotion flag.
  • Ready packets status: Lists Ready packets not currently queued and what is holding each (holding: promotable, needs_you, unmapped, errored).
  • Submit all clean: An immediate button to queue real submissions for all clean Ready packets currently waiting on promotion.
  • Auto-refresh: Live-polls and refreshes every 15 seconds while open.

ATS submission APIs are not available to applicants (Greenhouse/Lever/Ashby all require an employer key), so browser form-fill is the only general mechanism — and it is the component that bypasses every SSRF guard we have, which is why it is contained rather than trusted:

  1. The same URL + resolved-address pre-flight as the scraper.
  2. The browser has no route anywhere. It runs in its own container (docker/browser, Playwright's image running run-server — the api image stays read-only/noexec, which Chromium cannot run under) on an internal network whose only exit is the forward proxy.
  3. The proxy does the DNS (docker/proxy, squid): with HTTPS_PROXY set Chromium emits CONNECT and never resolves a name itself, so DNS rebinding is structurally impossible at the browser; the proxy also refuses private, loopback, link-local and CGNAT destinations and non-web ports.
  4. Route interception aborts non-http(s) requests and off-site top-level navigations.
  5. A least-privilege Postgres role for the agent (AGENT_DB_PASSWORD creates applytrack_agent; the API grants it on every boot): no DELETE anywhere, no access to sessions or magic_tokens. Since Chromium runs without its own sandbox in a container, this is what decides how bad a renderer escape is — "write rows the agent already writes", not "read every session token".

MyGreenhouse, signed in. Greenhouse's candidate portal (my.greenhouse.io) lists the newest postings across every Greenhouse board once you are signed in, and the poller can pull from it as the MyGreenhouse source in Settings · Criteria (off by default). It needs two things you already may have: a board account for greenhouse.io (Settings · Agent · Board accounts — username only, the portal has no password) and your mailbox (Settings · Notifications), because the portal signs you in by emailing a security code. The agent's browser does that sign-in — the portal only sends the mail for a real browser's click — reads the code from the mailbox, and keeps the fortnight-long session sealed on the board-account row; it renews it two days before it runs out, and again whenever the portal bounces it, so the code is read once in a while, not every poll. The poller only searches with the kept session. It searches your keywords (capped) a page each plus the newest postings, remote only when your profile is remote-only, and every listing links to the employer's own Greenhouse board, which the packet builder reads through the Job Board API as usual.

LinkedIn, your own account — the employer's posting, never Easy Apply. LinkedIn lists more of the market than any one board, but its postings come in two kinds: Easy Apply (the application stays inside LinkedIn) and offsite apply (Apply leads to the employer's careers site or ATS). The LinkedIn source in Settings · Criteria (off by default) wants the second kind only: it searches your keywords (a page each, postings from the last day, remote when your profile is), reads where each matching posting's Apply leads, and stages the lead with the employer's posting as its link — the same rule every aggregator gets. Easy Apply-only postings are skipped and remembered, and a posting the ledger already knows is never read again, so the account's request budget stays small. Save a board account for linkedin.com with its password (Settings · Agent · Board accounts): the agent's browser signs in as you — LinkedIn's official API only opens job search to approved partners — and keeps the year-long session sealed on the row, renewing it well before it runs out or whenever LinkedIn bounces it. A sign-in from a device LinkedIn has not seen is challenged: a tap in the LinkedIn app (the moo asks you for it and the browser waits a few minutes) or an emailed PIN (read from your mailbox). With no account, or no kept session yet, the source falls back to LinkedIn's guest job search — the approach JobSpy uses — which needs no sign-in but is rate-limited hard.

Step 5 — Lever, Ashby, and the long tail. Only Greenhouse publishes its form schema. Lever, Ashby, Workable, Breezy, SmartRecruiters and join.com forms are discovered read-only in the browser — the agent visits the form one hop past the posting (Lever's /apply, Ashby's /application, Workable's …/j/{id}/apply/, Breezy's /p/{id}/apply; SmartRecruiters and join.com open theirs behind the Apply click, which is followed, new tab included, and bounded so a button that never wakes up is a named reason rather than a timeout — and join.com's sits behind an email sign-in besides, so a dry run stops there and says so, while a real Submit signs in as you and parks for the emailed code or link, see the moo above), enumerates every control by its accessible name (field name, type, options, required), never types, never clicks — and the result is the same question list Greenhouse's API gives, so the answer drafter and the submitter need no per-ATS code. A company careers page that embeds a Greenhouse job (?gh_jid=) has its board token read off the page, so it takes the API path. A lead whose link is a job aggregator's listing (remoteOK, Remotive, We Work Remotely, …) has no form on it: the poller follows the listing's Apply to the employer's posting and stores that; one it could not resolve is prepared and mooed as apply-by-hand, never run.

  • "Apply later" trap avoidance: Boards like join.com render a side "Apply later" box (one email input + submit) that simply emails the link to the applicant. The agent requires at least two text-like controls before treating a container as an application form, never takes anything matching "later / send me the link / remind / subscribe / alert" (in EN, FR, DE, ES, PT) as the Apply trigger or Submit button, and ignores side forms during required-field sweeps. Localized Apply buttons (Postuler, Bewerben, Aplicar, Solicitar, Candidat…) are recognized.
  • Provider re-detection & closed postings: The ATS provider is re-evaluated from the URL at submit time so newly supported ATS boards are handled without recreating packets. Postings returning HTTP 404 or 410 are cleanly reported as "gone" / closed rather than failing as "no form found".

Anything else is the long tail: a generic adapter that fills by field label and refuses to click if any required field is unmapped, off by default behind Let the browser fill forms on ATSs it doesn't know in Settings · Agent. Workday stays manual, permanently — applying needs an account with the employer's tenant, email verification and a multi-step wizard — so it is detected, the packet is prepared, and the sheet routes you to copy-and-open. SAP SuccessFactors also only takes an application from a signed-in candidate account, but there the account is yours to give: save the sign-in you created on that careers site under Settings · Agent · Board accounts (host, username, a write-only password sealed with the secrets key) and the browser signs in with it when Apply now leads to career*.successfactors.com, opens the folded sections of the application, counts the résumé already on your account as attached, fills what is still empty, presses Apply and answers the "are you sure" once. Without a saved account the run says which host wants one. The browser never creates accounts. A SuccessFactors career site on the employer's own domain (Kiewit's, say) is not knowable from the link: the browser learns it at the Apply click. The careers site's own job-search and job-alert boxes are never mistaken for the form. The agent never guesses on EEO/demographic questions (it fills them only from the answers you saved once), file fields other than the résumé, or any answer the model wasn't confident about — those block Submit until you resolve them.

Running it. The worker is the API image with Agent__Enabled=true and no published port — the compose files start it as the agent service, and deploy/quadlet/applytrack-agent.container is the systemd unit. It holds a database connection across multi-minute model calls, which is why it is a separate container with its own small pool rather than a thread in the API. Two containers now migrate on boot; the migrator serializes on an advisory lock.

Security & hardening

OSApplyTrack is built to face the public internet behind a reverse proxy:

  • No account enumeration. POST /api/auth/request returns an identical 200 for known, unknown, and malformed addresses.
  • Single-use, short-lived tokens. Login tokens are 15-minute, one-shot, and stored only as SHA-256. Sessions are opaque and server-side, so logout revokes instantly (no stranded JWTs).
  • Hard tenant isolation. Repositories are DI-scoped per tenant; every query filters tenant_id. There is no endpoint path that reads across tenants.
  • Strict security headers on every response (custom middleware), and the app is their single source of truth: a tight Content-Security-Policy (script-src 'self', no inline scripts), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, and HSTS once the request is HTTPS. Don't re-add these at your reverse proxy. A second copy duplicates every header and, worse, invites the two sources to drift apart in the weaker direction — an edge X-Frame-Options: SAMEORIGIN alongside the app's DENY, say. Let the app own the set; if your proxy adds its own headers by default, drop the ones the app already sends. Also turn off the proxy's version banner (server_tokens off; on nginx, or the equivalent) so it doesn't advertise its exact build.
  • Output sanitization. User Markdown is rendered with marked and scrubbed through DOMPurify before it touches the DOM — defense in depth against stored XSS even though the poller already strips HTML at ingestion.
  • Encryption at rest, on for every deploy. The résumé PDF, cover letters, the agent's packet answers and posting excerpts, evidence screenshots, and a tenant's own LLM API key and Telegram bot token are sealed with AES-256-GCM before they reach the database. With no APPLYTRACK_SECRETS_KEY set, the api generates a key on first run and keeps it in a key file; it never runs plaintext. See Encryption at rest for what is and is not covered.
  • Rate limiting. The magic-link and poll endpoints are per-IP fixed-window rate-limited so the always-200 auth surface can't be abused for spam or probing.
  • Bounded API input. Ordinary JSON mutations are capped at 1 MiB, application strings/notes have explicit length ceilings, and criteria/résumé collections reject excessive cardinality with a clear 400 instead of growing rows or LLM prompts without limit.
  • SSRF-hardened outbound fetches. Every server-side fetch of a URL a user supplied — the link prober, the Autofill scraper, and custom RSS feeds — refuses to connect to private/loopback/link-local/reserved addresses, re-checks every redirect hop, and caps the response size, so a hostile listing or feed URL can't pivot into your network.
  • Behind HTTPS. Front the API with a TLS-terminating reverse proxy (Caddy, nginx, or tailscale serve). Same-host loopback proxies are trusted by default. For a proxy container or remote load balancer, set FORWARDED_HEADERS_KNOWN_PROXY to its source IP or FORWARDED_HEADERS_KNOWN_NETWORK to its CIDR. Forwarded headers from every other address are ignored, so a direct caller cannot forge its rate-limit IP or HTTPS state. Don't expose Kestrel directly.
  • Change the default password. For any deployment reachable beyond localhost, change POSTGRES_PASSWORD (and the matching connection string) from the bundled development default before first boot — the documented value is not a production secret. The hardened docker-compose.production.yml has no password default and refuses to start until one is supplied.
  • Dependency CVE watch. .forgejo/workflows/audit.yml runs dotnet list package --vulnerable --include-transitive and pip-audit on every push/PR and weekly, failing the build on a known-vulnerable dependency. Run the same two commands locally any time. The weekly run is the one that matters: an advisory published after a merge turns main red with no commit to blame, so .github/workflows/ci.yml files an issue when a scheduled or push-to-main build fails — commenting on the open one rather than opening a fresh issue every Monday. Pull requests are excluded; their author already sees the failing check.

Encryption at rest

Every self-hosted database holds the operator's and their tenants' most personal material. Since 1.26 the API seals it before it is written, so a database file, a dump, or a read-only connection yields ciphertext:

Sealed Not sealed (and why)
resume_profiles.source_pdf — the uploaded résumé applications.company, role, status, score, dates — they drive the list, sorting and stats
cover_letters.body applications.notes — written by the Python poller too, and shown in the list snippet; a cross-runtime change, deferred
agent_packets.answers and posting_excerpt users.email — the login identity has to be looked up; needs a lookup hash plus an encrypted copy, deferred with contact_email and phone
agent_evidence.screenshot — the filled form the résumé's extracted fields (summary, experience, …) — the same shape as the PDF and next in line; deferred
llm_settings.api_key_ciphertext, notification_settings.telegram_bot_token_ciphertext

The key. APPLYTRACK_SECRETS_KEY if set; otherwise the api generates a 48-byte random key on first run, writes it owner-only to APPLYTRACK_SECRETS_KEY_FILE (/var/lib/applytrack/secrets.key in a container — the compose files and quadlets mount a secrets volume there, shared with the agent container) and reuses it from then on. A deploy that can do neither refuses to start and says why. A generated key lives on the same host as the data: it stops an offline database copy, not someone with the whole box. The hardened production stack therefore requires the operator to set the key. Back the key up with the database. Losing it loses everything in the left column; the API cannot recover it for you.

Upgrading. On the first boot after upgrading, the api sweeps every covered column and seals what the previous release stored in the clear. The sweep is idempotent and runs on every boot; it only rewrites rows that are not already under the current key.

Rotating. Set the new key as APPLYTRACK_SECRETS_KEY and the old one as APPLYTRACK_SECRETS_KEY_PREVIOUS, restart the api, wait for the log line encryption at rest: sealed N value(s), then remove the previous key. Rows under a key the instance no longer has are logged and left alone; their owner re-enters the value.

Your data

  • ExportGET /api/account/export returns a single JSON snapshot of your whole account: every application (all fields + its slug, so apply links survive a move), your search criteria, and your company blacklist. A real backup, and the door's never locked.
  • ImportPOST /api/account/import loads a snapshot back. Applications upsert by slug (an incoming app overwrites a matching local one, new slugs are added, untouched apps stay), so re-importing is idempotent. The whole load runs in one transaction — a mid-import failure leaves your account untouched. Use it to migrate from one instance to another: export here, import there.
  • ShareGET /api/account/export/shared exports a peer-shareable opportunity list: only the facts of each posting (company, role, link, location, source, plus the slug for de-dup). Status, notes, contacts, dates, score, and salary are stripped at the source. A peer imports the file and every entry lands as a fresh lead; anything they already track is skipped, never overwritten. All three live in Settings · Account.
  • DeleteDELETE /api/account removes your account and, via ON DELETE CASCADE, every row that belongs to it (applications, search profile, blacklist, seen ledger, queued polls, sessions, tokens) in one statement.

Accessibility

The web interface targets WCAG 2.2 Level AA. It provides semantic screen-reader navigation, complete keyboard operation, visible focus, labeled controls, live status announcements, reduced-motion and high-contrast modes, light/dark/system colors, adjustable 100%/125%/150% text size, comfortable or compact spacing, responsive reflow through 400% browser zoom, and mobile touch scroll containment (overscroll-behavior: contain) so swipe gestures scroll the application list without triggering browser pull-to-refresh. Dialogs and drawers (such as Settings and the Pipeline view) follow standard ARIA modal semantics (role="dialog", accessible titles, focus trapping, Escape dismissal). The Settings · Accessibility panel detects system preferences before sign-in and lets each browser override color, contrast, motion, text size, and density. Preferences are stored only in the current browser.

Pull requests run Playwright and axe-core checks against login, application, editor, pipeline drawer, settings, validation, and responsive workflows. See the accessibility statement and manual test matrix, or use the Accessibility problem issue template to report a barrier without sharing private data.

First-run import (optional)

If you're coming from the original single-user applytrack, import your existing Markdown applications. Sign in firsttenant_id is a real foreign key to your user account (so deleting the account cascades cleanly), which means a tenant must exist before any data is written under it. Then point the importer at your applications/ folder and your tenant id (find it in the API logs or the users table — it's not necessarily 1 if other accounts exist):

docker compose run --rm \
  -v "$PWD/applications:/data" \
  --entrypoint applytrack \
  poller import-md --dir /data --tenant <your-tenant-id>

Local development

Run Postgres in a container and the two runtimes on the host:

docker compose up -d db

# API (reads appsettings.json → localhost Postgres) — serves the whole app on
# http://localhost:5049 (per launchSettings.json; the Docker setup uses 8080).
# In the default configuration the magic-link login URL is printed to this
# console; click it to sign in.
cd api && dotnet run --project ApplyTrack.Api

# Poller (one-shot poll; needs DATABASE_URL or the POSTGRES_* / PG* env vars)
pip install -e '.[dev]'
DATABASE_URL=postgresql://applytrack:JanewayDidNothingWrong@localhost:5432/applytrack applytrack poll

Enable cover-letter drafting (optional). Drafting stays off until an OpenAI-compatible endpoint and model are set. For local testing, point the API at Ollama (ollama serve, then ollama pull llama3.1:8b):

cd api
Llm__BaseUrl=http://localhost:11434/v1 Llm__Model=llama3.1:8b \
  dotnet run --project ApplyTrack.Api

See Cover letters for hosted providers, per-tenant keys, and the Settings · AI tab — and .env.example for the same settings, annotated.

Regenerating the README screenshots. The images in this README come from a running app, not from the mocked Playwright suite, so the UI they show is the real one. scripts/demo-seed.json is the account they shoot — five applications spread across the pipeline. With the API running on localhost:5049, sign in, then import the seed and capture:

# SID = your session cookie (applytrack_session) from the browser or the sessions table
curl -X POST http://localhost:5049/api/account/import \
  -H 'Content-Type: application/json' -H "Cookie: applytrack_session=$SID" \
  --data-binary @scripts/demo-seed.json

APPLYTRACK_SESSION_NAME=applytrack_session APPLYTRACK_SESSION_VALUE="$SID" \
  npm run screenshots

The import is slug-preserving, so re-running it updates the same five entries rather than piling up duplicates. Retake the shots whenever a change lands that is visible in the sidebar or on an application sheet.

Tests

# .NET — xUnit + Testcontainers (needs a Docker-API-compatible runtime)
cd api && dotnet test

# Local Podman / podman-machine
./scripts/test-dotnet-podman.sh

# Python — pytest (offline; no DB/network), plus lint + types
pytest
ruff check .
mypy src

# Web — Playwright keyboard/responsive tests + axe-core WCAG checks
npm ci
npx playwright install chromium
npm run test:web

The .NET suite drives the live HTTP stack with WebApplicationFactory against a throwaway Postgres (Testcontainers), including the auth spine and cross-tenant isolation. The Python suite is fully offline (fakes for the DB and HTTP transport). Testcontainers reads its runtime from DOCKER_HOST / DOCKER_CONTEXT; the Podman wrapper discovers the active Podman socket and disables Ryuk for rootless Podman. Set PODMAN_SOCKET=/path/to/podman.sock if your socket lives somewhere custom.

Project layout

api/                      the .NET solution
  ApplyTrack.Api/         Minimal API host
    Endpoints/            auth, apps, criteria, blacklist, account
    Middleware/           tenancy choke-point, security headers, error mapping
    Migrations/           DbUp .sql scripts (the schema = the contract)
    wwwroot/              the vanilla-JS SPA (served by the API)
  ApplyTrack.Api.Tests/   xUnit + Testcontainers
src/applytrack/           the Python poller + CLI
docker/                   poller entrypoint (two-cadence loop)
docker-compose.yml        db + api + poller
docker-compose.production.yml  hardened self-hosting stack
Dockerfile.poller         the poller image

Roadmap

v1 is intentionally focused. Deferred, with clean seams already in place:

  • Link checking. /api/apps/{name}/check-link returns 501 today; the SSRF-hardened prober already exists in the poller for when it's enabled.
  • Richer cover-letter output. The materials engine already ships plain-text/ Markdown letters (Cover letters) with direct Markdown and PDF downloads; future work can add richer templates and provider-specific formats.

Contributing

Issues and PRs welcome. Please keep the cross-runtime contract intact (every query filters tenant_id), add tests for new behavior, and keep the SPA dependency-free. Both test suites and the dependency audit run in CI.

License

Apache-2.0. Copyright 2026 Aaron K. Clark.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

About

WCAG 2.2 AA-focused, multi-tenant, self-hosted job tracking application with AI Cover letter generation, PDF Import, and automated job discovery.

Topics

Resources

Stars

16 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages