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

Career-ops uses a sandboxed plugin architecture that lets you add external data sources by creating a directory containing a 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 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 (⚠️).

A plugin is considered enabled only if 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.

How the Built-in Integrations Work

All three official integrations follow the same pattern: a manifest.json declaring metadata and an index.mjs implementing hook functions.

Gmail Ingest Integration

File: 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.

File: 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

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, 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):

    mkdir plugins.local/myats
  2. Write the manifest (manifest.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):

    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):

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

  5. Populate secrets in .env:

    echo "MYATS_API_KEY=sk_live_abc123" >> .env
    echo "MYATS_SUBDOMAIN=acme" >> .env
  6. Enable the plugin:

    node plugins.mjs enable myats --confirm

    This writes the consent card to plugins.lock and updates config/plugins.yml.

  7. Execute the hook:

    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:

- 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 (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.

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 →