# skiffr docs > Open-source job application autofill that learns from your corrections. Early development, not released. # Introduction URL: https://docs.skiffr.findmalek.engineer/docs Markdown: https://docs.skiffr.findmalek.engineer/docs/index.md > What Skiffr is made of and where to find things. skiffr is an open-source Chrome extension that fills job applications from your own profile, and never submits anything for you. A dashboard keeps your profile, answers, a job queue and stats. You host the api and the database yourself. It is in early development and not released: the extension detects forms, asks, fills them and learns from your edits today, with the limits listed in [how skiffr learns](https://docs.skiffr.findmalek.engineer/docs/learning.md). The repo is a turborepo with a landing page, a dashboard with login, an api, and the extension, wired to postgres with pgvector, auth and typed rpc. Edit these pages in `apps/docs/content/docs`, they are plain mdx. ## What is in the repo - `apps/web`: the landing page. It only talks to the api - `apps/dashboard`: profile, answers, queue, stats, Gmail, sources and tokens. It only talks to the api - `apps/api`: orpc on bun. The only app that touches the database, auth and providers - `apps/extension`: the Chrome extension (WXT, React, Manifest V3). It calls the api with a token - `apps/e2e`: the Playwright suite that drives the built extension - `apps/docs`: this site - `packages/*`: shared code, see [structure](https://docs.skiffr.findmalek.engineer/docs/structure.md) ## Where to go next - [Setup](https://docs.skiffr.findmalek.engineer/docs/setup.md): Install, start postgres, run the apps. - [Environment](https://docs.skiffr.findmalek.engineer/docs/environment.md): Every key in the root `.env`. - [Structure](https://docs.skiffr.findmalek.engineer/docs/structure.md): Apps, packages and the rules between them. - [The extension](https://docs.skiffr.findmalek.engineer/docs/extension.md): Install it, connect it, what it does on a page. - [The dashboard](https://docs.skiffr.findmalek.engineer/docs/dashboard.md): Answers, queue and stats. - [Gmail job emails](https://docs.skiffr.findmalek.engineer/docs/gmail.md): Turn job alerts into queued jobs. - [Job sources](https://docs.skiffr.findmalek.engineer/docs/sources.md): Watch careers pages and feeds. - [Deploy](https://docs.skiffr.findmalek.engineer/docs/deploy.md): Vercel, Docker or Dokploy. - [How skiffr learns](https://docs.skiffr.findmalek.engineer/docs/learning.md): Lookup order, what is learned, what never is. - [Privacy and data flow](https://docs.skiffr.findmalek.engineer/docs/privacy.md): Exactly what leaves your machine. --- # Setup URL: https://docs.skiffr.findmalek.engineer/docs/setup Markdown: https://docs.skiffr.findmalek.engineer/docs/setup.md > From a fresh clone to every app running locally. You need node 24, pnpm, bun and docker. ```bash pnpm install cp .env.example .env pnpm db:up pnpm dev --filter='!extension' ``` Edit `.env` before the first start. `BETTER_AUTH_SECRET` must be at least 32 characters (`openssl rand -base64 32` gives 44), otherwise the api refuses to start. `ADMIN_PASSWORD` must be at least 10 characters, and with `ADMIN_EMAIL` or `ADMIN_PASSWORD` empty no admin is created. `DATABASE_URL` in `.env.example` already matches the local postgres from `pnpm db:up`. `pnpm dev` runs `turbo dev`, which starts every workspace with a `dev` script: web, api, dashboard, docs and the extension (`wxt`, which opens a development browser). `pnpm dev --filter='!extension'` starts only the four apps. Run the extension on its own with `pnpm --filter extension dev`. `pnpm db:up` starts a local postgres (`pgvector/pgvector:pg17`) from `docker/docker-compose.yml`. The api applies migrations on start and, when `SEED=demo`, fills an empty database with sample data. The pgvector image is debian based, the old alpine based postgres image used a different libc, so the compose file mounts a new volume, `skiffr-pgvector-data`, and never reuses the old one. Never point the new image at an old alpine data directory, text indexes break silently. Remove the old dev volume (it holds the old data) with `docker volume rm docker_skiffr-pg`. | App | Url | | --- | --- | | web | http://localhost:3000 | | api | http://localhost:3001 | | dashboard | http://localhost:3002 | | docs | http://localhost:3003 | Sign in to the dashboard with `ADMIN_EMAIL` and `ADMIN_PASSWORD` from `.env`. The admin is created or reset on every api start. ## Load the extension Build it, load it unpacked, create a token on the dashboard's Extension page and paste it in the extension options. The steps, the CI build option and what the extension does on a page are in [the extension](https://docs.skiffr.findmalek.engineer/docs/extension.md). ## Working on one app ```bash pnpm dev:web # web and api pnpm dev:dashboard # dashboard and api ``` ## Before you push ```bash pnpm lint pnpm check-types pnpm test pnpm build ``` --- # The extension URL: https://docs.skiffr.findmalek.engineer/docs/extension Markdown: https://docs.skiffr.findmalek.engineer/docs/extension.md > Install it, connect it to your api, and what it does on a page. The extension is a Manifest V3 Chrome extension. There is no Chrome Web Store listing and no release yet, so you build it or take a CI build, and load it unpacked. It needs Chrome 142 or a Chromium browser of the same age. ## Install Build it from the repo: ```bash pnpm --filter extension build ``` In `chrome://extensions` turn on developer mode, choose Load unpacked and pick `apps/extension/.output/chrome-mv3`. Or take a CI build: every pull request uploads `extension-chrome-pr-` and every push to `main` uploads `extension-chrome-latest`, from the [CI runs](https://github.com/FindMalek/skiffr/actions/workflows/ci.yml). GitHub login is required and artifacts expire. Unzip the download, extract the inner `extension--chrome.zip` into an empty folder and load that folder unpacked. ## Connect it 1. In the dashboard open **Extension** and create a token 2. In the extension options set the api url (`http://localhost:3001` locally) and paste the token 3. Press test connection Save your profile in the dashboard first: a fill without one stops with a message. ## Use it On an application form (Greenhouse, Lever, Ashby, Workable, Workday and other common systems, also inside iframes) a toast asks "Autofill this application?": - **Yes** fills the form - **No** closes the toast - **Don't ask on this site** stops the toast on that site `Ctrl+Shift+F` (`Cmd+Shift+F` on macOS) fills the current page without the toast. Remap it at `chrome://extensions/shortcuts`. For each field the extension takes the first answer it has: what you filled on this very form before, then a saved answer for the same question, then a language model that maps the field to a key of your profile (needs a provider key, see [environment](https://docs.skiffr.findmalek.engineer/docs/environment.md)). Fields the model is unsure about are filled and outlined in amber. An answer from a similar question is never filled by itself, the empty field is outlined as a suggestion. When it is done the toast says how many fields were filled and how many are outlined. It never submits a form, never clicks submit, next or apply buttons, and never touches password or hidden inputs. You review the form and press the button. After a fill the extension records what you corrected, cleared or left as filled, when you submit, leave the page or stop editing for 5 minutes (at most 3 batches per session). Sensitive fields are never sent, and forms with more than 60 learnable fields only record the first 60. The details and limits are in [how skiffr learns](https://docs.skiffr.findmalek.engineer/docs/learning.md). What is sent where is in [privacy](https://docs.skiffr.findmalek.engineer/docs/privacy.md). --- # Form snapshots URL: https://docs.skiffr.findmalek.engineer/docs/snapshots Markdown: https://docs.skiffr.findmalek.engineer/docs/snapshots.md > Turn a real application page into a sanitized test fixture with one command. A snapshot is a copy of one application form from a public page, stripped of everything that is not the form. It lives in `apps/e2e/pages/snapshots//` and the e2e suite runs detection and a fill against every folder there, so a new site becomes a test case by running one command. ## Take one ```bash pnpm snapshot https://job-boards.greenhouse.io/acme/jobs/123 pnpm snapshot https://jobs.lever.co/acme//apply --slug lever-acme pnpm snapshot https://jobs.lever.co/acme//apply --force pnpm snapshot https://example.com/careers/123 --click "text=Apply now" ``` The tool opens the page in headless chromium, waits for the network to go idle plus a settle delay, optionally clicks an apply button, takes the application area, sanitizes it, and writes two files: - `index.html`: the sanitized form, one self contained page - `meta.json`: the source host (no path), the date, the tool version, the root as a plain tag path (`body > main > div > form`, never an id or a class from the page) and the number of fields by type | Flag | Meaning | | --- | --- | | `--click ` | click this (any playwright selector) before taking the form, for pages that open it behind an apply button | | `--root ` | take this element. Without it the extension's own application detection picks the form or dialog, and falls back to the form or dialog with the most fields | | `--slug ` | folder name, default is the host name (`jobs-lever-co`), never words from the path | | `--force` | replace an existing folder, otherwise the tool refuses to overwrite one. The new folder is written and checked on the side first, so a failed run keeps the old one | | `--out ` | where the folders go, default `apps/e2e/pages/snapshots` | | `--timeout ` | per page load and click, default 30000 | | `--settle ` | wait after the network is idle, default 1500 | | `--delay ` | pause between pages when you pass several urls, default 2000 | | `--allow-local` | allow a loopback or private host, for the tool's own tests only | Then read `index.html` once, check the site's terms, and commit the folder. The tool prints this reminder every time. ## Check ```bash pnpm snapshot --check ``` Refuses a snapshot folder that: - holds anything but a regular `index.html` and `meta.json` (an extra file, a nested folder, a loose file next to the folders and every symlink are refused), or is not strict utf-8 - has a script, a frame, a style element, an event handler, a remote url, an email, a phone number, a hidden input with a value, a uuid, a jwt, a long hex string or an identifier that holds a long number, read with a tokenizer that knows a `>` can sit inside a quoted attribute - is not exactly what the sanitizer would write for itself, so a hand edited page is refused - has a `meta.json` with other keys than the five known ones, or a value of the wrong shape (the root must be a plain tag path) An unreadable or missing snapshots folder is an error, not a pass. The same check runs in the e2e suite and in the lint job of CI. ## Respecting the site - Public pages only. The tool refuses a start url on a loopback, private, link local, ipv6 mapped, NAT64, 6to4 or other non public address or name unless `--allow-local`. Every spelling is judged after the url parser has normalized it, and every address a name resolves to is judged too - Every request the page makes (page load, sub resource, fetch, xhr) is made by the tool from node, not by chromium: the tool checks the url, follows redirects itself and checks every `Location` before it requests it (at most 10 hops, a loop is an error, only http and https), and only then hands the final response to the page. Chromium therefore never follows a redirect, and the page sees the url it first asked for, not the final one - The name is resolved by node when it connects, and every address it returns must be public, so a name that changes its answer between the check and the connection (dns rebinding) is refused too. With `--allow-local` only the page's own host and port is let through, no other private address is - Websockets are refused outright, webrtc and quic are switched off, service workers are blocked, and a response is cut at 15 MB (100 MB per page) so a page that never stops cannot fill memory - Not covered: this makes the page load from node's tls stack, not chromium's, so a site that fingerprints clients can answer differently. - It identifies itself as `skiffr-snapshot/` with the repo link and nothing personal, and it starts from a fresh browser profile with no cookies - It only reads: any request that is not GET or HEAD is aborted, nothing is typed into the page, and the only click is the one you ask for with `--click`. If that selector is a submit button it is on you - It does not read robots.txt and does not crawl. One url, one load, `--delay` between several ## What the sanitizer guarantees The output is rebuilt from the parsed page, not patched: - no `