Skip to content

Overview

This page describes plugin contract 2: how SPOT's core and a plugin talk, and the models they exchange. All the models are in the SDK, so core and plugins share one definition.

How core uses plugins

  1. Intake. A mail retriever (or a user) submits a raw RFC 5322 message. Core stores it encrypted and parses it once with spot_sdk.mime.parse into an EmailView.
  2. Analysis. The orchestrator runs the workflow's stages. For each analyzer of a stage it sends POST /internal/analyze with an AnalyzeRequest. The analyzer answers an AnalyzerVerdict. An HTTP error or a timeout counts as unknown.
  3. Decision. The workflow's VerdictPolicy turns the analyzers' verdicts into the email's verdict, recorded as an OrchestrationResult.
  4. Response. Core calls the responders that Workflow.on_verdict lists for that verdict, with a RespondRequest.
  5. Context. Apart from any email, core calls each context provider's POST /internal/sync on the provider's schedule, then reads GET /internal/state for the dashboard.

Routes of a plugin

create_plugin_app serves these routes; only those of the handlers given exist.

Route Token Request Answer
GET /health no 200 {"status": "healthy", "version": "<manifest version>"}; starting or unhealthy, with a reason, when the plugin's health check says so
GET /manifest no Manifest
POST /internal/analyze yes AnalyzeRequest 200 AnalyzerVerdict; 500 when the handler fails
POST /internal/respond yes RespondRequest 204; 500 when the handler fails
POST /internal/sync yes 202 SyncState; the sync runs in the background
GET /internal/state yes SyncState

