# How to Extend the Career-ops Plugin System with Gmail, Notion, and Apify Integrations

> Discover how to extend the career-ops plugin system by integrating Gmail, Notion, and Apify. Learn to add external data sources using its sandboxed plugin architecture and CLI.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: how-to-guide
- Published: 2026-08-19

---

**Career-ops uses a sandboxed plugin architecture that lets you add external data sources by creating a directory containing a [`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json) and an `index.mjs` file, then enabling the plugin via CLI with `node plugins.mjs enable <id> --confirm`.**

Career-ops is an open-source job application tracker that ships with a lightweight, opt-in plugin system. You can extend the career-ops plugin system to ingest leads from Gmail, mirror your tracker to Notion, or integrate Apify actors without modifying the core scanning logic. The implementation relies on three core modules: `plugins.mjs` (CLI entry), `plugins/_engine.mjs` (execution engine), and individual plugin directories that expose standardized hooks.

## Plugin Architecture Overview

The system is designed to keep third-party code isolated from core application logic while maintaining a strict security boundary.

### Discovery and Validation

The `discoverPlugins()` function in `plugins/_engine.mjs` walks two directories: the bundled `plugins/` folder and the user-controlled `plugins.local/` folder. It loads every [`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json) file found and normalizes it through `validateManifest()`, which verifies hook names, required environment variables, and allowed egress hosts. Invalid manifests are skipped with a warning (`⚠️`).

### Enablement and Consent Gates

