peregrine/docs/developer-guide/adding-integrations.md
pyr0ball 8cb636dabe docs: mkdocs wiki — installation, user guide, developer guide, reference
Adds a full MkDocs documentation site under docs/ with Material theme.

Getting Started: installation walkthrough, 7-step first-run wizard guide,
Docker Compose profile reference with GPU memory guidance and preflight.py
description.

User Guide: job discovery (search profiles, custom boards, enrichment),
job review (sorting, match scores, batch actions), apply workspace (cover
letter gen, PDF export, mark applied), interviews (kanban stages, company
research auto-trigger, survey assistant), email sync (IMAP, Gmail App
Password, classification labels, stage auto-updates), integrations (all 13
drivers with tier requirements), settings (every tab documented).

Developer Guide: contributing (dev env setup, code style, branch naming, PR
checklist), architecture (ASCII layer diagram, design decisions), adding
scrapers (full scrape() interface, registration, search profile config,
test patterns), adding integrations (IntegrationBase full interface, auto-
discovery, tier gating, test patterns), testing (patterns, fixtures, what
not to test).

Reference: tier system (full FEATURES table, can_use/tier_label API, dev
override, adding gates), LLM router (backend types, complete() signature,
fallback chains, vision routing, __auto__ resolution, adding backends),
config files (every file with field-level docs and gitignore status).

Also adds CONTRIBUTING.md at repo root pointing to the docs site.
2026-02-25 12:05:49 -08:00

8.6 KiB

Adding an Integration

Peregrine's integration system is auto-discovered — add a class and a config example, and it appears in the wizard and Settings automatically. No registration step is needed.


Step 1 — Create the integration module

Create scripts/integrations/myservice.py:

# scripts/integrations/myservice.py

from scripts.integrations.base import IntegrationBase


class MyServiceIntegration(IntegrationBase):
    name  = "myservice"      # must be unique; matches config filename
    label = "My Service"     # display name shown in the UI
    tier  = "free"           # "free" | "paid" | "premium"

    def fields(self) -> list[dict]:
        """Return form field definitions for the connection card in the wizard/Settings UI."""
        return [
            {
                "key":         "api_key",
                "label":       "API Key",
                "type":        "password",   # "text" | "password" | "url" | "checkbox"
                "placeholder": "sk-...",
                "required":    True,
                "help":        "Get your key at myservice.com/settings/api",
            },
            {
                "key":         "workspace_id",
                "label":       "Workspace ID",
                "type":        "text",
                "placeholder": "ws_abc123",
                "required":    True,
                "help":        "Found in your workspace URL",
            },
        ]

    def connect(self, config: dict) -> bool:
        """
        Store credentials in memory. Return True if all required fields are present.
        Does NOT verify credentials — call test() for that.
        """
        self._api_key      = config.get("api_key", "").strip()
        self._workspace_id = config.get("workspace_id", "").strip()
        return bool(self._api_key and self._workspace_id)

    def test(self) -> bool:
        """
        Verify the stored credentials actually work.
        Returns True on success, False on any failure.
        """
        try:
            import requests
            r = requests.get(
                "https://api.myservice.com/v1/ping",
                headers={"Authorization": f"Bearer {self._api_key}"},
                params={"workspace": self._workspace_id},
                timeout=5,
            )
            return r.ok
        except Exception:
            return False

    def sync(self, jobs: list[dict]) -> int:
        """
        Optional: push jobs to the external service.
        Return the count of successfully synced jobs.
        The default implementation in IntegrationBase returns 0 (no-op).
        Only override this if your integration supports job syncing
        (e.g. Notion, Airtable, Google Sheets).
        """
        synced = 0
        for job in jobs:
            try:
                self._push_job(job)
                synced += 1
            except Exception as e:
                print(f"[myservice] sync error for job {job.get('id')}: {e}")
        return synced

    def _push_job(self, job: dict) -> None:
        import requests
        requests.post(
            "https://api.myservice.com/v1/records",
            headers={"Authorization": f"Bearer {self._api_key}"},
            json={
                "workspace":  self._workspace_id,
                "title":      job.get("title", ""),
                "company":    job.get("company", ""),
                "status":     job.get("status", "pending"),
                "url":        job.get("url", ""),
            },
            timeout=10,
        ).raise_for_status()

Step 2 — Create the config example file