Every /internal/* route answers 401 unless the call carries Authorization: Bearer <the plugin's own token>, and an invalid body is answered 422 with the fields refused, never the values sent.

Tokens

Each plugin holds its own opaque token (spot_pt_...). Core writes it to the file /run/secrets/spot_plugin_token when it creates the container. The token works both ways:

  • core sends it on its calls to the plugin, which checks it;
  • the plugin sends it on its calls to SPOT, whose services grant the scopes of the plugin's kind.
SPOT route Scope SDK call
POST /internal/ingest (gateway) ingest SpotClient.ingest
GET /internal/emails/{email_id}/raw (gateway) content:read SpotClient.raw
GET /internal/emails/{email_id}/parts/{part_id} (gateway) content:read SpotClient.part
GET /internal/results/{job_id} (gateway) results SpotClient.result
POST /query (Knowledge Store) knowledge:read KnowledgeClient.fetch
PUT /sources/{source}/documents and the other writes (Knowledge Store) knowledge:write, own documents only KnowledgeClient.replace_source, upsert, bulk_upsert, delete

The models

The email view

EmailView (spot_sdk.email) is the parsed message, the same for every analyzer: the headers in order (without X-SPOT-*), the address fields, the trusted authentication results, the MIME tree as Parts, the text, the HTML and the visible text, the URLs with where they were found, and the anomalies the parser met. It is always serialised with by_alias=True, so its from_ field travels as from. Each list and long string has a budget, so its JSON takes at most MAX_VIEW_BYTES (about 6.84 MiB) whatever the message. The reference describes each field.

The analyzer call

class AnalyzeRequest(BaseModel):
    job_id: str
    email: EmailView
    previous: dict[str, StageOutcome] = {}  # earlier stages, by name
    retrieval_limits: RetrievalLimits | None = None
    deadline_ms: int  # what is left of the analyzer's timeout

The answer:

class AnalyzerVerdict(BaseModel):
    verdict: VerdictClass  # malicious, suspicious, spam, benign, unknown
    score: int  # 0-100: how likely the email is malicious
    confidence: int  # 0-100: how sure the analyzer is of its verdict
    signals: list[Signal] = []
    summary: str = ""
    details: dict[str, Any] = {}  # analyzer-specific, stored, shown raw
    model_version: str | None = None


class Signal(BaseModel):
    name: str  # dotted and lower-case: "auth.dmarc_fail"
    weight: int  # -100 to 100: the contribution, in score points
    evidence: str = ""  # short human-readable proof
    location: str | None = None  # "header:From", "url:3", "part:1.2"

A benign verdict has a low score. An analyzer that cannot decide answers verdict="unknown" with a summary saying why.

The workflow's decision

Workflow.verdict_policy (spot_sdk.workflow.VerdictPolicy) decides, in this order:

  1. The first decisive signal emitted by an analyzer that ran sets the class (decided_by="signal:<name>"). A decisive signal names a signal exactly, or a prefix ending in .*.
  2. The analyzers that count are those of decision_stages (every stage when unset), without the unknown ones. A required analyzer that is unknown makes the verdict unknown, and so does having none left.
  3. The method decides the rest (decided_by="method:<name>"): most_confident, weighted_mean (the default), mean, max or majority. A score at or above malicious_threshold (80) is malicious, at or above suspicious_threshold (50) suspicious; below, the email is spam when at least half of the counting weight says spam, and benign otherwise.

OrchestrationResult (spot_sdk.orchestrator) records the verdict, the score, decided_by, every signal attributed to its analyzer, each analyzer's outcome and the policy that decided.

The responder call

class RespondRequest(BaseModel):
    job_id: str
    email_id: UUID
    workflow_id: str
    verdict: VerdictClass
    score: int
    decided_by: str
    signals: list[AttributedSignal]
    mode: Literal["observe", "enforce"]

mode is the job's mode, observe by default, from its connection and its route: a responder acts only in enforce, and only records in observe.

Ingestion

A mail retriever sends IngestionRequest (spot_sdk.retriever): the raw message as base64, the SMTP envelope, an optional workflow, a priority, and whether to wait for the verdict. The answer is an IngestionResult: accepted at once, analyzed with the verdict and score, or timeout. A message refused before it is stored (too large, bad encoding) is an HTTP error instead.

The manifest

Manifest (spot_sdk.manifest) declares what a plugin is. The same object is served by GET /manifest and rendered as the image's OCI labels by Manifest.labels(), which the catalog reads before installing.

Label Field Values
spot.plugin.id, spot.plugin.version id, version
spot.plugin.kind kind analyzer, context_provider, mail_retriever or responder: one
spot.plugin.contract contract 2
spot.plugin.egress egress none, indicators or content (the default, which needs the operator's consent)
spot.plugin.settings settings JSON: name to SettingField
spot.plugin.capabilities capabilities free strings, comma-separated
spot.plugin.port, spot.plugin.healthcheck port, healthcheck 8000, /health
spot.plugin.compatibility compatibility a SPOT version range, e.g. >=2.0.0,<3.0.0
spot.plugin.requires_external_ingress requires_external_ingress true or false
spot.plugin.resources resources JSON: {"memory_mb": 1024, "cpus": 1.0}

The catalog refuses an image with an unknown kind or a contract other than 2.

The package

Module Holds
spot_sdk.email EmailView, Header, Address, AuthResult, Part, Url, Anomaly, Envelope
spot_sdk.mime parse, part_bytes, the anomaly codes and the parser's bounds
spot_sdk.verdict AnalyzerVerdict, Signal, VerdictClass, Score
spot_sdk.signals the signal names of the official analyzers and of parser anomalies
spot_sdk.workflow Workflow, WorkflowStage, AnalyzerConfig, VerdictPolicy, DecisiveSignal, RetrievalLimits
spot_sdk.orchestrator AnalyzeRequest, RespondRequest, OrchestrationResult, AnalyzerOutcome, StageOutcome
spot_sdk.retriever IngestionRequest, IngestionResult, IngestionStatus, ActionReport
spot_sdk.manifest Manifest, PluginKind, Egress, SettingField, SettingType, Resources, the label names
spot_sdk.app create_plugin_app, SyncState
spot_sdk.artifacts, spot_sdk.urls fetch, a model's files from a local path or an archive; redact, a URL without its secrets
spot_sdk.settings PluginSettings
spot_sdk.clients SpotClient, SpotAPIError
spot_sdk.knowledge KnowledgeClient, KnowledgeDocument, chunk_text; spot_sdk.knowledge_tags.KnowledgeTag
spot_sdk.llm LLMClient, LLMToolCalls, LLMToolRound, LLMOutputError
spot_sdk.testing view_from_eml, make_view, FakeSpot, FakeKnowledgeClient, FakeLLM, assert_contract, assert_bounds_agree, assert_provider_contract; with the testing extra, the plugin test engine: corpus, container, engine, metrics, report
spot_sdk.hosting how core hosts a plugin: its container options, limits, secret files and environment, and how it reads an answer
spot_sdk.config, spot_sdk.errors, spot_sdk.logging core's configuration API models, ErrorResponse, logging setup

spot_sdk re-exports the classes and functions; the parser, the signal names and the test helpers are imported from their modules.