Files
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00
..

career-ops plugins

The plugin layer is the opt-in home for integrations that need a key or talk to an external service — things the zero-keys, local-first core deliberately doesn't carry. It generalizes the proven providers/ pattern: drop a directory in here, declare a manifest, and it's discovered automatically.

Not the Claude Code plugin. This is unrelated to .claude-plugin/ (the Claude Code marketplace metadata). These plugins extend career-ops itself.

Default: off

Plugins load only when you opt in. With no config/plugins.yml, the core runs exactly as it always has — no plugin code runs, no .env is read, nothing changes. Two gates must both be satisfied:

  1. Enable the plugin in config/plugins.yml (copy config/plugins.example.yml).
  2. Provide its keys in your own .env (each plugin declares which it needs). Run node doctor.mjs or node plugins.mjs list to see what's missing.

Anatomy of a plugin

A plugin is a directory under plugins/ (bundled, shipped with career-ops) or plugins.local/ (your own, gitignored, never auto-updated):

plugins/<id>/
  manifest.json     # parsed, not executed — validated before any code is imported
  index.mjs         # default-exports an object keyed by hook type
  _anything.mjs     # helpers (the _ prefix means "never discovered as a plugin")

manifest.json

{
  "id": "wellfound",                 // must equal the directory name; [a-z0-9-]
  "apiVersion": 1,
  "description": "One mission-framed line.",
  "hooks": ["provider"],             // any of: provider, ingest, search, notify, export
  "requiredEnv": ["WELLFOUND_TOKEN"],// env VAR NAMES only — values go in .env
  "allowedHosts": ["api.wellfound.com"], // required when requiredEnv is non-empty
  "humanInTheLoop": true             // must be true
}

Hooks (index.mjs default export)

Hook Signature Does
provider { id, detect?, fetch(entry, ctx) → Job[] } A keyed/auth-gated job source. Same shape as providers/_types.js. Runs via scan on a provider: <id> entry in portals.yml.
ingest (ctx) → Job[] Pull postings from a service (email, a board).
search (query, ctx) → Job[] Postings for a query string.
export (snapshot, ctx) → {pushed} Push a read-only tracker snapshot to your own external store.
notify (payload, ctx) → void Send an outbound notification.

Producers (provider/ingest/search) return Job[] ({title, url, company, location}); the engine — never the plugin — writes them to data/pipeline.md through the canonical writer, so a plugin can't break the data formats the web reads. Non-provider hooks run explicitly:

node plugins.mjs list
node plugins.mjs run gmail                       # ingest
node plugins.mjs run notion search "platform"    # search
node plugins.mjs run notion export [--dry-run]   # export

The ctx object

  • fetch(url, opts) — the guarded primitive: HTTPS-only, pinned to your allowedHosts, redirect:'manual' re-validating every hop and stripping credentials on a hostname change. Route your HTTP through ctx.fetch (or the fetchText/fetchJson conveniences over it) so the egress guard actually runs — a plugin that calls global fetch bypasses it (the bundled apify plugin is one deliberate exception: its client self-constrains to a single hardcoded host, documented in its code).
  • env (frozen, scoped to your declared keys), settings (your non-secret config/plugins.yml block), log (redacts your declared secrets), dryRun.

Your own plugins → plugins.local/

Put private or experimental plugins in plugins.local/ (a sibling of plugins/), never in plugins/. plugins.local/ is gitignored and never auto-updated, so updates can't clobber it and a same-id bundled plugin can't be shadowed by it. Bundled plugins always win an id collision.

Trust model (read this)

career-ops is plain ESM with no build step, so the engine cannot truly sandbox a plugin's imports. allowedHosts, the scoped ctx.env, and the no-auto-submit hook taxonomy constrain an honest plugin and make every loaded plugin visible (doctor / plugins.mjs list) — but they are not a hard boundary against malicious code, which can reach process.env or the network directly. Containment is the same as everywhere else in open source:

  • Bundled plugins (plugins/) are code-reviewed exactly like providers/. CI checks that they declare no core-owned secret, import no browser-automation or process-spawning module, and never auto-submit.
  • plugins.local/ runs with your trust — you installed it. Treat a third-party plugin like any code you run on your machine.

Not a plugin

These don't belong in the plugin layer — they're a different direction:

  • Centralized infrastructure the project would operate — hosted job aggregation, a shared matching service, proxies/Workers. That's a separate, opt-in service, discussed in Where career-ops is going (#904) — not the open-core.
  • Auto-submitting / blind-applying to jobs. career-ops is a decision-support tool, not a spam bot — it drafts applications for you to review and submit. No hook can submit, and humanInTheLoop: true is mandatory. This holds everywhere, in core and plugins alike.

See CONTRIBUTING.md → "Scope" for the full boundary.