Create config/integrations/myservice.yaml.example:

# config/integrations/myservice.yaml.example
# Copy to config/integrations/myservice.yaml and fill in your credentials.
# This file is gitignored — never commit the live credentials.
api_key: ""
workspace_id: ""

The live credentials file (config/integrations/myservice.yaml) is gitignored automatically via the config/integrations/ entry in .gitignore.


Step 3 — Auto-discovery

No registration step is needed. The integration registry (scripts/integrations/__init__.py) imports all .py files in the integrations/ directory and discovers subclasses of IntegrationBase automatically.

On next startup, myservice will appear in:

  • The first-run wizard Step 7 (Integrations)
  • Settings → Integrations with a connection card rendered from fields()

Step 4 — Tier-gate new features (optional)

If you want to gate a specific action (not just the integration itself) behind a tier, add an entry to app/wizard/tiers.py:

FEATURES: dict[str, str] = {
    # ...existing entries...
    "myservice_sync": "paid",   # or "free" | "premium"
}

Then guard the action in the relevant UI page:

from app.wizard.tiers import can_use
from scripts.user_profile import UserProfile

user = UserProfile()
if can_use(user.tier, "myservice_sync"):
    # show the sync button
else:
    st.info("MyService sync requires a Paid plan.")

Step 5 — Write a test

Create or add to tests/test_integrations.py:

# tests/test_integrations.py (add to existing file)

import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from scripts.integrations.myservice import MyServiceIntegration


def test_fields_returns_required_keys():
    integration = MyServiceIntegration()
    fields = integration.fields()
    assert len(fields) >= 1
    for field in fields:
        assert "key" in field
        assert "label" in field
        assert "type" in field
        assert "required" in field


def test_connect_returns_true_with_valid_config():
    integration = MyServiceIntegration()
    result = integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})
    assert result is True


def test_connect_returns_false_with_missing_required_field():
    integration = MyServiceIntegration()
    result = integration.connect({"api_key": "", "workspace_id": "ws-123"})
    assert result is False


def test_test_returns_true_on_200(tmp_path):
    integration = MyServiceIntegration()
    integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})

    mock_resp = MagicMock()
    mock_resp.ok = True

    with patch("scripts.integrations.myservice.requests.get", return_value=mock_resp):
        assert integration.test() is True


def test_test_returns_false_on_error(tmp_path):
    integration = MyServiceIntegration()
    integration.connect({"api_key": "sk-abc", "workspace_id": "ws-123"})

    with patch("scripts.integrations.myservice.requests.get", side_effect=Exception("timeout")):
        assert integration.test() is False


def test_is_configured_reflects_file_presence(tmp_path):
    config_dir = tmp_path / "config"
    config_dir.mkdir()
    (config_dir / "integrations").mkdir()

    assert MyServiceIntegration.is_configured(config_dir) is False

    (config_dir / "integrations" / "myservice.yaml").write_text("api_key: sk-abc\n")
    assert MyServiceIntegration.is_configured(config_dir) is True

IntegrationBase Reference

All integrations inherit from scripts/integrations/base.py. Here is the full interface:

Method / attribute Required Description
name: str Yes Machine key — must be unique. Matches the YAML config filename.
label: str Yes Human-readable display name for the UI.
tier: str Yes Minimum tier: "free", "paid", or "premium".
fields() -> list[dict] Yes Returns form field definitions. Each dict: key, label, type, placeholder, required, help.
connect(config: dict) -> bool Yes Stores credentials in memory. Returns True if required fields are present. Does NOT verify credentials.
test() -> bool Yes Makes a real network call to verify stored credentials. Returns True on success.
sync(jobs: list[dict]) -> int No Pushes jobs to the external service. Returns count synced. Default is a no-op returning 0.
config_path(config_dir: Path) -> Path Inherited Returns config_dir / "integrations" / f"{name}.yaml".
is_configured(config_dir: Path) -> bool Inherited Returns True if the config YAML file exists.
save_config(config: dict, config_dir: Path) Inherited Writes config dict to the YAML file. Call after test() returns True.
load_config(config_dir: Path) -> dict Inherited Loads and returns the YAML config, or {} if not configured.

Field type values

type value UI widget rendered
"text" Plain text input
"password" Password input (masked)
"url" URL input
"checkbox" Boolean checkbox