{"id":"511f492d-3242-4605-bf5d-8a61a02ccff6","slug":"jobops","kind":"mcp","name":"jobops","summary":"A self-hosted Model Context Protocol server for the full job-search loop — portal scanning, JD evaluation, tailored resume + cover PDFs, outreach drafting, story bank, negotiation brief — all driven f","owner":"HireBridge","repoUrl":"https://github.com/HireBridge/jobops","license":"MIT","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-05-31T06:06:13.000Z","addedAt":"2026-10-21T04:38:01.403Z","logoUrl":"https://avatars.githubusercontent.com/u/304266137?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["search","cloud-infra","communication"],"tags":["career-tools","job-search"],"runtime":"node","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T08:08:15.989Z","sample":false,"url":"https://conduits.dev/tools/jobops","language":"TypeScript","transports":["stdio"],"install":{"npm":"@mohith_das/jobops"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# jobops\n\nA self-hosted **Model Context Protocol** server for the full job-search loop — portal\nscanning, JD evaluation, tailored resume + cover PDFs, outreach drafting, story bank,\nnegotiation brief — all driven from your MCP-aware chat client (Claude Desktop, Cursor,\nany client that speaks streamable-HTTP MCP).\n\nThe chat is the brain. This server executes the mechanical work and hands every artifact\nback as an `http://localhost:7891/...` link.\n\n> **Status:** early. Works. APIs may still move pre-1.0.\n\n---\n\n## Quickstart\n\n```bash\n# 1. Scaffold your working directory (cv.md, profile.yml, portals.yml, modes/*.md + SQLite DB)\nnpx @mohith_das/jobops init\n\n# 2. Open cv.md, config/profile.yml, portals.yml and replace every <TODO> placeholder.\n#    (Optional: tune modes/*.md — rubric, tailoring rules, outreach tone — your edits win.)\n\n# 3. Rebuild the career_packet from your now-real cv.md\n#    (or just re-run `init` — it auto-reseeds when it detects cv.md changed)\nnpx @mohith_das/jobops reseed\n\n# 4. Confirm everything is wired\nnpx @mohith_das/jobops doctor\n\n# 5. Boot the server (Chromium auto-installs on first run)\nnpx @mohith_das/jobops start\n#  ▷ jobops listening on http://127.0.0.1:7891\n\n# 6. Get a copy-paste config block for your MCP client\nnpx @mohith_das/jobops connect\n\n# 7. Paste a job URL or pasted JD into your chat — the chat calls evaluate_job, draws on\n#    your rubric + career_packet + tailoring rules, and walks the rest of the workflow.\n```\n\nThat's the loop. Everything else (warm-intro finder, story bank, negotiation brief,\nbatch rater, scheduler, …) is wired in but optional.\n\n### Editing the career packet — two safe directions\n\nYour `career_packet` is the source of truth the chat uses to score JDs and draft materials.\nYou can drive edits from **either** direction, and **neither one silently destroys the\nother** — both are explicit:\n\n**1. Chat-driven (the packet is your edit surface).** Ask the chat to change a tagline,\nremove a project, tighten a bullet — it calls `update_career_packet`, which writes a new\nversion and **marks the packet user-edited**. From then on a plain `reseed` will **refuse**\nto overwrite it (it warns and tells you to pass `force` or sync first). Section edits are\nergonomic — \"change my tagline\" only re-sends Section 2, not the whole packet:\n\n```text\nupdate_career_packet section=\"2\" section_content='- **Builder PM** — \"ships product with engineering teeth\"'\n```\n\nFor surgical, **item-level** changes there's `edit_packet_item` / `remove_packet_item` —\naddress a section (`projects`, `skills`, `taglines`, or a number) and one item (by index or a\nmatching substring) to change or drop a single bullet/project/skill/tagline without touching\nthe rest. Every change still versions the packet, and `edit_packet_item` runs the\nvisa-leakage scan on the new text. `remove_packet_item` echoes exactly what it removed. Since\nevery change is a version, **nothing is truly lost** — `restore_packet_version` lists history\nand brings any prior version back (restore is itself reversible).\n\n**2. File-driven (cv.md / profile.yml are the source).** Edit `cv.md` /\n`config/profile.yml`, then **reseed** to rebuild the packet from them:\n\n```bash\nnpx @mohith_das/jobops reseed            # safe: refuses if the packet has chat edits not in cv.md\nnpx @mohith_das/jobops reseed --force    # rebuild from cv.md anyway (drops chat edits)\n```\n\n**Bringing the two back in sync.** When you've been editing in chat and want `cv.md` to\ncatch up, run `sync_packet_to_cv` — it writes the packet back into `cv.md` + `profile.yml`\nso the source files reflect your chat edits (and a later `reseed` reproduces them instead of\nclobbering them). So the two directions are symmetric: **reseed** (cv.md → packet) and\n**sync-back** (packet → cv.md), both explicit, neither automatic.\n\n`reseed` writes a NEW active version (previous demoted, history kept). `doctor` reports when\nthe packet is chat-edited (expected — not a nag) vs when `cv.md` changed under a file-driven\npacket. Standing policy with no CV/profile field (naming conventions, rendering rules, custom\nguardrails) lives in `modes/career_packet.md` Section 9, which reseed always preserves.\n\n> **Operator's guide / project memory:** [`docs/PROJECT_MEMORY.md`](docs/PROJECT_MEMORY.md)\n> is a single self-contained reference (architecture, every tool, env vars, setup, the\n> template system, sampling/elicitation/auth, hard rules, troubleshooting). Drop it into your\n> MCP client's project memory so you can ask \"how do I X?\" and get answers.\n\n---\n\n## What it does\n\nTwo systems merged into one MCP server:\n\n- **Evaluation + materials side** — port of [santifer/career-ops](https://github.com/santifer/career-ops):\n  6-block A–F (+G legitimacy) report, archetype detection, ATS-friendly HTML→PDF resume\n  + cover generation, story bank, negotiation playbook.\n- **Pipeline side** — port of a personal Postgres + n8n pipeline (\"JSA\"): Greenhouse /\n  Ashby / Lever / Workday pollers + closed-board Playwright scrapers, content-hash\n  dedupe, batch LLM rater with strict-JSON parsing, warm-intro / founder DM drafter,\n  visa signal from DOL OFLC H1B data.\n\nEverything lives in a single Node process with a single SQLite file. No external\nPostgres, no n8n, no cloud anything. Bring your own LLM key (Gemini free tier by\ndefault, DeepSeek optional) if you want the API/batch paths; chat-mode tools work\nwithout one.\n\n> **Not affiliated with or endorsed by santifer's career-ops.** This is an independent\n> project that ports + adapts the publicly-released MIT-licensed templates and rubric\n> shape into the MCP transport surface. See the [Attribution](#attribution) section.\n\n---\n\n## Tools (58 — one MCP `tools/list` call away)\n\n| Group | Tools |\n|---|---|\n| **Evaluation** | `evaluate_job`, `batch_evaluate`, `get_top_jobs`, `evaluate_training`, `evaluate_project` |\n| **Materials** | `generate_materials`, `render_pdf` (PDF / `.tex` / `.docx`), `get_report` |\n| **Tracker** | `get_tracker`, `update_status`, `mark_ready_to_apply`, `delete_jobs` (soft-delete → trash), `restore_jobs`, `list_trashed`, `purge_jobs` (permanent, backup-first) |\n| **Sourcing** | `scan_portals` (Greenhouse + Ashby + Lever + Workday + Amazon + Google + generic Playwright) |\n| **Outreach** | `find_warm_intros`, `find_founders`, `draft_outreach`, `draft_followup`, `draft_reply`, `get_outreach_queue`, `update_outreach`, `get_followups_due` |\n| **Contacts** | `add_contacts` (upsert from chat), `export_contacts` (CSV+JSON backup), `import_contacts` (merge, non-destructive), `delete_contacts` (soft-delete, recoverable) |\n| **Interview / offer** | `extract_stories`, `get_story_bank`, `negotiation_brief` |\n| **Research** | `deep_research`, `enrich_company`, `daily_digest` |\n| **Profile + ops** | `get_career_packet`, `update_career_packet` (whole-doc), `edit_packet_item` / `remove_packet_item` (one bullet/project/skill/tagline), `restore_packet_version` (history + restore), `reseed_career_packet` (safe by default), `sync_packet_to_cv`, `update_profile` (elicitation), `cost_estimate`, `doctor` (read-only health) |\n| **Federation** | `compile_career_packet`, `get_career_packet_json` (public-safe), `sync_to_livingcv`, `generate_embeddings`, `get_embeddings`, `broadcast_signal`, `get_federation_status`, `update_jobops` |\n| **Apply (preview only — never submits)** | `apply_prefill` |\n| **Visa (optional, can be hidden)** | `visa_signal`, `import_h1b`, `import_linkedin` |\n| **Scheduler (opt-in cron, off by default)** | `scheduler_status`, `scheduler_enable`, `scheduler_disable` |\n\nSix MCP **resources** carry the editable behaviour — rubric, report_format,\ntailoring_rules, outreach_tone, negotiation_playbook, career_packet — all loaded from\n`modes/*.md` and live-reloaded on edit. Tune scoring or tone without touching code.\n\n> **Tip:** ask the chat to run **`doctor`** anytime — it's a read-only health report (same\n> checks as the `npx @mohith_das/jobops doctor` CLI command) covering packet ↔ cv.md sync state,\n> LLM provider/key, sampling + auth posture, active template, modes, visa scoring, and the\n> public base URL. Handy for \"is my server wired right?\" without leaving chat.\n\n---\n\n## Trash & recovery (jobs)\n\nJobs follow the same non-destructive, recoverable, echo-before-delete philosophy as contacts\nand the career packet. **Soft-delete is the default; hard delete is always explicit, confirmed,\nand backup-first.**\n\n- **`delete_jobs`** moves 1..N jobs to the **trash** (by `job_ids` and/or by `statuses` — e.g.\n  trash everything in `skip`/`discard`/`sourced`). Trashed jobs drop out of the tracker,\n  `get_top_jobs`, and batch rating but are **retained and restorable**. It echoes exactly which\n  jobs (title + company) were trashed. This is recoverable — *not* a hard delete.\n- **`list_trashed`** shows what's currently in the trash (title, company, score, prior status,\n  when trashed) so you can review before restoring or purging.\n- **`restore_jobs`** brings trashed jobs back to their prior state.\n- **`purge_jobs`** is the only hard delete: it permanently removes **trashed** jobs (never live\n  ones). Pass `job_ids`, or `purge_all: true` (which requires `confirm: true`). A timestamped\n  backup of the affected rows is written to the project root **before** deletion; it echoes\n  exactly what was permanently removed.\n\n**In the tracker UI** (`/`): the **Status** cell is an inline dropdown (edit in place), and each\nrow has a compact **Trash** button (soft-delete; the row leaves and the summary cards update\nlive). A dedicated **[/trash](/trash)** page lists trashed jobs where you can **Restore** an\nitem, **Delete permanently** one (with a confirm dialog), or **Empty trash** (a clearly-warned,\nirreversible hard delete — backup written regardless). UI actions hit the same `/api/*`\nendpoints that share the exact core logic the chat tools use, so a job trashed in chat shows up\non `/trash` and vice-versa.\n\n---\n\n## Filtering, search & pagination (tracker)\n\nAt 1000+ jobs the tracker doesn't ship every row to the browser — it **paginates server-side**\n(SQL `WHERE`/`LIMIT`/`OFFSET` + a `COUNT`, never in-memory). The dashboard at `/` has:\n\n- **Filters (combinable):** multi-select **status**, **min/max score**, **company** (contains,\n  with a datalist of present companies), **role** and **seniority**, and a **show trashed** toggle.\n- **Search:** debounced, case-insensitive substring on **title or company**.\n- **Pagination:** page-size selector (25/50/100, default 50), first/prev/next/last + jump-to-page,\n  and a \"N matching · showing X–Y\" indicator.\n- **Sort:** click the **Score / Company / Discovered** headers (default score ↓).\n- **Shareable URLs:** every filter/sort/page is in the query string\n  (`/?status=applied&min_score=70&q=engineer&sort=score&page=2`) — bookmarkable and survives refresh.\n- **The summary cards stay TRUE TOTALS** — the whole active pipeline, independent of the current\n  filter/page. Inline status edits and trashing keep working within a filtered/paged view (the\n  row updates/leaves and counts refresh without losing your place).\n\nThe **same query powers the `get_tracker` MCP tool**, so chat can ask for slices too — e.g.\n*\"show me applied jobs scored over 70 with 'engineer' in the title.\"* `get_tracker` accepts\n`statuses[]`, `min_score`/`max_score`, `company`, `role_category`, `seniority`, `q`, `sort`,\n`dir`, `limit`, `offset`, and `show_trashed`, and returns `items` + `total_matching` +\nfull-pipeline `counts_by_status`.\n\n---\n\n## Scoring without an LLM key — IF your client supports MCP sampling\n\nThe scoring tools (`batch_evaluate`, `evaluate_job` `mode=\"api\"`) can run on your\n*connected client's model* via **MCP sampling** — same rubric, same strict-JSON contract,\n**no separate Gemini/DeepSeek key** — **but only if the connected client advertises the\n`sampling` capability** in its initialize handshake.\n\n> ⚠️ **Most clients don't (yet) — including Claude Desktop, as of now.** Claude Desktop\n> advertises only its UI extension, never `sampling`. The transport (stdio vs HTTP) is **not**\n> sufficient on its own — it's a per-client *capability*. So on Claude Desktop and similar\n> clients, batch/api scoring **falls back to the BYO key** (`JOBOPS_LLM_PROVIDER` + key),\n> which is expected and correct. Check current support at\n> [modelcontextprotocol.io/clients](https://modelcontextprotocol.io/clients).\n\n- **It engages automatically if (and only if) a sampling-capable client connects** — no\n  configuration needed. The gate checks both the client's advertised `sampling` capability\n  **and** that the transport can carry the server→client request (stdio; the stateless HTTP\n  transport can't, so it never even tries). When sampling isn't available, scoring uses the\n  BYO key; if that isn't set either, `evaluate_job mode=\"chat\"` (the default) still works —\n  your chat scores it directly. Fallback is clean (no hang).\n- **Bottom line for Claude Desktop users:** set `JOBOPS_LLM_PROVIDER` + the matching key\n  for `batch_evaluate` / `evaluate_job mode=\"api\"`. (Plain `mode=\"chat\"` needs no key.)\n- **Cost.** When sampling *is* used it runs on the client's model, so the cost is **borne by\n  the client**; `cost_estimate` records those calls flagged client-borne ($0 server cost).\n- Run the `doctor` tool to see the **live** state — it reports whether your current client\n  advertised sampling or whether you're on the BYO-key path.\n- Set `JOBOPS_SAMPLING=false` to force the BYO-key path even when sampling is available.\n\n## Frictionless profile setup (MCP elicitation)\n\n`update_profile` uses **MCP elicitation** (form mode) so your client can collect identity\nfields + per-archetype taglines through a structured form — no hand-editing YAML. On\naccept it writes `config/profile.yml` and reseeds the career packet in one step.\n\nSensitive inputs (your LinkedIn export path, credentials) use **URL-mode elicitation**\n(2025-11-25): the server hands you a one-time local URL where you enter the value\ndirectly, so it **never passes through the MCP client / chat transcript**. `import_linkedin`\nuses this when you omit `path` and your client supports it.\n\nBoth are capability- and transport-gated (like sampling, elicitation is a server→client\nrequest that needs a stdio connection). Clients without elicitation support — and all HTTP\nclients — fall back to the file-based + argument paths (`update_profile fields=…`, edit\n`config/profile.yml`, pass `import_linkedin path=…`), which work exactly as before.\n\n---\n\n## Designed to be made yours\n\nThe defaults assume nothing about your location, citizenship, role, or industry. Every\nbehaviour-shaping piece is a markdown file you can rewrite. **`init` copies these into\n`<project-root>/modes/` so they're yours to edit** — the loader reads your project-root\ncopy first and falls back to the bundled default, so you never touch the package install.\nA re-`init` never clobbers an edited copy (it warns and keeps your edits); `doctor` reports\nwhich files are user-overridden vs bundled.\n\n| You can change… | By editing… |\n|---|---|\n| Scoring dimensions + weights | `modes/rubric.md` |\n| 6-block report shape | `modes/report_format.md` |\n| Resume/cover tailoring rules | `modes/tailoring_rules.md` |\n| Outreach tone + char caps | `modes/outreach_tone.md` |\n| Negotiation scripts + framework | `modes/negotiation_playbook.md` |\n| Your bullet/project bank | `modes/career_packet.md` (or via `update_career_packet`) |\n| Per-archetype taglines | `config/profile.yml` → `taglines:` (auto-fills career-packet Section 2 on reseed) |\n| Tracked companies + filters | `portals.yml` |\n| Identity + target roles | `config/profile.yml` |\n\n### Non-US users / non-sponsorship cases\n\nVisa scoring is **fully optional**. Set:\n\n```bash\nexport JOBOPS_VISA_SCORING=false\n```\n\nWhen off:\n\n- `score_total = round(0.6 · resume_fit + 0.4 · taste_fit)` (server-side authoritative)\n- The `visa_signal`, `import_h1b`, `import_linkedin` tools are hidden from `tools/list`\n- `score_visa_fit` is stripped from `get_top_jobs` items and the eval-report HTML badge\n- The rubric resource gets a \"VISA SCORING DISABLED\" override prefix the chat reads\n\nOther features are unaffected. If you're a US citizen, a non-US user, or anyone scoring\nroles where sponsorship is a non-issue — turn it off; the rest of the system works.\n\n### Non-US markets\n\n`portals.yml` ships with example shapes for Greenhouse / Ashby / Lever / Workday / Amazon\n/ Google / generic Playwright. Drop in the boards relevant to your market. `modes/rubric.md`\n+ `modes/negotiation_playbook.md` + `config/profile.yml` are all yours to localize\n(language, comp ranges, geography priors).\n\n---\n\n## Environment variables\n\n| Var | Default | Purpose |\n|---|---|---|\n| `JOBOPS_PORT` | `7891` | HTTP port (MCP + file server + tracker dashboard) |\n| `JOBOPS_HOST` | `127.0.0.1` | Bind host |\n| `JOBOPS_PROJECT_ROOT` | cwd | Where `cv.md` / `config/profile.yml` / `portals.yml` live |\n| `JOBOPS_DATA_DIR` | `<install>/data` | SQLite + WAL location |\n| `JOBOPS_OUTPUT_DIR` | `<install>/output` | Rendered artifacts (PDFs, report HTML) |\n| `JOBOPS_VISA_SCORING` | `true` | Set `false` to drop visa surface entirely (see above) |\n| `JOBOPS_TEMPLATE_DIR` | _empty_ | User-owned dir holding additional resume/cover themes — overrides bundled themes of the same name. See [Custom themes](#custom-themes) + [`TEMPLATES.md`](./TEMPLATES.md). |\n| `JOBOPS_DEFAULT_TEMPLATE` | `default` | Theme used when `render_pdf` has no explicit `template` argument. |\n| `JOBOPS_PUBLIC_BASE_URL` | _empty_ | Public URL emitted in artifact links. Default: `http://127.0.0.1:<port>`. Set when running on a remote host (Tailscale, LAN, etc.) — see [Running on a remote host](#running-on-a-remote-host--tailscale). |\n| `JOBOPS_AUTH_TOKEN` | _empty_ | Bearer token gating `/mcp`, `/files/*`, and the dashboard. **Required** to bind to anything other than localhost — without it, a non-localhost bind refuses to start (default-deny). See [Security model](#security-model). |\n| `JOBOPS_SAMPLING` | `true` | Use MCP sampling for `api`/batch scoring **when the connected client advertises it** (most, incl. Claude Desktop, don't — then the BYO key is used). Set `false` to always use the BYO key. |\n| `JOBOPS_LLM_PROVIDER` | `gemini` | BYO-key path for `api`/batch scoring — used whenever the client doesn't support sampling (the common case): `gemini`, `deepseek`, `none` |\n| `JOBOPS_LLM_MODEL` | _empty_ | Provider-specific model id |\n| `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` | _empty_ | Provider credentials — needed for `api`/batch scoring unless your client supports MCP sampling (most don't; `mode=\"chat\"` never needs a key) |\n| `JOBOPS_SCHEDULER_ENABLED` | `false` | Whether opt-in cron runs at all |\n| `JOBOPS_LIVINGCV_URL` | _empty_ | LivingCV base URL (no default — set to your deployed LivingCV, e.g. `https://jobs.example.com`) |\n| `JOBOPS_LIVINGCV_TOKEN` | _empty_ | LivingCV **Master Relay** bearer key (Admin → Settings → MCP → `mcp.master_key`) |\n| `JOBOPS_HIREBRIDGE_URL` | `https://api.hirebridge.io` | HireBridge central router base URL for signal broadcast |\n| `JOBOPS_HIREBRIDGE_TOKEN` | _empty_ | Bearer token for HireBridge (set by `connect_to_hirebridge`) |\n| `JOBOPS_HIREBRIDGE_EMAIL` | _empty_ | Email associated with HireBridge connection |\n| `JOBOPS_EMBEDDING_PROVIDER` | `local` | Embedding provider: `local` (all-MiniLM-L6-v2 via `@xenova/transformers`), `openai`, `voyage`, `none` |\n| `JOBOPS_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Model name for the embedding provider |\n| `OPENAI_API_KEY` / `VOYAGE_API_KEY` | _empty_ | Credentials for OpenAI / Voyage embedding providers |\n\nA working starter is at `.env.example`.\n\n---\n\n## Run one server, use every client\n\nThe recommended multi-client topology: **ONE long-running HTTP server = ONE source of\ntruth.** Start it once, point every interface at it:\n\n```bash\nnpx @mohith_das/jobops start          # HTTP mode — serves MANY concurrent MCP clients\nnpx @mohith_das/jobops connect        # prints ready-to-paste config for each client below\n```\n\nAll clients connect to the same `http://127.0.0.1:7891/mcp` (or a Tailscale host:port —\nsee [Running on a remote host](#running-on-a-remote-host--tailscale)). Materials,\ntracker moves, contacts, packet edits made in **any** client are instantly visible in\n**all** the others, because there is exactly one process and one SQLite DB behind them.\nRate-limited in one client? Switch to another — nothing is lost.\n\n| Client | Config (all printed by `connect`) |\n|---|---|\n| **Claude Code** | `claude mcp add --transport http jobops <url>` or `.mcp.json` (`\"type\": \"http\"`) |\n| **Claude Desktop** | `mcp-remote` bridge in `claude_desktop_config.json` (stdio→HTTP), or a paid-plan custom connector |\n| **opencode** | `opencode.json` → `\"mcp\": { … \"type\": \"remote\", \"url\": … }` |\n| **codex** | `~/.codex/config.toml` → `[mcp_servers.jobops]` with `url` + `bearer_token_env_var` |\n| **gemini-cli** | `~/.gemini/settings.json` → `\"httpUrl\"` + `\"headers\"` |\n| **LibreChat** | `librechat.yaml` → `type: streamable-http` (see below for Docker) |\n| **Web UI / browser** | the tracker dashboard at `/` is the same server, same DB |\n\n> **Known issue (mcp-remote × Node ≥ 26):** `mcp-remote` (≤ 0.1.38) fails under Node 26+\n> with `StreamableHTTPError: Unexpected content type: null` — its bundled undici\n> `EnvHttpProxyAgent` global dispatcher strips response headers from Node's built-in\n> `fetch`. The server's responses are spec-correct; the bridge mangles them client-side.\n> Until fixed upstream, run the bridge under Node ≤ 24: replace `\"command\": \"npx\"` with\n> an absolute path to a Node 24 binary and point `args` at a Node-24-installed\n> `mcp-remote` (`npm i -g mcp-remote` under that Node). Symptom appears in Claude\n> Desktop's `mcp-server-*.log` right after `Using transport strategy: http-first`.\n\nConcurrency is safe by design: each HTTP request gets an isolated MCP protocol\ninstance, reads run concurrently (SQLite WAL), and **all writes are serialized through\none write lock** in the single server process — no corruption under multi-client load,\nno per-client spawn, no port conflicts.\n\nVerify everything is wired to the same instance:\n\n```bash\nnpx @mohith_das/jobops status         # uptime, source-of-truth DB path + fingerprint,\n                               # clients seen since boot (add --url / --token as needed)\n```\n\nThe `doctor` tool reports the same server-identity block from inside any chat client.\n\n**Auth:** the moment the server is reachable beyond localhost (Tailscale / LAN /\nalways-on box), you **must** set `JOBOPS_AUTH_TOKEN` — it serves PII (resume, contacts,\nH1B data) to every connected endpoint, and it refuses to boot remotely without the\ntoken (see [Security model](#security-model)). The token then goes into each client's\nconfig; `connect` embeds it automatically when the env var is set.\n\n---\n\n## Wiring it to Claude Desktop (stdio transport)\n\n**Single-client alternative.** This spawns a *private* server inside Claude Desktop\nrather than connecting to the shared one — fine when Claude Desktop is your only\nclient. For the shared topology above, use the `mcp-remote` bridge that `connect`\nprints instead. (A stdio instance next to a running shared server also needs its own\n`JOBOPS_PORT`, or the HTTP file server inside it fails with `EADDRINUSE`.)\n\nClaude Desktop's local MCP only speaks **stdio**, not HTTP. Use the `--stdio` flag:\n\n```jsonc\n{\n  \"mcpServers\": {\n    \"jobops\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"jobops\", \"start\", \"--stdio\"],\n      \"env\": {\n        \"JOBOPS_PORT\": \"7891\",\n        \"JOBOPS_PROJECT_ROOT\": \"/absolute/path/to/your/job-search/dir\"\n      }\n    }\n  }\n}\n```\n\n(Drop into `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,\n`%APPDATA%/Claude/claude_desktop_config.json` on Windows. Restart Claude Desktop.)\n\nIn `--stdio` mode the MCP transport rides stdin/stdout (which Claude Desktop drives via\nthe `npx` spawn); the HTTP file server still binds to `JOBOPS_PORT` in the background\nso the `http://127.0.0.1:7891/files/*` links the server returns continue to resolve in\nyour browser.\n\nGeneric MCP clients that take a streamable-HTTP URL: skip the `--stdio` flag, run\n`npx @mohith_das/jobops start` in a terminal, and point your client at\n`http://127.0.0.1:7891/mcp`.\n\n`npx @mohith_das/jobops connect` prints both blocks ready to paste.\n\n### LibreChat\n\n`npx @mohith_das/jobops connect` also prints a `librechat.yaml` block. Two shapes:\n\n- **LibreChat as a host process:** `type: streamable-http`, `url: http://127.0.0.1:7891/mcp`.\n- **LibreChat in Docker:** swap to `http://host.docker.internal:7891/mcp` AND allowlist\n  the address under `mcpSettings.allowedAddresses` (LibreChat blocks private/internal\n  addresses by default as SSRF protection). On Linux, also add\n  `extra_hosts: [\"host.docker.internal:host-gateway\"]` to the LibreChat service in your\n  `docker-compose.yml`.\n\n(Refs:\n[librechat.ai/docs/.../mcp_servers](https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/mcp_servers),\n[features/mcp](https://www.librechat.ai/docs/features/mcp).)\n\n---\n\n## Working `evaluate_job` payloads\n\n### Step 1 — paste a JD or URL\n\n```jsonc\n{\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"evaluate_job\",\n    \"arguments\": {\n      \"input\": \"https://jobs.ashbyhq.com/example/123\",\n      \"mode\": \"chat\",\n      \"title\":   \"Builder PM\",\n      \"company\": \"Frontier AI Tools Co\"\n    }\n  }\n}\n```\n\nReturns the rubric, the report format, the active career packet, and a `job_id`. The\nchat client uses those to score + draft the 6 blocks.\n\n### Step 2 — finalize\n\n```jsonc\n{\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"evaluate_job\",\n    \"arguments\": {\n      \"job_id\": \"<from step 1>\",\n      \"mode\": \"chat\",\n      \"report\": {\n        \"archetype_detected\": \"Agentic / LLMOps hybrid\",\n        \"block_role_summary\": \"…\",\n        \"block_cv_match\":     \"…\",\n        \"block_level\":        \"…\",\n        \"block_comp\":         \"…\",\n        \"block_personalize\":  \"…\",\n        \"block_interview\":    \"…\",\n        \"block_legitimacy\":   \"…\",\n        \"keywords\":           [\"builder pm\", \"agentic workflows\", \"…\"]\n      },\n      \"scores\": {\n        \"resume_fit\": 86, \"taste_fit\": 92, \"visa_fit\": 88, \"score_total\": 88,\n        \"reasoning\": \"Strong match on agentic workflows + PRDs + SQL/Python.\",\n        \"concerns\":  \"Evals experience is adjacent rather than LLM-eval-specific.\",\n        \"role_category\": \"pm\",\n        \"seniority\":     \"senior\"\n      }\n    }\n  }\n}\n```\n\nServer persists, renders HTML at `/files/reports/<id>.html`, returns the URL.\n\n---\n\n## Running on a remote host / Tailscale\n\nBy default every artifact link the server returns starts with `http://127.0.0.1:<port>`.\nThat's fine when you run server + chat on the same machine. If you run the server on a\ncloud instance, a homelab box, or anything you reach over Tailscale / LAN / a tunnel,\n`127.0.0.1` on the link resolves to your *chat machine* — not the server — and the\nlinks don't work.\n\nSet `JOBOPS_PUBLIC_BASE_URL` to the URL the chat machine actually uses to reach the\nserver:\n\n```bash\n# Tailscale magic DNS\nexport JOBOPS_PUBLIC_BASE_URL=\"https://jobs.example.ts.net\"\n\n# Tailscale 100.x IP\nexport JOBOPS_PUBLIC_BASE_URL=\"http://100.64.0.5:7891\"\n\n# LAN IP\nexport JOBOPS_PUBLIC_BASE_URL=\"http://192.168.1.20:7891\"\n\n# Reverse proxy\nexport JOBOPS_PUBLIC_BASE_URL=\"https://jobs.example.com\"\n```\n\nEvery artifact link (resume PDF, .tex, .docx, eval report, apply_prefill screenshot,\ntracker URL) now uses that base. The server still binds to `JOBOPS_HOST` (default\n`127.0.0.1`); to accept connections from other devices, also set `JOBOPS_HOST=0.0.0.0`\n— **which now requires `JOBOPS_AUTH_TOKEN`** (see [Security model](#security-model)).\n`npx @mohith_das/jobops doctor` prints the effective public base URL and auth posture.\n\nA malformed value (e.g. `not-a-url`) is rejected at boot with a warning on stderr; the\nserver keeps running with the default 127.0.0.1 base. Trailing slashes are stripped.\n\n---\n\n## Security model\n\nThis server handles **PII**: your resume PDFs, cover letters, eval reports, your\nLinkedIn connections, and H1B-derived employer data. The auth posture is decided entirely\nby **where you bind** and **whether a token is set**:\n\n| Bind (`JOBOPS_HOST`) | `JOBOPS_AUTH_TOKEN` | Result |\n|---|---|---|\n| `127.0.0.1` (default) | unset | **Open** — frictionless local use. PII stays on loopback. |\n| `127.0.0.1` | set | **Token required** — bearer auth enforced even locally (opt-in). |\n| `0.0.0.0` / LAN / Tailscale | **unset** | **Refuses to start** (default-deny). |\n| `0.0.0.0` / LAN / Tailscale | set | **Token required** — bearer auth on every PII route. |\n\nWhen a token is required, every PII-bearing route — the MCP endpoint (`/mcp`), the file\nserver (`/files/*`), and the tracker dashboard (`/`) — demands an\n`Authorization: Bearer <token>` header. Requests without it get `401` with a\n`WWW-Authenticate` header pointing at the protected-resource metadata document\n(`/.well-known/oauth-protected-resource`). This aligns with the MCP 2025-06-18 model of\ntreating the server as an **OAuth Resource Server**, to the extent practical for a\nself-hosted single-user tool: one operator-provisioned static token, no full\nauthorization-server flow.\n\n```bash\n# Expose over Tailscale/LAN — generate a strong token first.\nexport JOBOPS_HOST=0.0.0.0\nexport JOBOPS_AUTH_TOKEN=\"$(openssl rand -hex 32)\"\nexport JOBOPS_PUBLIC_BASE_URL=\"https://jobs.example.ts.net\"\nnpx @mohith_das/jobops start\n```\n\n**What's protected:** `/mcp`, `/files/*`, `/`. **What's open by design:** `/healthz`\n(liveness, no PII) and the discovery metadata. **Hard rule:** PII must never be served\nunauthenticated to a network — the default-deny boot guard exists precisely so a missing\ntoken fails loudly instead of silently exposing your data. Still prefer a private network\n(Tailscale / VPN / authenticated reverse proxy) over the public internet.\n\n---\n\n## Downloadable, editable source formats\n\n`render_pdf` produces the resume and cover in any subset of three formats:\n\n| Format | Where it lands  | Use it for                                                    |\n|--------|-----------------|---------------------------------------------------------------|\n| `pdf`  | `/files/pdfs/`  | The deliverable. Light/white background, ATS-clean.           |\n| `tex`  | `/files/tex/`   | The editable LaTeX source. Compiles with vanilla `pdflatex`.  |\n| `docx` | `/files/docx/`  | Word / Google Docs editing. Real headings + bullets, ATS-safe. |\n\nDefault is `formats: [\"pdf\"]` for back-compat. Request any subset:\n\n```jsonc\n{\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"render_pdf\",\n    \"arguments\": {\n      \"job_id\": \"<from evaluate_job>\",\n      \"kind\":    \"both\",\n      \"formats\": [\"pdf\", \"tex\", \"docx\"],\n      \"cover_body\": \"I am reaching out about ...\"\n    }\n  }\n}\n```\n\nAll URLs persist onto the application row in the `rendered_files` JSON column so\n`get_tracker`, `apply_prefill`, and `daily_digest` can find them later. Re-rendering\none format merges into the existing map — never clobbers the others.\n\nAll formats in one call share a single content snapshot taken when the call starts:\n`cv.md` + `profile.yml`, the active career packet (chat edits count — no sync-back\nneeded), and the job's persisted tailored materials (`generate_materials` output,\ncurrent `materials_v`). So editing and recompiling the `.tex` reproduces the same\ndocument, and re-rendering after a packet edit or a new materials version produces\nthe updated one. The visa-leakage rail runs against every output format before\nfiles are written.\n\n### Custom themes\n\n`render_pdf` accepts a `template` argument — pick a named theme to render with.\nThemes are directories under `templates/themes/<name>/` holding any of:\n\n```\nresume.tex    cover.tex    resume.html    cover.html\n```\n\nOut of the box you get `default`. To author your own:\n\n```bash\nmkdir -p ~/job-themes/compact\n# Author resume.tex / cover.tex / resume.html / cover.html in there.\n# Each theme file is a plain template with {{PLACEHOLDER}} slots — see\n# TEMPLATES.md for the full placeholder contract.\n\nexport JOBOPS_TEMPLATE_DIR=~/job-themes\nnpx @mohith_das/jobops templates             # lists bundled + user themes\n\n# Then in your MCP chat:\n# render_pdf job_id=... kind=both formats=[\"pdf\",\"tex\"] template=\"compact\" cover_body=\"...\"\n```\n\nThe loader checks `$JOBOPS_TEMPLATE_DIR` **first**, so a `default/` directory\ninside your themes dir overrides the bundled default. Set `JOBOPS_DEFAULT_TEMPLATE`\nto make a non-`default` theme the implicit default for every call.\n\n| Env var | Default | What it does |\n|---------|---------|--------------|\n| `JOBOPS_TEMPLATE_DIR` | _empty_ | Extra dir holding your custom themes (one subdir per theme). |\n| `JOBOPS_DEFAULT_TEMPLATE` | `default` | Theme used when `render_pdf` has no explicit `template` arg. |\n\nA custom theme that omits a placeholder degrades gracefully (the section is dropped,\nthe renderer does not crash). In `.tex` themes, commenting a placeholder out\n(`% {{SUMMARY_SECTION}}`) drops the section the same way — substitution skips\nLaTeX comments. A malformed theme (missing `\\documentclass`, missing\n`\\begin{document}`, etc.) returns a clear error naming the theme + file — pdflatex's\nown backtrace never reaches the user. The visa-leakage scan and ATS hard rules apply\nregardless of which theme you pick.\n\nSee [`TEMPLATES.md`](./TEMPLATES.md) for the full placeholder reference + an example\ncustom theme.\n\n> `.docx` is generated programmatically and does **not** use themes. The Word file\n> follows a fixed Calibri / heading-style layout for ATS friendliness; edit the\n> output in Word if you need visual variation.\n\n---\n\n## Advanced / outreach features (optional)\n\n### Importing your LinkedIn network → warm-intro finder\n\nDownload your LinkedIn data export (Settings → Data Privacy → Get a copy of your data →\nConnections), then:\n\n```bash\n# Through your MCP chat:\nimport_linkedin path=\"/absolute/path/to/Connections.csv\"\n```\n\nNow `find_warm_intros(company=\"…\")` returns the people you actually know who work there\n(filtered to non-recruiters, sorted by engineering / leadership weight).\n\n**Adding contacts from chat (no CSV).** Found someone useful mid-search, or want to capture a\nfew people without a bulk export? Use **`add_contacts`** — it takes an array of 1..N contacts\nin one call and upserts them into the same store, so they show up in `find_warm_intros` /\n`find_founders` immediately:\n\n```text\nadd_contacts contacts=[\n  { \"full_name\": \"Dana Lee\", \"company\": \"Anthropic, Inc.\", \"title\": \"Staff Engineer\",\n    \"linkedin_url\": \"https://linkedin.com/in/dana-lee\" },\n  { \"full_name\": \"Sam Park\", \"company\": \"Vercel\", \"title\": \"Head of Talent\" }\n]\n```\n\nOnly `full_name` is required. It matches existing people (by `linkedin_url`, else\n`full_name` + company) so there are **no silent duplicates**; merges on update (omitted\nfields are preserved); resolves company names with the **same fuzzy normalization** as the CSV\npath; and infers `is_recruiter` / `is_engineering` / `is_leadership` from the title unless you\npass them. Partial contacts are stored and the per-contact result reports what was missing\n(`no linkedin_url`, company unmatched, …) so the chat can ask you to fill the gaps. (Claude\nparses your free-text/pasted contact info into these fields before calling.)\n\n**Backup, portability, and safe deletion** — all non-destructive, sharing one philosophy\n(timestamped backups before anything risky; deletes are reversible):\n\n- **`export_contacts`** writes **every** contact and field (flags, notes, email, resolved\n  company, ids, archived state) to timestamped `contacts_export_*.csv` **and** `.json` in the\n  project root. Your backup / portability path.\n- **`import_contacts path=\"…\"`** reads a `.json`/`.csv` (e.g. a prior export) and **upserts /\n  merges — never delete-and-replace**: blank/absent fields never overwrite richer existing\n  data, existing rows match (no duplicates), new rows insert. Re-importing an export is\n  **idempotent** — same DB, zero loss. A backup is written **before** the import.\n- **`delete_contacts`** soft-deletes 1..N people (by `linkedin_url`, `full_name`+company, or\n  `id`). Archived contacts vanish from `find_warm_intros` / `find_founders` but **stay\n  recoverable** in the row; a backup is written first; and the result **echoes exactly which\n  rows matched** (name + company + url) so a wrong fuzzy match is caught.\n\n> **Guarantee:** export → (edits happen) → re-import never destroys data not present in the\n> imported file. The imported file is additive/updating, not authoritative-overwrite.\n\nCompany names are matched **fuzzily** so legal-name variants line up: `import_linkedin`,\n`import_h1b`, JD ingestion, `visa_signal`, and `find_warm_intros` all normalize names by\nstripping common legal suffixes (Inc, LLC, PBC, Ltd, Corp, Co, GmbH, …), lowercasing, and\ntrimming punctuation. So a LinkedIn connection at \"Anthropic\", an H1B filing under\n\"ANTHROPIC PBC\", and a JD scraped as \"Anthropic, Inc.\" all resolve to the **same company\nrow** — which is what makes warm-intro and visa-signal joins actually work. Resolved\nvariants are recorded in the `company_aliases` table.\n\n### Importing DOL OFLC H1B data → visa-friendliness signal\n\nDownload a quarterly LCA disclosure CSV from <https://www.dol.gov/agencies/eta/foreign-labor/performance>,\nthen:\n\n```bash\n# Through your MCP chat:\nimport_h1b path=\"/absolute/path/to/LCA_Disclosure_Data_FY2025_Q1.csv\"\n```\n\n`visa_signal(company=\"…\")` then returns a friendliness band (`strong | mixed | weak |\nnone`) computed from filings count + recency. **Internal only** — never surfaced in any\nresume, cover letter, or outreach (see the visa hard rule).\n\nIf you disabled visa scoring (`JOBOPS_VISA_SCORING=false`), these tools don't appear in\n`tools/list` at all.\n\n### Scheduler (opt-in cron)\n\nOff by default. To run scans + batch rates on a schedule:\n\n```bash\n# In your MCP chat:\nscheduler_enable jobs=[\"scan_portals_4h\", \"batch_evaluate_30m\", \"daily_digest_morning\"]\n```\n\nJob cadence is fixed (4h / 30m / hourly with an 8AM digest window). Toggle off with\n`scheduler_disable`. Survives only as long as the server process is alive.\n\n---\n\n## Federation — private edge-intelligence node\n\nJobOps operates as a **private edge-intelligence node** in a federated talent discovery\nnetwork. Your local data (cv.md, profile.yml, story bank) is compiled into a canonical\n`career-packet.json`, synced to LivingCV (your personal relay), embedded locally, and\nbroadcast as a signed signal snapshot to HireBridge (the central router) for opportunity\nmatching. **No data leaves your machine without your explicit action** — embeddings are\ngenerated locally by default.\n\n### Data flow\n\n```\ncv.md + profile.yml + story_bank\n    ↓ compile_career_packet\ncareer-packet.json (JSON Resume superset + Lightcast IDs + evidence)\n    ↓ sync_to_livingcv (MCP SSE → sync_career_packet {packet})\nLivingCV (canonical store — own /career-packet.json route)\n    ↓ generate_embeddings\nlocal vectors (cached by packet hash; __packet__ first)\n    ↓ broadcast_signal (POST /ingest/snapshot, ed25519-signed)\nHireBridge router (signed snapshot — `embedding[0]` is the only one indexed)\n```\n\n### Federation contract v1 (shared with hirebridge + LivingCV)\n\njobops implements the **Canonical Federation Contract v1** — the same envelope,\nauth flow, and signature scheme the other two repos use. Key invariants:\n\n- `candidate_id` = first 32 hex chars of `sha256(lowercase(trim(email)))`.\n- Every snapshot is signed with **ed25519**, not HMAC. jobops generates the\n  keypair on first connect and persists it in `federation_state`; the\n  `public_key` is registered with HireBridge at `/auth/device` time.\n- The whole-packet embedding **must** be `embedding[0]` — HireBridge indexes\n  only element 0 (`hirebridge/internal/store/repo/snapshots.go:45-57`).\n- LivingCV sync is **MCP-over-SSE**, not bare JSON-RPC. The bearer token is\n  LivingCV's `mcp.master_key` (Admin → Settings → MCP). The tool name is\n  `sync_career_packet` with arguments `{packet: <LcvPacket>}`.\n\n### CLI commands\n\n```bash\n# Compile the canonical career-packet.json\njobops compile-packet                    # maps skills to Lightcast IDs via LLM\njobops compile-packet --skip-lightcast   # skip Lightcast mapping (faster)\n\n# Sync career-packet.json to your LivingCV instance\njobops sync                              # push to LivingCV\njobops sync --force                      # push even if content unchanged\n\n# Authenticate with HireBridge via magic link\njobops connect_to_hirebridge             # prompts for email\njobops connect_to_hirebridge --email you@example.com\n\n# Broadcast signal snapshot to HireBridge\njobops broadcast\n\n# Check for jobops updates\njobops update\n```\n\n### MCP tools\n\n| Tool | Description |\n|------|-------------|\n| `compile_career_packet` | Compile cv.md + profile.yml + story_bank into `career-packet.json` (JSON Resume superset with Lightcast Open Skills IDs). Persists to DB + writes `output/career-packet.json`. |\n| `get_career_packet_json` | Get the active compiled career packet (read-only, **public-safe**). |\n| `sync_to_livingcv` | Push the compiled career packet to LivingCV via **MCP SSE** (tool `sync_career_packet {packet}`). Requires `JOBOPS_LIVINGCV_URL` + `JOBOPS_LIVINGCV_TOKEN`. |\n| `generate_embeddings` | Generate vector embeddings of career packet sections using the configured provider (local, OpenAI, or Voyage). Cached by packet hash. The `__packet__` section is the canonical whole-packet vector broadcast at `embedding[0]`. |\n| `get_embeddings` | Get cached embedding metadata (sections, model, dim) without the vector data. Read-only. |\n| `broadcast_signal` | Compile and broadcast a signed snapshot to `POST /ingest/snapshot`. Envelope is `{candidate_id, payload, embedding, signature}`; signature is **ed25519** over the exact payload bytes (NOT HMAC). |\n| `get_federation_status` | Get federation state: LivingCV sync time, HireBridge connection status, cached embedding count, broadcast history. Read-only. |\n| `update_jobops` | Check npm registry for updates. Reports the update command if a newer version is available. |\n\n### HireBridge device-auth + magic link flow\n\n```\n1. jobops connect_to_hirebridge\n2. Enter your email\n3. jobops → POST /auth/device  JSON {node_type:\"jobops\", endpoint_url, public_key}\n                                  ← {device_code, user_code, verification_uri, …}\n4. jobops → POST /auth/request  form-encoded email=<you>&uc=<user_code>\n                                  → HireBridge emails the magic link\n5. Click the magic link in your email (or open verification_uri_complete)\n6. jobops → POST /auth/token  form-encoded grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=…\n                                  ← 200 {access_token, node_id, email}\n7. Token + node_id saved to .env + federation_state\n```\n\nPer [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628), pending states\nreturn **HTTP 400** with a JSON `{error: …}` body. The polling loop in jobops\nreads the body on **every** response (including 400s) and switches on\n`authorization_pending`, `slow_down`, `expired_token`, and `access_denied`.\nThrowing on non-2xx would abort the very first poll — this was a real v0.16.x\nbug; the v1 client is correct.\n\nThe bearer token is a long-lived token stored in `.env` (`JOBOPS_HIREBRIDGE_TOKEN`).\njobops **never** stores the bearer in the career packet and never sends it on\nsnapshots — snapshots are signed with ed25519 against the locally-stored\nprivate key (the server verifies with the `public_key` registered at device\ninit).\n\n### Embedding providers\n\n| Provider | Env var | Model | Dim | Notes |\n|----------|---------|-------|-----|-------|\n| **local** (default) | `JOBOPS_EMBEDDING_PROVIDER=local` | `all-MiniLM-L6-v2` | 384 | Runs entirely on-device via `@xenova/transformers`. Model auto-downloads on first use (~90MB). No data leaves your machine. |\n| **openai** | `JOBOPS_EMBEDDING_PROVIDER=openai` | `text-embedding-3-small` | 1536 | Requires `OPENAI_API_KEY`. |\n| **voyage** | `JOBOPS_EMBEDDING_PROVIDER=voyage` | `voyage-3-lite` | 384 | Requires `VOYAGE_API_KEY`. |\n| **none** | `JOBOPS_EMBEDDING_PROVIDER=none` | — | — | Embedding generation is disabled. Broadcast won't work without embeddings. |\n\n### Full workflow example\n\n```text\n# In chat (or CLI):\ncompile_career_packet             # Step 1: build career-packet.json\ngenerate_embeddings               # Step 2: embed locally\nsync_to_livingcv                  # Step 3: push to LivingCV\nconnect_to_hirebridge             # Step 4: auth with HireBridge (one-time)\nbroadcast_signal                  # Step 5: broadcast signed signal\nget_federation_status             # Check sync/broadcast state\n```\n\n### Signal snapshot shape (contract v1)\n\n```json\n{\n  \"candidate_id\": \"8a3f... (32-hex, sha256(lowercase(email))[:16] bytes)\",\n  \"payload\": {\n    \"candidate\":     { \"email\": \"...\", \"livingcv_url\": \"...\" },\n    \"career_packet\": { /* public-safe compiled packet — the same blob get_career_packet_json returns */ },\n    \"capabilities\":  [{ \"competency\": \"leadership\", \"evidence_count\": 3, \"story_ids\": [\"...\"] }],\n    \"packet_hash\":   \"sha256...\",\n    \"packet_version\": 3,\n    \"broadcast_at\":  \"2026-07-10T...\"\n  },\n  \"embedding\":  [ [/* __packet__ vector — dim matches HB_EMBED_DIM, default 384 */], [...per-section vectors] ],\n  \"signature\":  \"ed25519 hex over JSON.stringify(payload) bytes\"\n}\n```\n\nThe signature is **ed25519**, keyed by the local keypair registered at\n`/auth/device` time. HireBridge recomputes it from the exact payload bytes\nand verifies against the stored `public_key`. Throwing HMAC at it would just\nbe silently accepted as key-less — this is the v0.16.x failure mode the v1\ncontract fixes.\n\n`embedding[0]` is the canonical whole-packet vector (section `__packet__` in\nthe local cache). HireBridge's repo indexes only element 0 (see\n`hirebridge/internal/store/repo/snapshots.go:45-57`); per-section vectors may\nfollow but are ignored at storage time.\n\n### LivingCV sync preconditions\n\nThe LivingCV stack must have these Admin → Settings → MCP flags enabled or\n`sync_to_livingcv` returns 401/403:\n- `mcp.enabled = 1`\n- `mcp.jobops_sync_enabled = 1`\n\n`JOBOPS_LIVINGCV_TOKEN` is the **master-relay key** (`mcp.master_key` in\nLivingCV Admin → Settings → MCP) — it carries the jobops-sync scope.\n\n> **Public-safe surface:** `get_career_packet_json` is marked `publicSafe`, meaning it's\n> available on the `/mcp/public` endpoint for external recruiter-side AI agents. It returns\n> the structured career packet (JSON Resume superset) — the same packet synced to\n> LivingCV and embedded for broadcast. No PII beyond what's in your profile.yml identity\n> fields is exposed.\n\n---\n\n## Hard rules baked in\n\n1. **Never surface visa / work-auth** in any resume, cover letter, or outreach. Visa data\n   is internal scoring only.\n2. **Never invent claims** not in `career_packet`. The materials generator validates LLM\n   output against the packet before persisting.\n3. **Human-in-the-loop everywhere.** No tool auto-submits an application or auto-sends a\n   DM. `apply_prefill` is preview-only — it opens the form in Chromium, drafts values,\n   takes a screenshot, and stops. You submit manually.\n4. **Strict-JSON parsing on the api path** with a recorded `PARSE_ERROR` fallback —\n   never silent zeros.\n5. **Tracker / application / outreach writes are serialized** behind a single write lock.\n\n---\n\n## Layout\n\n```\njobops/\n├── modes/                     # MCP resources (edit me to tune the brain)\n├── templates/                 # CV HTML/LaTeX templates + cover-letter template\n├── fonts/                     # Space Grotesk, DM Sans (woff2 subsets)\n├── cv.example.md              # → cv.md after init\n├── config/profile.example.yml # → config/profile.yml after init\n├── portals.example.yml        # → portals.yml after init\n├── .env.example               # → .env after init (LivingCV, HireBridge, embedding config)\n├── src/                       # TypeScript source (not published)\n│   ├── cli.ts                 # init / start / doctor / connect / compile-packet / sync / connect_to_hirebridge / broadcast / update\n│   ├── server.ts              # HTTP + MCP boot\n│   ├── core/                  # llm, providers, jobs, reports, render, scan_engine, …\n│   │   ├── career_packet_json.ts  # federation: compile career-packet.json\n│   │   ├── lightcast.ts           # federation: LLM-assisted Lightcast skill mapping\n│   │   ├── embeddings.ts          # federation: local/API embedding providers\n│   │   ├── livingcv_client.ts     # federation: JSON-RPC sync to LivingCV\n│   │   ├── hirebridge_client.ts   # federation: device auth + signal broadcast\n│   │   ├── signal_broadcast.ts    # federation: snapshot compiler + broadcaster\n│   │   ├── updater.ts             # federation: npm registry version checker\n│   │   └── env_loader.ts          # .env file loader (precedence: shell > .env > legacy)\n│   ├── http/                  # express app + dashboard\n│   ├── mcp/                   # define + register + tools/\n│   │   └── tools/\n│   │       ├── career_packet_json.ts  # federation tools\n│   │       ├── embeddings.ts          # federation tools\n│   │       ├── sync_to_livingcv.ts    # federation tools\n│   │       ├── signal_broadcast.ts    # federation tools\n│   │       └── update_jobops.ts       # federation tools\n│   └── migrations/*.sql       # SQLite migrations (007 added federation tables)\n└── data/, output/             # gitignored runtime state\n```\n\n---\n\n## Attribution\n\n- The HTML CV template, font set, and ATS unicode-normalization logic are ported from\n  [santifer/career-ops](https://github.com/santifer/career-ops) (MIT licensed). The\n  6-block A–F report shape, scoring rubric framing, and outreach tone rules are also\n  inspired by that project. **Not affiliated with or endorsed by career-ops** — this is\n  an independent fork of those publicly-released ideas into the MCP transport.\n- The 3-dimension scoring formula (resume / taste / visa), the schema shape\n  (companies / jobs / outreach / enrichment / career_packet views), and the strict-JSON\n  rater rubric are distilled from a personal pipeline (\"JSA\") that predates this\n  project.\n\n---\n\n## Releasing (maintainer notes)\n\nReleases ship to npm via the GitHub Actions workflow at\n[`.github/workflows/publish.yml`](.github/workflows/publish.yml). The workflow fires\n**only on pushing a version tag** (`v*.*.*`) — never on a push to `main` — so merging\nwork never auto-publishes.\n\n### One-time setup\n\n1. **Generate an npm automation token** at [npmjs.com](https://www.npmjs.com/) →\n   click your avatar → **Access Tokens** → **Generate New Token** → choose\n   **\"Automation\"** (NOT *Read-Only* and NOT *Publish*; Automation tokens bypass 2FA,\n   which CI needs).\n2. **Add it to GitHub.** In the repo → **Settings** → **Secrets and variables** →\n   **Actions** → **New repository secret** → name **`NPM_TOKEN`**, value the token\n   you just copied (starts with `npm_`).\n\n### Cutting a release\n\n```bash\n# 1. Bump the version in package.json. Either edit by hand, or:\nnpm version patch      # 0.3.0 → 0.3.1 (also creates a git commit + tag)\n# npm version minor    # 0.3.0 → 0.4.0\n# npm version major    # 0.3.0 → 1.0.0\n\n# 2. If you edited package.json by hand instead of `npm version`, commit it:\n# git add package.json && git commit -m \"release: vX.Y.Z\"\n# git tag vX.Y.Z\n\n# 3. Push the commit + tag.\ngit push && git push origin vX.Y.Z\n```\n\nThat tag push triggers `publish.yml`, which:\n\n1. Checks out the tagged commit.\n2. Sets up Node 20 with the npm registry.\n3. Verifies the tag (`vX.Y.Z`) matches `package.json`'s `version` — fails fast on a typo.\n4. `npm ci` + `npm run build`.\n5. `npm publish --access public --provenance` — provenance attaches a sigstore\n   attestation visible on npmjs.com showing exactly which GitHub Actions run produced\n   the tarball.\n\nWatch progress in the repo's **Actions** tab. On success the new version appears on\n[npmjs.com/package/jobops](https://www.npmjs.com/package/jobops).\n\n## Contributing / feedback\n\nIssues + PRs welcome. There's no contributor guide yet — open an issue first if you're\nplanning a large change.\n\n---\n\nMIT — see [LICENSE](./LICENSE).\n"}