A plugin is considered **enabled** only if [`config/plugins.yml`](https://github.com/santifer/career-ops/blob/main/config/plugins.yml) contains `{ plugins: { <id>: { enabled: true } } }` and all `requiredEnv` variables are present. The `pluginStatus()` helper reports whether a plugin is enabled, configured, and which secrets are missing.

Before execution, `lockGate()` compares the on-disk plugin files against the stored hash in `plugins.lock`. If the hash drifts without a version bump, the plugin is blocked until the user re-consents via `node plugins.mjs enable <id> --confirm`.

### Context Building and Isolation

The `buildCtx()` function creates a minimal, sandboxed execution context for each plugin:

- **Guarded fetch**: A wrapped `fetch` that enforces HTTPS and an allow-list (`allowedHosts`) preventing unauthorized egress.
- **Frozen secrets**: A subset of `process.env` containing only the declared secret keys, passed as `ctx.env`.
- **Redacted logging**: A logger that automatically redacts sensitive values from output.

### Hook Execution

The `runHook(kind, payload, {root, dryRun})` function loads all enabled plugins exposing the requested hook kind (`ingest`, `search`, `export`, `notify`, or `provider`). Hooks execute in parallel with a default 15-second timeout per hook. Results are aggregated and returned to the CLI caller, which typically appends new data to [`data/pipeline.md`](https://github.com/santifer/career-ops/blob/main/data/pipeline.md).

## How the Built-in Integrations Work

All three official integrations follow the same pattern: a [`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json) declaring metadata and an `index.mjs` implementing hook functions.

### Gmail Ingest Integration

**File**: [`plugins/gmail/manifest.json`](https://github.com/santifer/career-ops/blob/main/plugins/gmail/manifest.json)

The Gmail plugin exposes an `ingest` hook that pulls job leads from a Gmail label.

- **ID**: `gmail`
- **Hook**: `ingest`
- **Required environment**: `GMAIL_CLIENT_ID`, `GMAIL_CLIENT_SECRET`, `GMAIL_REFRESH_TOKEN`
- **Allowed hosts**: `oauth2.googleapis.com`, `gmail.googleapis.com`

When you run `node plugins.mjs run gmail`, the engine checks credentials, builds a context where `ctx.fetch` can only reach Google APIs, and calls the `ingest` hook exported from `plugins/gmail/index.mjs`. The hook returns an array of job objects; the CLI sanitizes them to title and URL, then appends new entries to the pipeline.

### Notion Export and Search

**File**: [`plugins/notion/manifest.json`](https://github.com/santifer/career-ops/blob/main/plugins/notion/manifest.json)

The Notion plugin demonstrates multi-hook architecture with `export` and `search` capabilities.

- **ID**: `notion`
- **Hooks**: `export` (mirrors tracker to Notion), `search` (queries Notion DB for leads)
- **Required environment**: `NOTION_ACCESS_TOKEN`, `NOTION_PARENT_PAGE_ID`
- **Allowed host**: `api.notion.com`

Running `node plugins.mjs run notion export` triggers `buildSnapshot()` in `plugins.mjs`, passing the current tracker state to the `export` hook. The hook creates or updates Notion pages via the restricted `ctx.fetch`. Conversely, `node plugins.mjs run notion search "staff engineer"` invokes the `search` hook, which returns matching job objects for deduplication and pipeline insertion.

### Apify Provider Integration

**File**: [`plugins/apify/manifest.json`](https://github.com/santifer/career-ops/blob/main/plugins/apify/manifest.json)

Apify operates as a **provider-style** plugin, exposing a `provider` hook rather than data ingestion hooks.

- **ID**: `apify`
- **Hook**: `provider`
- **Required environment**: `APIFY_TOKEN`
- **Allowed host**: `api.apify.com`

When `scan.mjs` encounters a portal entry with `provider: apify` in [`portals.yml`](https://github.com/santifer/career-ops/blob/main/portals.yml), `mergeProviderPlugins()` injects the Apify provider into the core provider map. The provider's `fetch(entry, ctx)` method retrieves job data from the Apify actor. Provider plugins are *detect-exempt*: they only execute when explicitly referenced in configuration.

## Step-by-Step Implementation Guide

To extend the career-ops plugin system with a new integration (e.g., a custom ATS), follow this scaffold:

1. **Create the plugin directory** under `plugins/` (bundled) or `plugins.local/` (user-specific):

   ```bash
   mkdir plugins.local/myats
   ```

2. **Write the manifest** ([`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json)):

   ```json
   {
     "id": "myats",
     "name": "My ATS Connector",
     "version": "1.0.0",
     "hooks": ["ingest", "export"],
     "requiredEnv": ["MYATS_API_KEY", "MYATS_SUBDOMAIN"],
     "allowedHosts": ["api.myats.com"]
   }
   ```

3. **Implement the hooks** (`index.mjs`):

   ```javascript
   export default {
     // Ingest hook returns array of {title, url, ...}
     ingest: async (ctx) => {
       const { MYATS_API_KEY, MYATS_SUBDOMAIN } = ctx.env;
       const resp = await ctx.fetch(`https://api.myats.com/${MYATS_SUBDOMAIN}/jobs`, {
         headers: { Authorization: `Bearer ${MYATS_API_KEY}` }
       });
       const data = await resp.json();
       return data.jobs.map(job => ({
         title: job.title,
         url: job.apply_url,
         company: job.company.name
       }));
     },

     // Export hook receives {applications, pipeline} snapshot
     export: async (snapshot, ctx) => {
       const { MYATS_API_KEY } = ctx.env;
       const resp = await ctx.fetch('https://api.myats.com/applications', {
         method: 'POST',
         headers: { 
           'Content-Type': 'application/json',
           Authorization: `Bearer ${MYATS_API_KEY}`
         },
         body: JSON.stringify(snapshot.applications)
       });
       return { pushed: snapshot.applications.length };
     }
   };
   ```

4. **Add documentation** (optional [`skill.md`](https://github.com/santifer/career-ops/blob/main/skill.md)):

   This file is displayed when running `node plugins.mjs skill myats` but never affects runtime behavior.

5. **Populate secrets** in `.env`:

   ```bash
   echo "MYATS_API_KEY=sk_live_abc123" >> .env
   echo "MYATS_SUBDOMAIN=acme" >> .env
   ```

6. **Enable the plugin**:

   ```bash
   node plugins.mjs enable myats --confirm
   ```

   This writes the consent card to `plugins.lock` and updates [`config/plugins.yml`](https://github.com/santifer/career-ops/blob/main/config/plugins.yml).

7. **Execute the hook**:

   ```bash
   node plugins.mjs run myats ingest
   ```

## Provider Hook Deep Dive

Provider plugins like Apify integrate at the scanner level rather than the CLI level. When `scan.mjs` initializes, `mergeProviderPlugins()` adds enabled providers to the internal provider map. 

Unlike ingest hooks that run on-demand via CLI, provider hooks expose a `fetch(entry, ctx)` method that `scan.mjs` calls during the portal scanning loop. This allows the plugin to define custom retrieval logic while the core engine handles rate limiting and deduplication. To use a provider plugin, reference it in your [`portals.yml`](https://github.com/santifer/career-ops/blob/main/portals.yml):

```yaml
- name: Custom Actor Jobs
  provider: apify
  actorId: some-actor-id

