Career-Ops Plugin Architecture Explained: How to Develop Custom Plugins

Career-Ops extends its core job-search automation through an opt-in plugin layer managed by plugins/_engine.mjs, which discovers extensions from plugins/ and plugins.local/, validates declarative manifests, and executes hooks inside a guarded context (ctx) that enforces least-privilege network egress and secret isolation.

The santifer/career-ops repository implements a plugin architecture designed for safe, audited extensibility. Unlike monolithic systems that require core modifications, Career-Ops loads external logic through a hardened engine that treats every plugin as untrusted code, ensuring zero side-effects on import and fail-open error handling.

Core Components of the Plugin Architecture

Plugin Directories: plugins/ vs plugins.local/

The architecture recognizes two distinct locations for extensions. The plugins/ directory contains bundled, reviewed plugins shipped with the repository. The plugins.local/ directory holds user-owned, experimental, or private plugins and is git-ignored by default. During discovery, the engine walks both directories with a first-root-wins policy, though approved successors in the registry can override bundled references.

The Manifest File (manifest.json)

Every plugin must contain a manifest.json that declares its contract before any code is imported. This declarative file specifies the plugin ID, API version, supported hooks, required environment variables, and allowed egress hosts. The engine validates this schema strictly; a malformed manifest blocks loading entirely.

{
  "id": "my-slack",
  "apiVersion": 1,
  "description": "Push job notifications to a Slack channel.",
  "hooks": ["notify"],
  "requiredEnv": ["SLACK_BOT_TOKEN"],
  "allowedHosts": ["hooks.slack.com"],
  "humanInTheLoop": true
}

The Plugin Engine (plugins/_engine.mjs)

The heart of the system resides in plugins/_engine.mjs. This module exposes functions like discoverPlugins(), validateManifest(), and runHook() to orchestrate the lifecycle. It enforces four critical constraints: zero side-effects on import, fail-open handling, opt-in activation via config/plugins.yml, and least-privilege network egress through an HTTPS allow-list.

The Execution Context (ctx)

When a hook executes, it receives a ctx object built by buildCtx(). This context provides:

  • ctx.fetch: A guarded fetch implementation that only communicates with hosts listed in allowedHosts and strips credentials on hostname changes.
  • ctx.env: A frozen object containing only the secret variables declared in requiredEnv.
  • ctx.settings: A map for non-secret configuration values from config/plugins.yml.
  • ctx.log: A redacting logger that prevents accidental secret leakage.

Plugin Lifecycle and Security Model

The plugin engine processes every request through a six-stage pipeline that prioritizes security and stability.

  1. Discovery – discoverPlugins() scans plugins/ then plugins.local/.
  2. Manifest Validation – validateManifest() checks schema compliance, ensures required env vars do not clash with core secrets, and verifies that allowedHosts is present when secrets are required.
  3. Enable Check – pluginStatus() reads config/plugins.yml; a plugin loads only when enabled: true and all requiredEnv variables exist in process.env.
  4. Integrity Gate – lockGate() compares the plugin’s cryptographic hash against plugins.lock.json. If the code has drifted without re-consent, the load is blocked until the user runs plugins.mjs enable ... --confirm.
  5. Context Creation – buildCtx() assembles the limited execution environment.
  6. Hook Execution – importHook() loads the plugin’s index.mjs, and runHook() executes the function with a cooperative timeout, catching errors to ensure the core continues unchanged (fail-open behavior).

How to Develop a Custom Plugin for Career-Ops

Step 1: Scaffold the Plugin Structure

Create a new folder under plugins.local/ to house your extension. For example, plugins.local/my-slack/ will contain your manifest and entry point. Alternatively, use the CLI to generate a template:

node plugins.mjs new my-slack

Step 2: Create the manifest.json

Define your plugin’s capabilities and security requirements. Declare which hooks you implement (e.g., provider, ingest, search, export, notify), which secrets you need, and which external domains you will contact.

{
  "id": "my-slack",
  "apiVersion": 1,
  "description": "Push job notifications to a Slack channel.",
  "hooks": ["notify"],
  "requiredEnv": ["SLACK_BOT_TOKEN"],
  "allowedHosts": ["hooks.slack.com"],
  "humanInTheLoop": true
}

Step 3: Implement Hooks in index.mjs

Create an index.mjs file that exports an object keyed by hook names. Each hook receives a payload and the guarded ctx. Always use ctx.fetch instead of the global fetch to ensure network restrictions apply.

// plugins.local/my-slack/index.mjs
export default {
  notify: async (payload, ctx) => {
    const { message } = payload;
    const url = `https://hooks.slack.com/services/${process.env.SLACK_BOT_TOKEN}`;
    
    await ctx.fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: message }),
    });
    
    ctx.log('Sent Slack notification');
  },
};

Step 4: Enable via config/plugins.yml

Copy config/plugins.example.yml to config/plugins.yml and enable your plugin:

plugins:
  my-slack:
    enabled: true

Ensure the required secret is available in your environment:

export SLACK_BOT_TOKEN=xxxxx/xxxxx/xxxxx

Step 5: Manage with the CLI (plugins.mjs)

Use the plugins.mjs CLI host to manage your extension:


# List all discovered plugins and their status

node plugins.mjs list

# Run a specific hook with dry-run to verify payload

node plugins.mjs run my-slack notify "Test message" --dry-run

# Enable and consent to the plugin's current hash

node plugins.mjs enable my-slack --confirm

The integrity system in plugins/_lock.mjs automatically records a hash on first successful load. If you modify the plugin code, you must re-consent using the --confirm flag to update the lock file.

Summary

  • The Career-Ops plugin architecture uses an opt-in engine in plugins/_engine.mjs to load extensions from plugins/ and plugins.local/.
  • Plugins declare their capabilities and security requirements via manifest.json before any code executes.
  • The ctx object provides a guarded fetch, frozen secrets, and redacted logging to enforce least-privilege execution.
  • Plugins are fail-open: errors or misconfigurations log warnings but never crash the core application.
  • Development requires creating a folder in plugins.local/, implementing hooks in index.mjs, and enabling the plugin in config/plugins.yml.

Frequently Asked Questions

What hooks are available in the Career-Ops plugin architecture?

The architecture supports five primary hook types: provider (for job source integrations), ingest (for data normalization), search (for custom filtering logic), export (for data egress formats), and notify (for alerting channels). Each hook receives a standardized payload and the guarded ctx object. You can implement any subset of these hooks in your index.mjs export object.

How does the engine ensure plugin security?

Security relies on multiple layers enforced by plugins/_engine.mjs: manifest validation blocks code loading if the schema is invalid; the integrity gate in plugins/_lock.mjs prevents execution of modified code without user consent; the guarded ctx.fetch restricts HTTPS requests to explicitly allowed hosts; and the frozen ctx.env injects only declared secrets. Additionally, the system is fail-open, meaning a compromised or crashing plugin cannot destabilize the core application.

Can I override bundled plugins with custom versions?

Yes. The discovery process in discoverPlugins() walks plugins/ first, then plugins.local/, with a first-root-wins policy. Furthermore, plugins/_registry.mjs tracks community-approved plugins and defines successor rules that allow a vetted community plugin to replace a bundled reference. This enables users to swap official integrations with forked or enhanced versions while maintaining audit trails.

What happens when a plugin crashes during execution?

The plugin engine implements fail-open semantics. If runHook() catches an exception, times out, or encounters a malformed manifest, it logs a warning (⚠️) and returns an error result to the caller without propagating the failure to the core system. This ensures that a broken external integration (e.g., a down API endpoint) does not interrupt your job search automation workflow.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →