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¶
- 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.parseinto anEmailView. - Analysis. The orchestrator runs the workflow's stages. For each analyzer of a stage it sends
POST /internal/analyzewith anAnalyzeRequest. The analyzer answers anAnalyzerVerdict. An HTTP error or a timeout counts asunknown. - Decision. The workflow's
VerdictPolicyturns the analyzers' verdicts into the email's verdict, recorded as anOrchestrationResult. - Response. Core calls the responders that
Workflow.on_verdictlists for that verdict, with aRespondRequest. - Context. Apart from any email, core calls each context provider's
POST /internal/syncon the provider's schedule, then readsGET /internal/statefor 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:
- 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.*. - The analyzers that count are those of
decision_stages(every stage when unset), without theunknownones. Arequiredanalyzer that isunknownmakes the verdictunknown, and so does having none left. - The method decides the rest (
decided_by="method:<name>"):most_confident,weighted_mean(the default),mean,maxormajority. A score at or abovemalicious_threshold(80) ismalicious, at or abovesuspicious_threshold(50)suspicious; below, the email isspamwhen at least half of the counting weight saysspam, andbenignotherwise.
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.