```

## Security Model and Data Isolation

The career-ops plugin system enforces defense-in-depth through multiple layers:

- **Integrity verification**: The `lockGate()` function in `plugins/_engine.mjs` computes hashes of plugin files and compares them against `plugins.lock`. Any drift blocks execution until explicit re-consent.
- **Network containment**: The `buildCtx()` function creates a `fetch` wrapper that rejects requests to hosts not listed in the manifest's `allowedHosts` array.
- **Secret scope**: Only environment variables declared in `requiredEnv` are visible to the plugin via `ctx.env`. The parent process never exposes the full `process.env` object.
- **Timeout enforcement**: Individual hooks fail after 15 seconds by default, preventing runaway processes from hanging the CLI.

## Summary

- **Plugin structure**: Every integration requires a [`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json) (metadata and security declaration) and `index.mjs` (hook implementations) within a dedicated directory.
- **Security gates**: Plugins must pass validation (`validateManifest()`), enablement checks (`pluginStatus()`), and integrity verification (`lockGate()`) before execution.
- **Context isolation**: Hooks receive a sandboxed `ctx` object containing a restricted `fetch`, frozen secrets, and redacted logging via `buildCtx()`.
- **Hook types**: Use `ingest` for importing data, `export` for pushing snapshots, `search` for querying external databases, and `provider` for custom scanner implementations.
- **Activation workflow**: Enable plugins with `node plugins.mjs enable <id> --confirm` after setting required environment variables, then invoke with `node plugins.mjs run <id> <hook>`.

## Frequently Asked Questions

### What is the difference between bundled plugins and local plugins?

Bundled plugins reside in the repository's `plugins/` directory and ship with career-ops (Gmail, Notion, Apify). Local plugins live in `plugins.local/` and are ignored by version control, allowing you to develop private integrations or install community plugins via `plugin-install.mjs` without modifying core source code. Both follow identical validation and security rules.

### How does the plugin system handle authentication secrets?

Secrets must be declared in the manifest's `requiredEnv` array. During `buildCtx()`, the engine creates a frozen subset of `process.env` containing only those specific keys, passing them as `ctx.env` to the hook. The logger automatically redacts these values, and plugins cannot access undeclared environment variables or the broader system environment.

### Can I override built-in plugins with custom versions?

Yes. If you place a plugin with the same ID in `plugins.local/`, the discovery mechanism in `plugins/_engine.mjs` prioritizes the local version over the bundled one. This allows you to fork and modify official plugins (like Gmail or Notion) while maintaining the ability to diff against upstream updates. You must still re-consent via `lockGate()` when the local version changes.

### What happens if a plugin hook fails during execution?

The `runHook()` function executes hooks in parallel with `Promise.allSettled()`. If a hook throws or times out (default 15 seconds), the engine captures the error, logs it to stderr, and continues processing other enabled plugins. Failed hooks do not block the CLI pipeline; however, their partial results are not included in the aggregated return value passed to [`data/pipeline.md`](https://github.com/santifer/career-ops/blob/main/data/pipeline.md).