Building an integration¶
An integration connects an external service to core Alerts, core Inbox, core paper PDFs, or several of them. It is not a place to implement matching rules, paper resolution, triage, scheduling, or file storage.
Package shape¶
Create src/alma/plugins/<id>/__init__.py. The package exports one
PluginManifest and owns:
- a strict Pydantic config model;
- masked
read_configand validatedwrite_config; - direction-specific status;
- transport callbacks for its declared capabilities;
- an optional connectivity test.
from pydantic import BaseModel, Field
from alma.plugins.configs import StrictPluginConfig
from alma.plugins.manifest import SEND, PluginManifest
class WebhookConfig(StrictPluginConfig):
url: str = Field(
"",
title="Webhook URL",
description="Destination for Alert digests.",
json_schema_extra={
"x-alma-secret": True,
"x-alma-order": 10,
},
)
def read_config(_db) -> WebhookConfig:
# Read the current secret, return only a masked value.
...
def write_config(_db, config: BaseModel) -> None:
parsed = WebhookConfig.model_validate(config)
# Persist secrets through alma.core.secrets; never plaintext settings.
...
def status(_db) -> dict:
return {
"configured": ...,
"can_send": ...,
"can_receive": False,
}
async def send_alert(papers: list[dict], alert_name: str) -> bool:
# Delegate to this service's one transport.
...
async def test_connection() -> dict:
return {"ok": True, "target": "...", "message": "Test delivered"}
WEBHOOK_PLUGIN = PluginManifest(
id="webhook",
display_name="Webhook",
version="1.0.0",
description="Deliver Alert digests to an HTTPS webhook.",
kind="integration",
capabilities=(SEND,),
config_model=WebhookConfig,
read_config=read_config,
write_config=write_config,
status_factory=status,
docs_path="/development/integrations/",
alert_sender=send_alert,
connection_tester=test_connection,
action_ids=("test",),
)
StrictPluginConfig rejects unknown fields. The API serves
WebhookConfig.model_json_schema() and validates writes with the same class.
Supported UI extensions are:
| JSON Schema key | Meaning |
|---|---|
title, description, default |
Field copy and initial value |
minimum, maximum |
Numeric limits |
x-alma-secret |
Password input; value must be masked on reads |
x-alma-order |
Stable field order |
x-alma-advanced |
Put behind advanced disclosure |
x-alma-step |
Numeric input step |
x-alma-links |
Help links under the field, [{"label", "url"}]. A url starting with / is a path on the documentation site (e.g. /plugins/shadow-libraries/#add-them); anything else opens as given. Use it wherever a value has to be looked up elsewhere (addresses, keys) |
The manifest's docs_path becomes the card's Guide link, on the same
documentation site. Every plugin points at its own page under
Plugins; a plugin's setup is documented there and
nowhere else.
Add capabilities¶
For receive, implement InboundChannel from
alma.application.inbox_schema and set inbound_factory. The adapter only
fetches InboundMessage values and acknowledges CaptureResult; the core
capture pipeline owns identifiers, network resolution, corpus landing,
idempotency, and Inbox state.
For send, use alert_sender. The core Alert engine owns rules, matches,
schedules, per-integration deduplication, activation checks, history, and
Activity. The integration renders/delivers the supplied payload.
For pdf_source, set pdf_source_factory to a callable returning the plugin's
PdfSource objects (alma.application.pdf_schema) in run order, and report
can_fetch in status. A source has an id, a label, a tier (open or
shadow) and one method, candidates(ref: PaperRef) -> list[PdfCandidate]:
network only, never the database. It returns the URLs it believes serve the
paper's PDF, best first, or raises SourceSkippedError when it cannot run at
all (no key, email or mirror address), so the attempt reads as skipped, not
as a miss, and SourceBlockedError when its own lookup page is behind a bot
wall, so it reads as blocked. The core owns everything after that: the
download (every redirect hop checked against private and tailnet addresses,
one HTML hop to the PDF a page advertises — citation_pdf_url, a #pdf
viewer element, a pdf.js viewer.html?file=), the byte cap, bot-wall
detection, pypdf verification against the paper's DOI or title, the attempts
ledger (one row per source; when a source returned several candidates, its
detail says what each host answered), storage and serving. The registry runs
every open source of every enabled plugin before any shadow source, and a
fetch runs only when the user asks for one paper's PDF.
A source that knows how its site says "not here" declares it instead of
parsing pages itself: PdfCandidate.absent_titles lists lower-case phrases
that, in a landing page's <title>, turn "no PDF on the page" into "does not
have this paper" (Sci-Hub: "not available through sci-hub"). A redirect to
the site's home page reads the same way without any declaration.
A source that needs its own transport (a browser TLS fingerprint, for example)
wraps it in a SourcePolicy with a session_factory and passes the client from
client_for_policy() as PdfCandidate.transport, so it keeps ALMa's pacing,
retries, diagnostics and network switch. The client's session is per thread,
so a login done in candidates() (a form post(..., data=…)) carries its
cookie into the core's download of the same fetch.
Everything a PDF source touches is untrusted input, and the rules are enforced by the core, not by each plugin:
- Guarded clients only. The download step refuses a client whose policy
lacks
guard_addresses=True. A guarded client passes every request and every redirect hop throughcore.url_safety.vet_url(http(s), ports 80/443, no credentials or disguised hosts, and a host that resolves to public addresses only — never loopback, private, link-local or the tailnet), pins the connection to the checked addresses (GuardedAdapterforrequests,CURLOPT_RESOLVEplus a peer check for curl), follows redirects itself (never re-sending a POST body on 307/308), and caps a non-streamed body (max_body_bytes). Give a curl sessiontrust_env=FalseandPROTOCOLS_STR/REDIR_PROTOCOLS_STR="http,https", and keep one session per trust domain (a member login must not ride along to another site). - Never parse a PDF yourself. The core opens every file in
pdfs.sandbox— a child process with memory, CPU, file-size and time limits — and rewrites a fetched file without its active content before keeping it. - Mirror pages: set
PdfCandidate.follow_anchors=Falseso the landing hop follows only the page's viewer element, not its advertising links. - Remote text: anything a server said that you put in an exception message
or a result passes through
core.url_safety.clean_remote_text(control and bidi characters out, bounded). The attempts ledger applies it again.
A pdf_source plugin's connectivity test should run the real path: build a
PaperRef for a well-known paper, take each address's candidate from the
plugin's own sources and pass it to alma.application.pdfs.download.download_candidate,
discarding the file. Return results rows {source, address, state, severity}
(severity from lib/severity: ok / warning / critical); Settings lists
them under the test button. A home page that loads proves nothing about a
walled paper page.
If both directions use the same external API, they must share one transport and credential owner.
Register and migrate¶
Import the package's manifest in alma.plugins.registry and add it once to
PLUGINS. Add plugins.<id>.enabled to DEFAULT_SETTINGS, advance the settings
schema, and write a forward migration.
A plugin ships switched off, and no migration turns one on. Saved
credentials are not consent: activation is a thing the reader does in
Settings → Plugins, never something the code infers. is_enabled() falls back
to False, so a plugin whose activation key is not in DEFAULT_SETTINGS yet is
inert rather than fatal — but add the key anyway, because
validate_settings_schema checks every key the schema declares, and
tests/test_channel_registry.py fails if a registered plugin has none.
What "off" means¶
Off is enforced by PluginManifest itself, not by the call sites, so the next
caller cannot forget it (POST /plugins/{id}/test did, and a deactivated Slack
posted real messages for it). While a plugin is off:
inbound_channel()returnsNone— quietly. Off is absence, not a fault, so nothing is logged; a factory that raises still leaves a warning.send_alert()andtest_connection()raisePluginDisabledError; the test route answers 409 before it ever reaches the transport.enabled_delivery_plugins()andcan_deliver_alerts()exclude it.
What a disabled plugin still does, deliberately: describe itself, keep its
configuration (deactivating never deletes credentials), and report its
status() — that is what lets Settings, Health and the Alerts channel picker
say "set up, but off". A status_factory is therefore held to a contract:
local, cheap, side-effect-free — read settings and secrets, build no
transport, open no socket, import no network library (tests/
test_plugin_activation_gate.py asserts that listing plugins loads neither
slack_sdk nor smtplib). Anything that probes the outside world belongs in
connection_tester.
Add the integration's Settings icon only if a specific icon exists; the generic
icon already works. Alert delivery choices and schema fields are discovered
from /api/v1/plugins, so no service-specific form or route is added.
Required tests¶
- registry identity and declared capabilities;
- generated schema equals the validating model and forbids extra fields;
- activate → deactivate retains config;
- it is off in a fresh profile, and has an activation key;
- inactive adapters neither send, poll, nor accept a connection test (409);
- secret reads are masked and plaintext never enters settings;
- transport callback uses the service's one client;
- inbound idempotency and acknowledgement, when
receiveis declared; - a replayed PDF fetch per source (the
tests/_cassette.pylayer, with a real PDF fromtests/_pdf_fixtures.py), whenpdf_sourceis declared; - connectivity action dispatches through the generic manifest route.
Run the backend suite, frontend typecheck/tests/build, and strict MkDocs build.