Tracks job applications through a pipeline (Wishlist → Applied → Phone Screen → Interview → Offer → Rejected/Withdrawn), with a stats dashboard as a core feature. See PLAN.md for the full spec and the reasoning behind the data model.
- Node.js 20+ — https://nodejs.org (or via
nvm). Not currently installed. - A Supabase project — https://supabase.com/dashboard
- A Google Maps Platform key for office addresses and the dashboard map — optional, see Office locations. Without it addresses still save; they just get no map pin.
-
In the Supabase SQL editor, run
supabase/bootstrap.sql. Creates all tables, indexes, theupdated_attrigger, theprofilesprovisioning trigger, and the RLS policies. Safe to re-run. An already-provisioned database instead needs the numbered files it has not yet run, in order:002_profiles.sql,003_dashboard.sql,004_application_location.sql,005_documents.sql,006_company_tags.sql.005is the exception to "bootstrap creates everything": it also creates the private storage bucket and its policies, so run it afterbootstrap.sqlon a fresh project too.Skipping one shows up at runtime, not at build time — saving an application against a database missing
004fails with "Could not find the 'location_lat' column of 'applications' in the schema cache", and the applications list fails to load without006.npm testchecks that every column inschema.tsis declared by some file insupabase/, but it cannot know which files a given database has run. -
In Authentication → Sign In / Providers: enable Google (Client ID and Secret from Google Cloud Console, with
https://<project-ref>.supabase.co/auth/v1/callbackas an authorized redirect URI), and disable Email.Disabling Email matters. Removing the password fields from the UI does not remove the password path — while the Email provider is on, anyone can still register straight against the Supabase API. SSO-only has to be enforced at the provider, not the form.
-
cp .env.example .envand fill in the values. -
Then:
npm install
npm run db:push # confirms schema.ts and the live DB agree (expect no changes)
npm run devUse npm run build:check rather than npm run build while the dev server is
running. A plain build writes to the same .next the dev server serves from and
corrupts its chunks, producing a blank page and a __webpack_modules__[moduleId] is not a function exception. build:check targets .next-build instead.
The publishable key is browser-safe and bound by RLS. The secret key bypasses
RLS — keep it server-side and never prefix it NEXT_PUBLIC_.
Needs the gcloud CLI and a GCP
project with billing enabled.
gcloud auth login
gcloud config set project <PROJECT_ID>
./scripts/gcp/setup.sh # once: APIs, image repo, service accounts, empty secrets
./scripts/gcp/secrets.sh # values from .env - or set them in the console
npm run deploy # build, push, deployWhat goes into Secret Manager: five values.
Four are build-time: NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_LOGO_DEV_TOKEN and
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY. Cloud Build fetches them itself
(availableSecrets in cloudbuild.yaml) and passes them to docker build;
they exist only inside that build step. Secret Manager here is a place to keep
configuration together, not a vault — every one of these is public by design
once the bundle ships. Because Next inlines NEXT_PUBLIC_*, changing one
means redeploying - adding a secret version alone changes nothing that is
running.
One is runtime: GOOGLE_MAPS_API_KEY, mounted into the container by Cloud Run
(--set-secrets) and read by the runtime service account, which is the only
identity granted access to it. It stays out of the bundle on purpose — address
lookup and the map are called from the server so that key is never handed to a
browser. Rotating it needs a new revision, not a rebuild.
What deliberately does not: SUPABASE_SECRET_KEY and DATABASE_URL.
Both bypass RLS, and the app needs neither at runtime.
If you create secrets in the console, paste the bare value. A trailing newline would be inlined into the JavaScript; the build strips one defensively, but it is better not to have it.
After the first deploy, take the service URL it prints and:
- Supabase → Authentication → URL Configuration: set the Site URL, and add
https://<service-url>/auth/callbackto the redirect URLs. Without this, Supabase sends users back to the old Site URL after Google sign-in. - Google's redirect URI stays the Supabase one
(
https://<project-ref>.supabase.co/auth/v1/callback) - nothing to change. - Optional: with a custom domain, redeploy with
npm run deploy -- --substitutions=_SITE_URL=https://your.domain.
Defaults (region northamerica-northeast1, TZ=America/Toronto, scale to
zero, max 2 instances) live in cloudbuild.yaml under substitutions. Put
the region near your Supabase project's: every page load makes Supabase calls.
Every application records where the office is. It is required unless the role is fully remote — and a remote role may still record one, since "remote" often means remote within a region, and a head office you visit quarterly is worth knowing. The address field suggests real addresses as you type, and the dashboard plots every one of them on a map.
How it is wired. Picking a suggestion stores Google's place id alongside the text; the coordinates are then looked up again on the server when the form is saved, so nothing a browser sends can move a pin. Change the address later and the pin moves with it, on every path — the form, and the MCP tools. When the lookup fails or no key is configured, the address is saved as plain text with no pin, and the next save of that application retries.
"Remote" is not an address. Google answers almost anything: it resolves
Remote to a holiday villa in Greece and Anywhere to a tour operator in Costa
Rica, both with real coordinates. Text that is only a placeholder is refused
before the lookup is made (isPlaceholderLocation in src/lib/geo/places.ts),
so those applications keep their text and stay off the map. A placeholder next
to a real place is fine — "Montreal (remote)" still finds Montreal.
Over MCP, the same thing happens deliberately rather than by typing:
lookup_location takes free text ("Shopify Montreal", "our Toronto office") and
returns the candidates Google matched, each with a placeId to pass to
create_application or set_application_location. The tool descriptions tell
the model to look the office up rather than guess at it.
The map pans and zooms. It is a Maps JavaScript map with a pin per office: drag to move, the zoom buttons or ctrl/⌘-scroll to zoom, Street View and fullscreen in the corner, and a click on a pin opens the applications at that address as links. Scrolling the page over the map scrolls the page — zooming takes a modifier — so the map cannot swallow a scroll aimed past it. It follows the app's light/dark. The list beside it carries the same places, and is the part that works with JavaScript off and the part a screen reader can use.
Two keys, and the difference matters. GOOGLE_MAPS_API_KEY is read only by
the server and never prefixed NEXT_PUBLIC_. It can spend Places quota on
geocoding, so it stays out of the bundle: that is why suggestions go through
/api/places/search and the still map arrives as an image from
/api/map/applications instead of a Google URL in the markup. Both endpoints
require a signed-in session — an open proxy to a metered API is somebody else's
free geocoder.
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY is the opposite and is meant to be public,
because a map that pans has to be drawn by the browser. It is restricted to the
Maps JavaScript API and to this app's own domains, so the worst a scraped copy
can do is draw a map — it cannot geocode. Leave it unset and the map falls back
to the still image, which is also what shows while the library loads and if it
fails to load at all.
gcloud services enable places.googleapis.com static-maps-backend.googleapis.com \
maps-backend.googleapis.com
# Server key: geocoding and the still map. Never leaves the server, so it gets
# no referrer restriction - a request from a server has no Referer to check.
gcloud services api-keys create --display-name="job-tracker-places" \
--api-target=service=places.googleapis.com \
--api-target=service=static-maps-backend.googleapis.com
# Browser key: the interactive map only, locked to the origins it may run on.
gcloud services api-keys create --display-name="job-tracker-maps-browser" \
--api-target=service=maps-backend.googleapis.com \
--allowed-referrers="http://localhost:3000/*","https://<your-service>/*"
# Print either one:
gcloud services api-keys get-key-string "$(gcloud services api-keys list \
--filter='displayName=job-tracker-places' --format='value(name)' | head -1)"Or in the console: APIs & Services → Library, enable Places API (New), Maps Static API and Maps JavaScript API; then Credentials → Create credentials → API key twice, restricting each under API restrictions as above, and the browser one under Application restrictions → Websites.
Put them in .env as GOOGLE_MAPS_API_KEY and
NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY, and for a deploy, in Secret Manager under
the same names (./scripts/gcp/secrets.sh does this from .env). Add a new
origin to the browser key's referrer list whenever the app gets one, or the map
silently refuses to draw there.
All three APIs are billable with a monthly free allowance. The address lookup fires at most once per 300ms of typing, the still map is cached five minutes per viewer, and dynamic map loads have their own free tier.
An address that could not be resolved — the lookup was down, the key was not set yet, or the text was a placeholder — keeps its text and simply has no pin. Save that application again and the address is resolved on the way through, so there is nothing to run and nothing to remember.
A record of what was sent: the CV and cover letter that went with each
application, the posting as a file, any take-home assignment and what was
handed back. Files are kept in a library at /settings/documents and
attached to applications, so the generic CV is one file cited by many.
Files never change. There is no edit, in the app or in the policies behind it. A revised CV is uploaded as a new file and made the default, and every application that went out with the old one keeps pointing at it. That is what makes "which CV did they get" answerable a year later.
The default résumé is the generic CV. The first résumé uploaded becomes it; change it from the library. It is attached automatically when an application leaves the wishlist — created as applied, or moved there later — unless that application already has a résumé. If a tailored one was sent, attach it and detach the generic one.
What is accepted: PDF, Word (.doc, .docx), OpenDocument (.odt), RTF,
and plain text of any kind — Markdown, CSV, JSON, source code. 1 MB per
file. Images, audio, video and archives are refused: the bytes live in
Supabase Storage on a free tier, and one photo outweighs every CV you will
write. The type is decided from the file's extension and then checked against
its first bytes, so a renamed image is not a .txt. A take-home that arrives
as a zip has to be attached as its individual source files.
The limit is enforced three times, each for a different reason:
src/lib/documents/files.ts so the refusal can explain itself, the bucket
(file_size_limit, allowed_mime_types) so an upload sent straight to
Storage cannot skip it, and a CHECK on documents.size_bytes.
Deleting a file is refused while any application cites it — detach it there first. Deleting an application removes its attachments and leaves the files in the library.
Storage is a private Supabase bucket named documents, created by
005_documents.sql. Objects are keyed
<user id>/<document id>.<ext> and scoped by the same auth.uid() policies as
the tables, so there are no storage credentials to configure. Downloads go
through /api/documents/<id>/download, which signs a link for one minute.
The app exposes an MCP server at /api/mcp, so Claude can fill the tracker from
a job posting: give it a URL and it reads the page itself, then calls
create_application with the fields it extracted. It can also search, move an
application along the pipeline, add notes and interview rounds, and resolve an
office address to a map pin (lookup_location, set_application_location).
It tags what the company does — up to five short labels such as Fintech, AI or
Insurance, shown on the application page and the list — through
create_application for a new posting and set_company_tags for one already
saved. The tags belong to the company, so every application there shares them.
It also keeps the paperwork: upload_document stores a file (text passed
directly, or base64 for a PDF it holds the bytes of), list_documents and
get_document read the library, attach_document / detach_document record
which files went with which application, and set_default_resume names the
generic CV. The same type and size rules apply as in the web form — see
Documents. There is deliberately no tool that deletes a file.
The server also sends instructions at initialization describing how the
document tools fit together, since no single tool description can.
Authorization is Supabase Auth's own OAuth 2.1 server, not something this app
implements — Claude discovers it via /.well-known/oauth-protected-resource,
you approve the connection once at /oauth/consent, and the access token it
receives is an ordinary Supabase JWT. So the same RLS policies that scope the
web UI scope the MCP tools, and the Cloud Run service still holds no secrets.
One-time Supabase setup (Authentication → OAuth Server in the dashboard):
- Enable the OAuth 2.1 server.
- Set the authorization URL path to
/oauth/consent. - Enable dynamic client registration.
Step 3 is what makes connecting a one-field operation. With it on, the client registers itself during discovery and the user pastes a URL and nothing else — no client id, no secret, no dashboard visit. With it off, every user has to be handed a client id out of band, which is not a thing you can ask of people.
The cost is that anyone can register a client and name it whatever they like,
so client_name on the consent screen proves nothing. That is handled where it
has to be: the consent screen treats the name as an unverified claim and vouches
for the redirect URI instead, warning plainly when the app is one it does not
recognise (src/lib/mcp/clients.ts). Nothing is granted until a signed-in user
approves it there.
In Claude: Settings → Connectors → Add custom connector, and paste:
https://<your-service>/api/mcp
That is the whole setup. Claude reads /.well-known/oauth-protected-resource,
finds Supabase, registers itself, and sends you to /oauth/consent to approve.
Revoke any time at /settings/connections.
If you cannot enable dynamic client registration
Register a client by hand and give its id to each user:
node scripts/supabase/register-oauth-client.mjs Claude
Needs SUPABASE_SECRET_KEY from .env. Its redirect URI list has to name every
client surface up front, because Supabase matches redirect URIs exactly and does
not accept wildcards — which is the other reason this does not scale.
Then: "Add this job to my tracker: <url>".
Supabase builds the consent redirect from the project's Site URL, so point that at your deployed origin (not
localhost:3000) before connecting Claude to the deployed app.
Five skills in .claude/skills/ take one posting from a URL
to a sent application, in Claude Code on your own machine. Each is a slash
command, each leaves its result in the tracker, and so each can be picked up
later or run on its own.
| Skill | What it does | Where it stops for you |
|---|---|---|
/job-capture <url> |
Reads the posting in Chrome, checks it is not already tracked, saves it as a wishlist application with the posting as a file | — |
/job-fit <application> |
Sorts what the posting asks for into shown on the CV, undersold, in the CV repo but hidden, and missing; opens the application's branch in the CV repo when the generic CV will not do | Asks about each missing item |
/cv-tailor <application> |
Proposes the changes, edits the CV on its own branch, renders the PDF, checks it, files it against the application | Before editing, and before filing |
/job-apply <application> |
Plans every field of the form, fills it in Chrome, uploads the PDF | Before filling, and before submit |
/manager-outreach <application> |
Finds who is likely hiring, drafts a note, types it into the LinkedIn dialog | On who, on the text, and before send |
You click the button. No skill submits an application or sends a message. Sign-ins, account creation, CAPTCHAs and consent boxes are yours too. The skills say this to the model in as many words; it is also why they drive your real Chrome rather than a headless one — you are meant to be watching.
Why these are not features of the app. PLAN.md rules LLM features out of the tracker, and the deployed service holds no model key. The tracker stays a store with an MCP interface; the reasoning happens in the client that is already signed in as you.
- The tracker, as an MCP server.
.mcp.jsondeclares it asjob-tracker, atJOB_TRACKER_MCP_URLif that is set and otherwise athttp://localhost:3000/api/mcp, which needsnpm run devrunning. For the deployed app, setJOB_TRACKER_MCP_URL=https://<your-service>/api/mcpin the environment Claude Code starts from. Approve the server when Claude Code asks, and sign in once: it is the same OAuth flow and consent screen as Connect Claude. - The CV repo. The skills expect a git repo whose
index.htmlis the CV and whoseCLAUDE.mdis titled "CV source" and states the contract: an element withhiddenis kept but not shown, content is hidden rather than deleted, nothing is written that the repo or you did not supply, and each application gets a branchapply/<company>-<role>. Add that repo to the session as a second directory, or setCV_REPOto its path. - Chrome, with the Claude in Chrome extension, signed in to the job boards you use, to LinkedIn, and to the tracker.
Start the session in this repo so the skills load.
There is no database of experience and no retrieval step. One person's career
fits in a model's context, so the CV repo holds all of it and hidden marks
what a given CV leaves out. job-fit reads the hidden parts as well as the
visible ones, which is what lets it tell "you have this, the CV does not show
it" from "nothing here supports this". A fact you supply while answering is
committed to the repo's main branch before any application branch uses it, so
the next application starts with it.
The PDF is index.html printed by headless Chrome
(render.sh). The script
reports the page count; the skill then reads the PDF page by page, because a
count of two says nothing about a job title stranded at the foot of page one.
Per application: the posting (job_posting), the fit analysis and the
tailored CV's HTML source (other), the PDF that was sent (resume), and
notes for the branch and commit, the submission, and any outreach. The commit
that was sent is also tagged applied/<company>-<date> in the CV repo.
Once, and then revised: Application answers.md in the library — work
authorisation, notice period, salary expectation and the other answers every
form asks for. job-apply fills from it, asks for what it lacks, and offers
to save the new answers as a new file.
- The PDF goes in through the web page.
upload_documenttakes base64, and a model cannot reproduce a PDF's bytes, socv-tailoruses the Documents section of the application's page in Chrome. - No contact tools.
contactshas ahiring_managerkind and no MCP tool, so outreach is logged as a note, follow-up date included. - No general update tool.
platform_appliedcan only be set when the application is created;job-applyrecords where it was submitted in a note.
| Path | What |
|---|---|
.claude/skills/ |
The application skills, and the script that renders the CV |
.mcp.json |
Tells Claude Code where the tracker's MCP server is |
src/db/schema.ts |
Single source of truth for the data model |
src/db/scope.ts |
Ownership predicate for Drizzle queries, which bypass RLS |
supabase/bootstrap.sql |
One-time DDL + RLS, mirrors schema.ts |
src/db/index.ts |
Drizzle client |
drizzle/ |
Generated migrations once Node is available — commit, never edit |
src/lib/applications/ |
Validation and write logic, shared by the form and the MCP tools |
src/lib/documents/ |
What may be uploaded, and every read and write of the file library |
src/app/settings/documents/ |
The library page, and the actions the application page shares |
src/app/api/documents/[id]/download/ |
Signs a short-lived link to a private file |
supabase/005_documents.sql |
Document tables, the storage bucket, and their policies |
supabase/006_company_tags.sql |
The sector tags on a company |
src/lib/geo/ |
Places lookup, map URLs, and the pin grouping the map and its list share |
src/app/api/places/search/ |
Address suggestions, proxied so the key stays server-side |
src/app/api/map/applications/ |
The dashboard map, fetched as an image for the same reason |
src/lib/mcp/ |
MCP tool definitions and bearer-token verification |
src/app/api/mcp/route.ts |
The MCP endpoint |
src/app/oauth/consent/ |
Consent screen for Supabase's OAuth 2.1 server |
src/lib/mcp/clients.ts |
Recognises OAuth clients at the consent screen |
scripts/supabase/ |
Manual OAuth client registration, if DCR is off |
Dockerfile |
Three-stage build to a standalone Next server |
cloudbuild.yaml |
Build, push, deploy; pulls config from Secret Manager |
scripts/gcp/ |
One-time project setup and secret upload |
The 2019-era version (Express + Mongo + Angular). Its data model is the reason this one tracks meetings, recruiters, and recruiter-vs-direct sourcing; see the table at the top of PLAN.md.