# OSApplyTrack > ### Your job hunt, self-hosted and on autopilot. ### ▶ [Try out the app here →](https://osapplytrack.cryptojones.dev/) > Live demo instance. Sign in with a magic link — each visitor gets their own tenant. [![CI](https://github.com/CryptoJones/OSApplyTrack/actions/workflows/ci.yml/badge.svg)](https://github.com/CryptoJones/OSApplyTrack/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/CryptoJones/OSApplyTrack?logo=github&sort=semver)](https://github.com/CryptoJones/OSApplyTrack/releases/latest) [![GHCR](https://img.shields.io/badge/ghcr.io-images-2496ED.svg?logo=docker&logoColor=white)](https://github.com/CryptoJones?tab=packages&repo_name=OSApplyTrack) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10-512BD4.svg?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) [![C#](https://img.shields.io/badge/C%23-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) [![ASP.NET Core](https://img.shields.io/badge/ASP.NET_Core-512BD4.svg?logo=dotnet&logoColor=white)](https://learn.microsoft.com/aspnet/core/) [![Python 3.12+](https://img.shields.io/badge/Python-3.12+-3776AB.svg?logo=python&logoColor=white)](https://www.python.org/) [![PostgreSQL 17](https://img.shields.io/badge/PostgreSQL-17-4169E1.svg?logo=postgresql&logoColor=white)](https://www.postgresql.org/) [![Docker](https://img.shields.io/badge/Docker-Compose-2496ED.svg?logo=docker&logoColor=white)](https://docs.docker.com/compose/) [![JavaScript](https://img.shields.io/badge/JavaScript-vanilla-F7DF1E.svg?logo=javascript&logoColor=black)](./api/ApplyTrack.Api/wwwroot) [![Multi-tenant](https://img.shields.io/badge/multi--tenant-yes-success.svg)](#data-model) [![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success.svg)](#quickstart-docker) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) **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](#why-osapplytrack) - [Architecture](#architecture) - [Quickstart (Docker)](#quickstart-docker) - [How it works](#how-it-works) - [Configuration](#configuration) - [API reference](#api-reference) - [Data model](#data-model) - [The discovery poller](#the-discovery-poller) - [Cover letters](#cover-letters) - [The agent](#the-agent) - [The pipeline view](#the-pipeline-view) - [Security & hardening](#security--hardening) - [Your data](#your-data) - [Operating an instance](#operating-an-instance) - [Observability](#observability) - [Accessibility](#accessibility) - [First-run import](#first-run-import-optional) - [Local development](#local-development) - [Tests](#tests) - [Project layout](#project-layout) - [Roadmap](#roadmap) - [Contributing](#contributing) - [License](#license) --- ## 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) ```sh 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](deploy/quadlet/) 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: ```sh 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): ```sh 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](#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: ```sh 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), bound to the requesting browser by a pre-auth `HttpOnly` cookie. Opening the link (`GET /api/auth/verify`) changes nothing — it shows a one-button confirm page, so mail link-scanners can't burn the token. The button POSTs the token back; that spends it only in the browser that asked for it (no login CSRF), replaces any session that browser already had, mints a **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. Open the link in the browser you requested it from. Only the session id's SHA-256 is stored, so a database read yields no live cookies. A session ends after 30 days unused (each use slides it forward) and 90 days after sign-in at the latest; **Settings · Account** lists where you're signed in and can sign out everywhere else. An operator disables an account by setting its `users.status` to anything but `active` (e.g. `UPDATE users SET status = 'disabled' WHERE email = '…'`): its sessions stop working on the next request and it gets no new sign-in links. **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/`` 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`](./.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](#cover-letters). | | `Llm__DailyCapPerTenant` | `200` | Model requests (draft, verdict, prepare) one tenant may make per UTC day on the operator's `Llm__ApiKey`; past it they get a 429 until tomorrow. A tenant on its own key or endpoint, or an instance with no key, is not metered. `0` = unlimited. The model routes are also throttled to 10 per 5 minutes per IP and per tenant. | | `TYPESAFE_API_KEY` / `TYPESAFE_BASE_URL` / `JEV_MODEL` | _(empty)_ / `https://api.typesafe.ai` / `jev-latest` | Optional, poller only. With a key set, a tenant who ticks **Score new listings with Jev** in **Settings · Agent** has each new listing judged by TypeSafe's Jev — is the posting's own work the kind the keywords describe? — and that 0–100 fit replaces the keyword score against the minimum fit score. Each new listing's title and description go to TypeSafe. Unset (the default), on any error, or for any tenant who has not opted in, keyword matching decides exactly as before. Evaluation: [`tools/jev_eval/RESULTS.md`](tools/jev_eval/RESULTS.md). | | `APPLYTRACK_SECRETS_KEY` | _(generated)_ | Master key (AES-256-GCM) for [encryption at rest](#encryption-at-rest): the résumé PDF, cover letters, packet answers and posting text, evidence screenshots, each tenant's own LLM API key and Telegram bot token, and the board sessions the poller signs in with. The poller must see the same key. 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 and mounted read-only into the poller, which reads the key but never generates one. | | `APPLYTRACK_SECRETS_KEY_PREVIOUS` | _(empty)_ | The old key(s), `;`-separated, during a [key rotation](#encryption-at-rest): 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-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**. | | `Agent__LlmTimeoutSeconds` | `300` | The worker's own deadline for one model completion. `Llm__TimeoutSeconds` (120) guards a request a person is waiting on; the worker is a background job, and a packet's drafting call used to die at exactly that deadline. Screening questions are drafted in chunks of 8, each under this budget, so one stalled completion voids one chunk, not the packet. `0` inherits `Llm__TimeoutSeconds`. Set on the `agent` service. | | `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](#the-agent), step 4. | | `AGENT_DB_PASSWORD` | _(required in production)_ | Set on the **api** (the migrating container): it creates the least-privilege `applytrack_agent` Postgres role on boot, or resets its password to this, and re-applies its grants. The `agent` container connects as it with `Migrations__Mode=wait` (it cannot migrate, so it waits for the api to). | | `POLLER_DB_PASSWORD` | _(required in production)_ | The same for the poller's `applytrack_poller` role, which the `poller` container's `DATABASE_URL` connects as: it reads profiles, blacklists and the dedup ledger, stages leads, and keeps board sessions, with no access to sessions, sign-in tokens, résumés, board passwords or DDL. Unset, the role is not created and the poller keeps whatever `DATABASE_URL` says (the quickstart uses the owner). | | `App__PublicBaseUrl` / `AllowedHosts` | _(empty)_ / `*` | The public origin sign-in links are built from (scheme, no trailing slash) — never the request `Host` header, which an attacker controls. Unset, links fall back to the request origin only for `localhost`/`127.0.0.1`; any other Host gets no link. **Required once `Email__Host` is set** (the api refuses to boot without it). `AllowedHosts` pins the Host headers the api answers at all. Compose reads them as `APP_PUBLIC_BASE_URL` / `ALLOWED_HOSTS`. | | `Retention__ScreenshotDays` / `Retention__ScreenshotsKeptPerApplication` | `90` / `3` | The daily [retention sweep](#your-data) clears the bytes of an evidence screenshot older than this (the evidence row and its text stay). Never a submission's, never one of an application still in Ready or Errors, never an application's newest three. `0` = keep every screenshot. | | `Retention__ExpiredGraceDays` | `1` | Sessions and magic-link tokens are deleted this many days after they expire. | | `Retention__AgentEventDays` | `180` | Agent audit events older than this are deleted — never a verdict, a packet build or an account created in your name, and never one about an application still a lead or in Ready. `0` = keep all. | | `Retention__SeenDays` | `0` (off) | Forget poller dedup keys older than this. Off by default: a forgotten key lets the poller stage that listing again if you deleted its application. | | `Retention__PollRunDays` | `30` | The poller's per-pass records (`poll_runs`) older than this are deleted — never an account's newest full pass or newest fast-lane pass, which the list's "last polled" line reads. `0` = keep all. | | `Retention__Enabled` / `Retention__IntervalHours` / `Retention__InitialDelaySeconds` | `true` / `24` / `300` | The sweep runs in the migrating (api) container only, first five minutes after boot. | | `OTEL_EXPORTER_OTLP_ENDPOINT` | _(empty — off)_ | Opt-in [OpenTelemetry](#observability): set it on the api and/or agent container to push ASP.NET Core, HttpClient and Npgsql traces and the app's metrics to your collector over OTLP. Unset, the SDK is not even registered and nothing leaves the process. The standard `OTEL_EXPORTER_OTLP_PROTOCOL` / `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_SERVICE_NAME` apply. | | `Metrics__Port` | _(empty — off)_ | Serve a Prometheus [`/metrics`](#observability) on this extra port — and only on it; the app's own port answers 404 for `/metrics`. Publish it to your scraper's network only (e.g. `127.0.0.1:9464:9464`), never through the reverse proxy. | | `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 and, per account, email notifications (Settings · Notifications). Set the same values on the `agent` container, which sends the notifications. 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 or a personal API token sent as `Authorization: Bearer <token>` (see [API tokens](#api-tokens)); 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); sets the pre-auth cookie the link is bound to. Per-IP rate-limited. | | `GET` | `/api/auth/verify?token=…` | The emailed link. Renders a confirm page; spends nothing. | | `POST` | `/api/auth/verify` | Form `token=…`. Spends the single-use token if this browser requested it, rotates the session cookie, 302 → `/` (else `/?error=invalid_link`, `/?error=wrong_browser` or `/?error=account_disabled`). | | `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/analytics?since=…` | Funnel analytics over the status history, for applications first applied for on or after `since` (a `YYYY-MM-DD` date or ISO timestamp, UTC; all time when absent): `{since, applied, responded, rejected, response_rate, median_days_to_response, funnel:[{stage,count}], by_source:[…], by_lane:[…]}`, each breakdown row `{key, applied, responded, response_rate, median_days_to_response}`. A response is the first move to screen, onsite, offer or rejected. | | `GET` | `/api/apps/{name}/history` | The application's timeline, oldest first: `[{kind, from_status, to_status, at, note}]`. `kind` is `status` for a status it entered (`from_status` is `null` on the row it was created with), `interview` for a scheduled interview (with its `note`) and `followup` for its follow-up date (midnight UTC of that day) and `note` for an entry of its notes log (the text in `note`); all but `status` carry an empty `to_status`. | | `GET` | `/api/apps/{name}/interviews` | The application's interviews, soonest first: `[{id, at, note}]`. | | `POST` | `/api/apps/{name}/interviews` | Schedule one: `{at, note}` — `at` an ISO 8601 instant (no offset means UTC), `note` optional free text. **201** with the interview. | | `DELETE` | `/api/apps/{name}/interviews/{id}` | Remove one. **204**, or **404**. | | `GET` | `/api/apps/{name}/notes` | The application's notes log, oldest first: `[{id, at, body}]`. | | `POST` | `/api/apps/{name}/notes` | Log one: `{body, at}` — `at` optional (an ISO 8601 instant; now when absent). Notes are never edited. **201** with the note. | | `DELETE` | `/api/apps/{name}/notes/{id}` | Remove one. **204**, or **404**. | | `GET` | `/api/apps/{name}/contacts` | The people on the application: `[{contact, role}]` — `contact` as below, `role` their part in this process (recruiter, hiring manager, panel…). | | `POST` | `/api/apps/{name}/contacts` | Put someone on it: `{contact_id, role}`, or `{contact:{name, email, …}, role}` to add a new contact and link them at once. Linking again replaces the role. **201** with `{contact, role}`. | | `DELETE` | `/api/apps/{name}/contacts/{id}` | Take a contact off the application (the contact stays). **204**, or **404**. | | `GET` | `/api/due?today=YYYY-MM-DD` | What needs you: `{today, followups:[{id, name, company, role, status, followup, overdue}], interviews:[{id, name, company, role, at, note}]}` — follow-ups dated on or before `today` (the caller's date; UTC when absent) on applications that are applied, screen, onsite or offer, and interviews from twelve hours ago to a week ahead. | | `POST` | `/api/apps/{name}/followup/draft` | Has the tenant's model draft a follow-up from the application, its cover letter, its notes log and the mail the mailbox has exchanged with the contact → `{subject, body, to, contact, can_send, thread_messages}`. Sends nothing. **400** while drafting is off (`cover_letters_enabled`), with no model call; metered like any draft, and rate-limited. | | `POST` | `/api/apps/{name}/followup/send` | `{to, subject, body, next_followup}` — mails the reviewed follow-up from the tenant's **own** mailbox over SMTP (never the instance's sender), logs it in the notes, and sets the follow-up date to `next_followup` (blank clears it). **400** with no outgoing mail configured, or for anything but one address and a one-line subject; **502** when the mail server refuses. | | `POST` | `/api/apps/{name}/followup/done` | `{channel, body, next_followup}` — a follow-up made some other way (LinkedIn, a call): logged in the notes, the date moved or cleared. | | `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. | | `GET` | `/api/poll/status` | The poller's latest passes for this account: `{last_polled_at, last_leads_added, failing:[{source, error}]}` — `last_polled_at` is `null` before the first pass. `failing` is the newest full pass's failed sources, with the ATS boards taken from a newer fast-lane pass. | | `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. | ### Contacts | Method | Path | Notes | | --- | --- | --- | | `GET` | `/api/contacts` | Every contact, by name: `[{id, name, email, phone, role, company, linkedin, notes, version, applications:[{application, role}]}]` — `role` is their job title; each link's `role` their part in that process. | | `POST` | `/api/contacts` | Add one: `{name, email, phone, role, company, linkedin, notes}` — a name required, `linkedin` an http(s) link. **201** with the contact. | | `GET` | `/api/contacts/{id}` | One contact, or **404**. | | `PUT` | `/api/contacts/{id}?expected_version=…` | Replace its fields. With `expected_version`, a contact changed since answers **409** (same flow as an application). | | `DELETE` | `/api/contacts/{id}` | Delete it and every link to it. **204**, or **404**. | ### 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 (`version: 2`): every application + criteria + blacklist + `status_events` (the dated status history) + `interviews` + `resume` (with its PDF, base64) + `cover_letters` + `answer_bank` (your own answers) + `agent_settings` + `llm_settings` + `notification_settings` + `contacts` + `application_contacts` + `notes` (the notes log). No secrets. | | `GET` | `/api/account/export.csv` | The applications alone as RFC 4180 CSV for a spreadsheet; formula-like cells (`= + - @`) are prefixed with `'`. | | `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. | | `GET` | `/api/account/sessions` | Where you're signed in: `id`, `created_at`, `last_seen_at`, `expires_at`, `user_agent`, and `current` for this browser. | | `DELETE` | `/api/account/sessions` | Sign out everywhere else: `{revoked}` — every session but this browser's. | | `DELETE` | `/api/account/sessions/{id}` | Revoke one listed session (**204**; **404** if it isn't yours). | | `GET` | `/api/account/tokens` | Your personal API tokens, newest first: `[{id, name, scope, created_at, last_used_at}]` — never the secret. | | `POST` | `/api/account/tokens` | Make one: `{name, scope}` with `scope` `read` or `write` → **201** with the listing plus `token`, the secret, in this answer only. At most 25 per account. | | `DELETE` | `/api/account/tokens/{id}` | Revoke one: it stops working at once. **204**; **404** if it isn't yours. | ### API tokens For scripts, a browser clipper, anything that can't carry the cookie: make a token under **Settings · Account · API tokens** (or `POST /api/account/tokens` from a session) and send it as `Authorization: Bearer atk_…` on any `/api` route. ```sh curl -H "Authorization: Bearer $TOKEN" https://apply.example/api/apps ``` - **`read`** tokens get the `GET` routes; **`write`** tokens every method. **403** otherwise. - **No token manages the account's credentials:** `/api/account/tokens`, `/api/account/sessions`, `/api/calendar/feed` and `DELETE /api/account` answer **403** to a token, so a leaked one can't mint more, sign its owner out, or delete the account. Nor do the secret-bearing settings — `/api/llm-settings`, `/api/notifications`, `/api/board-accounts` — so it can't redirect prompts, codes or messages. Export and import stay open to tokens. - Stored by SHA-256 only; the secret is shown once. A token of a disabled account, or a revoked one, gets **401**. The session cookie wins when a request carries both. - Each token has its own rate limit — 120 requests a minute — on top of the per-route ones. `last_used_at` is written at most once a minute. - Bearer requests need no CSRF defence (a browser can't attach the header cross-site without a CORS grant the API never gives); cookie requests keep theirs unchanged. ### 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](#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. `dry_run: true` on a submit makes it a dry run whatever *Dry run only* says — the Errors view's **Retry all**. | | `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`; `email_enabled`, `email_available` (an SMTP host is configured), `email_address` (the account's own); the per-event toggles `notify_packet_ready`, `notify_security_code`, `notify_submit_failed`, `notify_followup_due`; the daily reminder's `reminder_hour` (UTC, 0–23, default 12); the daily digest's `digest_enabled`, `digest_insults`, `digest_hour` (UTC, 0–23, default 12); `calendar_feed_enabled`, `calendar_feed_created_at`, `calendar_feed_last_used_at`. | | `PUT` | `/api/notifications` | Any of `telegram_enabled`, `telegram_chat_id`, `telegram_bot_token` (omit to keep; blank clears), `email_enabled`, `notify_packet_ready`, `notify_security_code`, `notify_submit_failed`, `notify_followup_due`, `reminder_hour`, `digest_enabled`, `digest_insults`, `digest_hour` (each omitted field is left alone; an hour outside 0–23 is **400**). **400** without `APPLYTRACK_SECRETS_KEY` when a token is sent. Also the mailbox half: `mailbox_enabled`, `mailbox_host`, `mailbox_port`, `mailbox_username`, `mailbox_password` (write-only; omit to keep, blank to clear) — the IMAP mailbox a parked run reads Greenhouse's security code from — and `mailbox_smtp_host`, `mailbox_smtp_port`, the same account's outgoing server that follow-ups are sent through (blank host: no sending). `GET` adds `mailbox_can_send`. | | `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. | | `GET` | `/api/calendar/feed` | `{enabled, created_at, last_used_at}` — whether a calendar feed link exists. | | `POST` | `/api/calendar/feed` | Make the feed link, revoking any earlier one: `{enabled, path, url, created_at}`. `path` is `/api/calendar.ics?token=…`; `url` is it on `App__PublicBaseUrl` (null when unset). The token is in this answer only. Rate-limited. | | `DELETE` | `/api/calendar/feed` | Turn the link off. **204**. | | `GET` | `/api/calendar.ics?token=…` | The ICS feed (`text/calendar`), read **without a session**: follow-up dates as all-day events and interviews (from 90 days back) as an hour each. **404** for an unknown, replaced or revoked token. Rate-limited per IP. | | `POST` | `/api/notifications/email/test` | Mail a test message to the account's own address (ignores the on/off switch) → `{ok, to}`; **400** with no SMTP host, **502** when the relay 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. A signed-in source's account (LinkedIn, MyGreenhouse, Handshake) also carries `keeps_session`, `session_expires_at` (null with none kept) and `renew_requested_at`. | | `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/board-accounts/{host}/renew` | **Sign in now**: the agent renews the kept session on its next tick (within a minute) instead of its own six-hour clock, so LinkedIn's app tap or the mailed PIN is asked for while you have your phone. **202** with the list; **400** for an account that keeps no session; **404** when there is 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](#roadmap)). Cover-letter drafting (`POST /api/apps/{name}/draft`) is implemented — see [Cover letters](#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). `status` is `active` or, set by `admin disable`, anything else; `last_polled_at` is when the poller last finished a pass for it. | | `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` | Server-side sessions, keyed by the SHA-256 of the cookie's id (instant revocation on logout), with last-seen time and browser. | | `llm_usage` | Per-tenant daily count of model requests on the operator's LLM key (`Llm__DailyCapPerTenant`). | | `seen` | The dedupe ledger — listings already surfaced, so leads don't repeat. | | `poll_requests` | The on-demand "Poll now" queue the worker drains. | | `poll_runs` | One row per finished poller pass per account: `started_at`, `finished_at`, `ats_only` (the fast lane), `leads_added`, and `errors` — `[{source, error}]` for each of the account's sources that failed. Written by the poller (insert only); read by `GET /api/poll/status`; pruned after `Retention__PollRunDays`. | | `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 settings: encrypted Telegram bot token and chat ID, the email switch, the per-event toggles, the daily digest's switches, hour and last day sent, the daily reminder's hour and last day sent, and encrypted IMAP mailbox credentials for automated security-code retrieval. | | `app_events` | Dated events on an application beyond its status — today its interviews (`kind = 'interview'`, `at`, `note`). | | `contacts` | The people behind a process, once per tenant: name, job title and company in the clear; email, phone, profile link and notes encrypted at rest. `version` is the optimistic lock. | | `application_contacts` | Who is on which application, and their part in it (`role`). Gone with either side. | | `app_notes` | The notes log: dated notes on an application (`at`, `body` encrypted at rest), never edited. | | `api_tokens` | Scoped secret tokens, stored by SHA-256 only. `calendar`: at most one per tenant, opening its ICS feed and nothing else. `read` / `write`: named personal API tokens sent as `Authorization: Bearer` (up to 25 per tenant). | | `agent_allowlist` | The accounts the operator allows to use auto-apply, managed with `admin allow` / `admin disallow`. | | `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`](./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, > use the [admin CLI](#operating-an-instance) in the api container: > > ```sh > docker compose exec api dotnet ApplyTrack.Api.dll admin allow ada@example.com the operator > ``` > > `admin disallow` takes it away again (both are a row in `agent_allowlist`). 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. **Email moos.** For anyone who won't set up a bot, **Settings · Notifications · Email** sends the same moos through the instance's own SMTP relay (`Email__*`, the one sign-in mail uses) to the address you sign in with — nothing else to configure, and no other address can be set. Every moo goes to each channel that is on, recorded per channel in the agent log. Three toggles pick which moments notify at all, on every channel: a packet is ready (or was submitted), a board's security code or sign-in check is waiting on you, and a real submission did not go through (with the reason — a crash, a refusal, a captcha). A mail can't be replied to with a code, so its 🔐 moo asks you to paste it in the app. The agent container sends these, so it needs the `Email__*` values too; without them the attempt is logged as a `notify_failed` event naming `Email__Host`. **Daily digest.** **Settings · Notifications · Daily digest** (off by default) mails the address you sign in with once a day, every day, through the same `Email__*` relay: the subject is always `NN application(s) submitted.` — two digits, `00` included — and the body lists each application marked applied the previous UTC day with its number, company, role, posting link and a link that opens it here. Pick the UTC hour it goes out at; **Insult me** (also off by default) opens it with a fresh remark from the machine about its human. The api container sends it, at most once per day. **Follow-ups, interviews and the calendar feed.** An application's follow-up date (**Mark applied** sets it a week out) and the interviews you schedule on its sheet (date, time and a note) are acted on: a **Due** chip on the pipeline strip opens the follow-ups due today or overdue — flagged **Overdue** on their cards too — and the interviews in the coming week, and both join the sheet's timeline. Once a day, at the UTC hour you pick, a reminder lists the follow-ups due that day and the interviews in the next 24 hours on every channel you have on (Telegram, email), under its own toggle, **Follow-ups due today and interviews in the next day** (on by default, like the others). A day with nothing due today sends nothing; overdue follow-ups ride along on a day that does. The api container sends it, at most once per day. **Settings · Notifications · Calendar feed** makes a private link to an ICS feed of the same dates for any calendar app's "subscribe by URL": the follow-ups as all-day events, the interviews as an hour each. A calendar can't sign in, so the link carries a secret token that opens that feed and nothing else — stored only as a hash and shown once; making a new link revokes the old, and it can be turned off. **Following up, agentically.** Each follow-up in **Due** has a **Follow up** button, and **Follow up on all** walks them one at a time. Your model (the same any-endpoint one as the cover letters) drafts a short, specific email from what the account already holds — the application and its dates, the contact, the cover letter, the notes log, and the mail your mailbox has exchanged with that contact — and a dialog shows it for you to edit. Nothing goes anywhere until you press **Send**, which mails it from **your own** account: add that account's SMTP host under Settings · Notifications · Mailbox (Gmail: `smtp.gmail.com`, port 587, the same app password). The instance's own sender is never used to mail an employer. With no contact email on file, the row says so and the draft is still written, to copy and send some other way (LinkedIn, the recruiter named on the posting); **Mark followed up** logs that instead. Either way the follow-up lands in the notes log and the date moves — or clears — so the row leaves Due. With drafting off there is no button and no model call. **People and the notes log.** An application's sheet lists the **People** in its process — a recruiter, a hiring manager, a panel — each with their part in it, email, phone and profile link. Add someone new or pick someone you already added: a contact is kept once and can turn up on any number of applications, and editing one is guarded like an application edit, so two tabs can't overwrite each other. Below it the **Notes log** keeps dated notes of what happened — a call, an email, what the salary band was — never edited, newest first, and each joins the timeline. Contact emails, phones, profile links and notes, and every log entry, are encrypted at rest; the agent's database role can't read them. The `Contact` fields on the application itself stay as they were. **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`, and `UPDATE` on an application's fields only — never its `tenant_id` or `name`. 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 four minutes) or an emailed PIN (read from your mailbox). The agent tries on its own clock — once every six hours after a try that stopped on the tap — so when you have your phone in hand press **Sign in now** beside the account (`POST /api/board-accounts/{host}/renew`): the sign-in starts within a minute and the moo says when to tap. An Easy Apply run with no kept session stands down before the browser, says so, and is queued again by the renewal that keeps one. With no account, or no kept session yet, the source falls back to LinkedIn's guest job search — the approach [JobSpy](https://github.com/speedyapply/JobSpy) uses — which needs no sign-in but is rate-limited hard. **Handshake, your own account — campus recruiting, the employer's own posting.** US colleges run their campus recruiting on Handshake, which reaches the entry-level and internship postings no other source here carries. Handshake does publish an official API, but the EDU API is read-only, scoped to a single institution and gated to Career Services partners through a developer portal Handshake runs — a self-hosted tenant cannot obtain one — so the **Handshake** source in Settings · Criteria (off by default) takes the route the LinkedIn source does. Save a **board account for `joinhandshake.com`** with your **school email** and password (Settings · Agent · Board accounts): the **agent's browser** signs in the way a person does — the school address first, which is how Handshake finds the school, then the school's own form, which takes the same address — and keeps the ninety-day session sealed on the row, renewing it well before it runs out or whenever Handshake bounces it. The poller then searches your keywords (a page each, newest first) through the endpoint Handshake's own web app calls, and reads each matching posting's `jobApplySetting`: an **externally-applied** posting carries the employer's own URL (the ATS Handshake tracked it from — iCIMS, BambooHR and the like), which becomes the lead's link under the same aggregator rule; a posting Handshake hosts itself is staged with the Handshake posting as its link and no employer URL — the poller's apply-by-hand lane — because those school-scoped postings are the ones that exist nowhere else. Unlike LinkedIn there is no guest search: Handshake's public pages expose a fraction of the board and hide the employer's URL, so with no live session the source yields nothing and says so rather than staging leads that cannot be applied to. Only a school whose sign-in is a plain username and password form on Handshake's own page can be driven; a school that federates to Microsoft, Okta, Shibboleth or Google, or that requires a second factor, is refused by name instead of timing out. > **Read this before saving a Handshake board account.** The password it stores is your > **school sign-in** — the same one that opens your campus email, your registrar record > and your financial-aid portal — not a job-board password like the LinkedIn one. It is > sealed on the board-account row like every other credential here, but a self-hosted > database plus its key is then a campus identity, and automated sign-ins from a server > can trip a school's new-device rules and lock you out. Most university acceptable-use > policies also forbid delegating SSO credentials to third-party software, and > Handshake's terms almost certainly forbid automated access — a ban lands on an account > the school issued. That is a materially different risk from the LinkedIn source, and it > is the operator's call to make knowingly. **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". **LinkedIn Easy Apply** is its own switch — *Apply through LinkedIn Easy Apply, signed in as me* in Settings · Agent, **off by default** — because it is a different kind of thing: the browser drives LinkedIn's own dialog signed in as *you*, from the session kept for the LinkedIn account under **Board accounts**. **That automates a personal account, which LinkedIn's terms do not allow**; an account LinkedIn notices can be restricted, and it is the same account the LinkedIn search source runs on. On, the poller stages postings that only take Easy Apply (it skips them otherwise) as `auto:linkedin:easy`, and the browser walks the dialog — Contact info, Resume, the employer's Additional Questions, Review. Those questions do not exist until their step is reached, so nothing can read them up front: a run answers what the packet knows, hands back the required ones it met for the first time, the packet takes them on and the drafter answers them, and it goes round again. A dry run stops at the Submit step and **discards**; a run that needs you **saves**, which keeps LinkedIn's own draft so you can finish by hand from where it stopped. It never ticks *Follow the company* for you, sends at most ten a day, and stops dead at a sign-in or a security check. 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. A form in **pages** — ClearCompany's "Page 1 · Page 2 · Page 3", evlo's five-step wizard — is walked: the browser presses Next, reads each page the way discovery reads a form, fills what the packet knows, and hands back any question it has never heard of for the drafter, then runs again; it never turns a page whose required question it cannot answer. 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. Its model calls run under their own deadline, `Agent__LlmTimeoutSeconds` (300), not the api's request-sized `Llm__TimeoutSeconds`; a form's screening questions are drafted eight per completion, so a completion that stalls costs that chunk and the rest of the packet is still drafted. Two stalls in a row stop the draft and hand the remaining questions to you rather than wait out another deadline per chunk. ## 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). Personal API tokens are hashed the same way, scoped `read`/`write`, revocable at once, and can't touch the account's credentials. - **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](#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`](./.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`](./.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 - **Export** — `GET /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, your company blacklist, every application's dated status history, interviews and notes log, your contacts and who is on which application, your résumé with its source PDF, your cover letters, the answers you gave the answer bank yourself, and your agent, AI (endpoint, model, cover-letter switch and signature) and notification settings. A real backup, and the door's never locked. **Secrets never leave:** not the LLM API key, the Telegram bot token (or its chat id), the mailbox or job-board passwords, sessions, API tokens or the calendar link. Enter them again after a move. An API token's export also leaves out the AI and notification settings, which a token can't read. - **Spreadsheet** — `GET /api/account/export.csv` returns the applications alone as RFC 4180 CSV. Any cell starting `=`, `+`, `-` or `@` gets a leading `'` so a spreadsheet shows it rather than running it. It is a view, not a backup. - **Import** — `POST /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. An imported app's status history, interviews, notes and contact links are replaced by the ones in the file, when the file carries them. A contact in the file overwrites one here with the same name and email, or is added, so re-importing adds nobody twice. Version-1 files still import; a section a file lacks leaves yours alone. A cover letter comes only with its application, a blank résumé never replaces yours, the agent's on switch and dry-run stay as they are here, and an LLM key you stored is dropped if the file names a different endpoint. 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. - **Share** — `GET /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**. - **Delete** — `DELETE /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. - **Retention** — a daily sweep in the api container keeps the tables that only grow in check: screenshots older than 90 days lose their bytes (never a submission's, an application's newest three, or any while it is still in Ready or Errors), expired sessions and sign-in links are deleted a day after expiry, and agent audit events go after 180 days (never verdicts, packet builds, accounts created, or events about a live lead or Ready application). The dedup ledger is kept unless `Retention__SeenDays` is set. Cleared screenshot space is reused by Postgres; `VACUUM FULL agent_evidence` returns it to the disk. All windows are in [Configuration](#configuration). ## Operating an instance For operators running an instance for other people. The full runbook is [`docs/operations.md`](docs/operations.md). - **Admin CLI** — no admin HTTP API, on purpose; the api image carries a command line instead, run in the api container (it needs the owner's connection, never the agent's or poller's role): ```sh docker compose exec api dotnet ApplyTrack.Api.dll admin users podman exec applytrack-api dotnet ApplyTrack.Api.dll admin usage ada@example.com ``` `users` lists accounts; `usage [<user>]` shows per-account counts (applications, letters, evidence rows and bytes, live sessions) and last sign-in, last seen and last poll; `allow` / `disallow` manage the auto-apply allowlist; `disable` / `enable` flip `users.status` — a disabled account is signed out on its next request, its API tokens stop working, it gets no sign-in link, and the poller and agent skip it. `<user>` is an id or an email. - **Backup & restore** — a backup is the database (`pg_dump -Fc`) **and** the encryption key (`APPLYTRACK_SECRETS_KEY` or the generated key file in the `secrets` volume). Without the key the sealed columns are unrecoverable. The runbook covers compose and quadlet, a restore that lets the api recreate the agent and poller roles, the retention sweep on an old dump, and a restore drill. Per-account export (above) is each user's own backup; this is the instance's. ### Observability Off by default, and each piece is opt-in on its own — an instance that sets nothing sends nothing anywhere. - **In the app.** Under the Applications heading every account sees when the poller last ran for it and which of its sources failed ("Last polled 12 min ago · 2 sources failing", the reasons behind a disclosure), from the poller's `poll_runs` rows. Operators get the same per account from `admin usage` (last poll). - **OpenTelemetry** — set `OTEL_EXPORTER_OTLP_ENDPOINT` on the api and/or agent container. Traces: incoming requests (by route template — `/api/apps/{name}`, never the slug or the query string), outgoing HTTP calls (scheme and host only; Telegram, whose bot token is in the URL, is not traced) and Npgsql commands. Metrics: the ones below, plus ASP.NET Core's, HttpClient's and Npgsql's. - **Prometheus** — set `Metrics__Port` (e.g. `9464`) and scrape `http://<container>:9464/metrics`. The endpoint exists on that port only: it is not reachable through the app's port or its reverse proxy, a forged `Host` header does not open it, and the port serves nothing else (every other path there is a 404). Publish the port to your scraper's network, not the internet. The app's own metrics (meter `ApplyTrack`) are tagged only with fixed vocabularies — never an account, email, company, host or URL: | Metric | Tags | What | | --- | --- | --- | | `applytrack.llm.duration` (s) | `endpoint` (`operator`/`tenant`), `outcome` | Each completion call to the OpenAI-compatible endpoint. | | `applytrack.llm.errors` | `endpoint`, `error.type` (`unavailable`/`timeout`/`refused`/`other`) | Failed completion calls. | | `applytrack.agent.runs` | `provider` (the ATS), `outcome` (`submitted`/`dry_run`/`failed`/`closed`/`captcha`/`crashed`), `dry_run` | Browser runs the agent finished. | | `applytrack.submit_requests.pending` | — | Submit requests not yet done, instance-wide (agent container). | HttpClient's metrics keep only method, status and error type (the host a posting or a tenant's LLM lives on is dropped), and Npgsql's only the connection state (the pool name is the connection string). The Python poller exports no telemetry; its record is `poll_runs`. ## 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](docs/accessibility.md), 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 first** — `tenant_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): ```sh docker compose run --rm \ -v "$PWD/applications:/data" \ --entrypoint applytrack \ poller import-md --dir /data --tenant <your-tenant-id> ``` That is the quickstart stack. In `docker-compose.production.yml` (and the quadlets) the poller connects as `applytrack_poller`, which may add leads but not overwrite them, so a re-import of an existing file fails there. Pass the owner role for this one-shot instead: `--database-url postgresql://applytrack:<POSTGRES_PASSWORD>@db:5432/applytrack` after `import-md`. ## Local development Run Postgres in a container and the two runtimes on the host: ```sh 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](https://ollama.com) (`ollama serve`, then `ollama pull llama3.1:8b`): ```sh cd api Llm__BaseUrl=http://localhost:11434/v1 Llm__Model=llama3.1:8b \ dotnet run --project ApplyTrack.Api ``` See [Cover letters](#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`](./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: ```sh # TOKEN = a write API token (Settings · Account · API tokens) curl -X POST http://localhost:5049/api/account/import \ -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \ --data-binary @scripts/demo-seed.json # SID = your session cookie (applytrack_session) from the browser or the sessions table 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 ```sh # .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](#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](./LICENSE). Copyright 2026 Aaron K. Clark. Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/