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 |