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

> Learn the Career-Ops plugin architecture and develop custom plugins. Discover how to extend job-search automation with secure, least-privilege extensions.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: architecture
- Published: 2026-07-03

---

**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`](https://github.com/santifer/career-ops/blob/main/manifest.json))

Every plugin must contain a **[`manifest.json`](https://github.com/santifer/career-ops/blob/main/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.

```json
{
  "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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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:

```bash
node plugins.mjs new my-slack

```

### Step 2: Create the [`manifest.json`](https://github.com/santifer/career-ops/blob/main/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.

```json
{
  "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.

```javascript
// 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`](https://github.com/santifer/career-ops/blob/main/config/plugins.yml)

Copy [`config/plugins.example.yml`](https://github.com/santifer/career-ops/blob/main/config/plugins.example.yml) to **[`config/plugins.yml`](https://github.com/santifer/career-ops/blob/main/config/plugins.yml)** and enable your plugin:

```yaml
plugins:
  my-slack:
    enabled: true

```

Ensure the required secret is available in your environment:

```bash
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:

```bash

# 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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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.