A bring-your-own-data, bring-your-own-LLM job application tracker backed by your own Google Sheet—now as a native desktop app.
- Applications in your Google Sheet: track applications with status history stored in your own spreadsheet.
- AI auto-fill: fill the form from a pasted job posting with Gemini, OpenAI, Anthropic, or any OpenAI-compatible server (including local LLMs via LM Studio).
- Analytics: status and company breakdowns, a draggable pipeline Sankey, and a status timeline grouped by day, week, or month.
- Printable report for any date range.
- Desktop-native: runs as an Electron app with secure local token storage and file-based logging.
flowchart LR
subgraph electron["Electron App"]
main["Main Process<br/>(Node.js)"]
renderer["Renderer Process<br/>(React SPA)"]
preload["Preload Script<br/>(contextBridge)"]
end
subgraph local["Local Storage"]
tokens[("Encrypted Tokens<br/>(safeStorage)")]
logs[("Log Files<br/>(daily rotation)")]
config[("Spreadsheet Config")]
end
google["Google OAuth, Sheets,<br/>Drive APIs"]
llm["LLM Provider<br/>Gemini / OpenAI / Anthropic / LM Studio"]
browser["System Browser"]
renderer <-->|IPC| preload <-->|IPC| main
main --> tokens
main --> logs
main --> config
main -->|OAuth loopback| browser -->|auth code| main
renderer -->|"fetch (REST)"| google
main -->|"fetch (proxied via IPC)"| llm
Details on architecture and data flow are in .claude/DESIGN.md.
- Node.js >= 22 and npm
- A Google Cloud project with the Sheets and Drive APIs enabled
- OAuth credentials:
- OAuth 2.0 Client ID of type "Desktop app" (includes client secret)
- OAuth consent screen with your account added as a test user
Set these in .env (template: .env.example). electron-vite inlines them into the build, so rebuild or restart npm run dev after changing them. Because the client secret ends up in out/ and dist/, both are gitignored.
| Variable | Required | Notes |
|---|---|---|
MAIN_VITE_GOOGLE_CLIENT_ID |
Yes | Desktop OAuth Client ID |
MAIN_VITE_GOOGLE_CLIENT_SECRET |
Yes | Desktop OAuth Client Secret |
Self-hosted models are called from the app's main process. Nothing LLM-related is bundled with the application.
- Load the model: in LM Studio's Developer tab, load a chat model (7B+). Set Context Length to 8192 or more. From CLI:
lms load <model> --context-length 8192. - Start the server. CORS settings don't matter: requests come from the app's main process.
- Connect the app: in Settings → AI Provider, pick Self-hosted (OpenAI-compatible), set URL to
http://localhost:1234/v1/chat/completions, click Discover, and select the model. - Leave Structured Output toggle off. The JSON schema is sent with every request as
response_format. - Nothing to set for thinking. Requests send
reasoning_effort: "none"(LM Studio) andchat_template_kwargs.enable_thinking: false(vLLM, llama.cpp). The schema constrains only the answer, not reasoning, so a model left thinking can loop until the request times out (240s). Output is capped at 2048 tokens.
To check a model outside the app (expect reasoning_tokens: 0 and JSON in content):
curl -s localhost:1234/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "<model>",
"messages": [
{"role": "system", "content": "Extract the job title and company."},
{"role": "user", "content": "Acme Corp is hiring a Senior Go Engineer in Austin."}
],
"temperature": 0,
"max_tokens": 2048,
"reasoning_effort": "none",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "job_posting_extraction",
"strict": true,
"schema": {
"type": "object",
"properties": {"position": {"type": "string"}, "companyName": {"type": "string"}},
"required": ["position", "companyName"],
"additionalProperties": false
}
}
}
}'# Clone and install dependencies
git clone git@github.com:runyanjake/applications.git && cd applications
npm install- APIs & Services → Library: enable the Google Sheets API and Google Drive API.
- Google Auth Platform → Branding / Audience / Data Access:
- User type: External (or Internal on Workspace).
- Scopes:
userinfo.email,userinfo.profile,.../auth/spreadsheets,.../auth/drive.readonly. - Test users: add your Google account.
- While the app is in Testing, refresh tokens expire after 7 days, so expect to sign in weekly. Publish app (unverified, with a warning screen and a 100-user cap) removes that limit.
- Clients → Create client: type Desktop app. No redirect URIs are needed, because desktop clients accept any
http://127.0.0.1loopback port (the app uses8085). Copy the Client ID and Client Secret.
# Configure credentials (see Configuration above)
cp .env.example .env
# Start Electron in development mode (hot reload for the renderer;
# restart after changing main/preload code or .env)
npm run devnpm run lint
npm run typecheck# Compile main, preload and renderer into out/
npm run build
# Run the compiled build without packaging
npm run start
# Package for distribution (output in dist/)
npm run package # current platform
npm run package:mac # macOS (.dmg, .zip)
npm run package:win # Windows (.exe, portable)
npm run package:linux # Linux (.AppImage, .deb)# Run directly from the build output
open "dist/mac-arm64/Job Application Tracker.app"
# Or install: open the .dmg and drag the app to Applications
open dist/Job\ Application\ Tracker-*-arm64.dmgThe app is unsigned. Builds you make locally run without a Gatekeeper prompt. A copy downloaded or transferred from elsewhere must be opened once via right-click → Open. On first sign-in, macOS may ask for Keychain access; this is safeStorage encrypting the stored tokens.
# View application logs (macOS)
tail -f ~/Library/Application\ Support/job-application-tracker/logs/app-$(date +%F).log
# View application logs (Linux)
tail -f ~/.config/job-application-tracker/logs/app-$(date +%F).log
# Clear stored credentials (macOS)
rm -rf ~/Library/Application\ Support/job-application-tracker/secure-data/
# Clear stored credentials (Linux)
rm -rf ~/.config/job-application-tracker/secure-data/
# Filter error logs
jq -c 'select(.level == "error")' ~/Library/Application\ Support/job-application-tracker/logs/*.log