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:
- Enable the plugin in
config/plugins.yml(copyconfig/plugins.example.yml). - Provide its keys in your own
.env(each plugin declares which it needs). Runnode doctor.mjsornode plugins.mjs listto 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 yourallowedHosts,redirect:'manual're-validating every hop and stripping credentials on a hostname change. Route your HTTP throughctx.fetch(or thefetchText/fetchJsonconveniences over it) so the egress guard actually runs — a plugin that calls globalfetchbypasses it (the bundledapifyplugin 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-secretconfig/plugins.ymlblock),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 likeproviders/. 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: trueis mandatory. This holds everywhere, in core and plugins alike.
See CONTRIBUTING.md → "Scope" for the full boundary.