Skip to content

Migrating from 1.x

SDK 2 implements plugin contract 2 and drops contract 1. There is no compatibility layer: SPOT 2.0 calls only contract-2 plugins, and a 1.x plugin must be ported before it runs on SPOT 2.0.

Porting a plugin

  1. Pin spot-sdk-python==2.0.0, and add uvicorn to serve the app.
  2. Describe the plugin with a Manifest: contract=2, its kind, its egress and its settings.
  3. Replace the settings class with a PluginSettings subclass. Secret settings now come from files in /run/secrets, the token included.
  4. Replace the FastAPI app and its routes with handlers given to create_plugin_app. It serves /health, /manifest and the /internal/* routes, and checks the plugin's token.
  5. Analyzers read an EmailView from AnalyzeRequest.email and answer an AnalyzerVerdict: a class, a 0-100 score, a confidence and signals.
  6. Build the image with the labels of Manifest.labels().
  7. Test with view_from_eml, make_view and assert_contract.

An analyzer in SDK 2:

from spot_sdk import (
    AnalyzerVerdict,
    AnalyzeRequest,
    Manifest,
    PluginKind,
    PluginSettings,
    VerdictClass,
    create_plugin_app,
)

MANIFEST = Manifest(id="analyzer-x", version="2.0.0", kind=PluginKind.ANALYZER)


class Settings(PluginSettings):
    threshold: float = 0.5


def analyze(request: AnalyzeRequest, settings: Settings) -> AnalyzerVerdict:
    probability = classify(request.email.visible_text)
    return AnalyzerVerdict(
        verdict=VerdictClass.MALICIOUS
        if probability >= settings.threshold
        else VerdictClass.BENIGN,
        score=round(probability * 100),
        confidence=round(max(probability, 1 - probability) * 100),
    )


app = create_plugin_app(MANIFEST, Settings, analyze=analyze)

From a 1.x result to a verdict

1.x 2.0
is_phishing=False verdict=benign, with a low score
is_phishing=True, threat_level high or critical verdict=malicious
is_phishing=True, another threat level verdict=suspicious
the analysis failed verdict=unknown, with a summary saying why
confidence (0.0-1.0) score (0-100, how likely the email is malicious) and confidence (0-100, how sure the analyzer is)
indicators signals, with dotted names and weights in score points

Removed names and their replacements

Plugin runtime

Removed (1.x) Replacement (2.0)
spot_sdk.analyzer.AnalyzerInterface an analyze(request, settings) -> AnalyzerVerdict handler given to create_plugin_app
spot_sdk.analyzer.AnalyzerCapability Manifest.capabilities (free strings)
spot_sdk.analyzer_base.create_analyzer_app create_plugin_app(manifest, settings_cls, analyze=..., respond=..., sync=...); its lifespan argument stays
spot_sdk.analyzer_base.run_analyzer none: run uvicorn <module>:app
spot_sdk.analyzer_base.BaseAnalyzerSettings, settings_singleton PluginSettings, which create_plugin_app reads once
BaseAnalyzerSettings.app_name, app_version Manifest.id, Manifest.version
BaseAnalyzerSettings.host, port, debug, log_level uvicorn's options
BaseAnalyzerSettings.platform_url PluginSettings.spot_gateway_url (SPOT_GATEWAY_URL)
spot_sdk.analyzer_base.AnalyzerState none
spot_sdk.merge_settings, spot_sdk.analyzer_base.merge_settings, spot_sdk.config_helpers.merge_settings none: PluginSettings reads the constructor, then /run/secrets, then the environment, then a dotenv file
spot_sdk.config_client.ConfigClient none: settings come from the environment and /run/secrets, and saving them restarts the plugin
spot_sdk.settings_schema.register_settings_schema Manifest.settings (SettingField), served by GET /manifest and the spot.plugin.settings label
spot_sdk.config.ConfigReloadResult none
spot_sdk.plugin.PluginKind spot_sdk.manifest.PluginKind, still exported as spot_sdk.PluginKind; it adds RESPONDER
spot_sdk.ollama.OllamaClientProtocol, LLMResponse LLMClient

Plugin routes

Removed (1.x) Replacement (2.0)
GET /health answering {"status": "ok", "service": ..., "version": ...} {"status": "healthy", "version": ...}, served by create_plugin_app; its health check may report starting or unhealthy
GET /capabilities, GET /config, GET /settings/schema, GET /settings/values GET /manifest
POST /admin/reload-config none: saving settings restarts the plugin
POST /internal/analyze taking an Email POST /internal/analyze taking an AnalyzeRequest, token-checked
POST /internal/sync, GET /internal/state written by hand the sync handler; the routes are token-checked

Email

Removed (1.x) Replacement (2.0)
spot_sdk.email.Email EmailView, in AnalyzeRequest.email
spot_sdk.email.EmailHeader the view's fields (subject, from_, to, cc, reply_to, date, message_id, ...), and Header with EmailView.header(name) for raw headers
spot_sdk.email.Attachment Part and EmailView.attachments; the content from SpotClient.part
Email.body_text, body_html, content EmailView.text, html, visible_text
Email.get_urls() EmailView.urls
Email.has_attachments bool(view.attachments)
Email.organization_context none: the Knowledge Store
Email.retrieval_limits AnalyzeRequest.retrieval_limits
Email.ctx, Email.analysis_context, spot_sdk.analysis_context.AnalysisContextReader AnalyzeRequest.previous, the earlier stages' StageOutcomes by name

Results

Removed (1.x) Replacement (2.0)
spot_sdk.analyzer.ThreatLevel, spot_sdk.threat_levels.confidence_to_threat_level VerdictClass and AnalyzerVerdict.score
spot_sdk.results.AnalysisResult AnalyzerVerdict
spot_sdk.results.AnalysisIndicator, IndicatorType Signal, with open dotted names (spot_sdk.signals names the official ones)
spot_sdk.results.AnalysisMetadata AnalyzerVerdict.model_version; core measures the time (AnalyzerOutcome.execution_time_ms)
spot_sdk.orchestrator.AnalyzerResult AnalyzerOutcome
spot_sdk.orchestrator.WorkflowStageResult StageOutcome
OrchestrationResult.is_phishing, threat_level, confidence, summary, recommended_action, reasoning, config_snapshot, indicators, stage_results OrchestrationResult.verdict, score, decided_by, signals, analyzers, policy; email_id is a UUID

Workflows

Removed (1.x) Replacement (2.0)
Workflow.final_stage_name Workflow.verdict_policy.decision_stages; the 1.x rule is method="most_confident", decision_stages=[<final stage>]
WorkflowStage.aggregation_method Workflow.verdict_policy.method (AggregationMethod)
WorkflowStage.continue_on_failure none: a failed analyzer answers unknown; AnalyzerConfig.failure_strategy decides whether it stops the run
WorkflowStage.min_successful_analyzers AnalyzerConfig.required and Workflow.verdict_policy (decision_stages, method) decide what a missing answer means
Workflow.max_parallel_analyzers none: a stage runs all its analyzers at once; the global prefetch and each plugin's own limits bound the load
FailureStrategy.FALLBACK none

Mail retrievers

Removed (1.x) Replacement (2.0)
spot_sdk.retriever.MailRetrieverClient SpotClient
MailRetrieverClient.from_env() SpotClient.from_settings(PluginSettings())
MailRetrieverClient.ingest(email, ...) SpotClient.ingest(raw, envelope=..., ...): the raw message, sent as base64
ingest(..., workflow_id=...) no argument: SPOT's routes choose the workflow; a request body that names one is refused
MailRetrieverClient.get_job(job_id) none in the SDK: GET /internal/jobs/{job_id} on the gateway
SPOT_MAIL_ORCHESTRATOR_URL, SPOT_RETRIEVER_ID, X-Retriever-Id SPOT_GATEWAY_URL; the gateway knows the retriever from its token
IngestionRequest.email IngestionRequest.raw (base64) and envelope
IngestionResult.is_phishing, threat_level, confidence IngestionResult.verdict, score, and the new email_id
Workflow.response_mode none: the mode of a job comes with it (IngestionResult.mode, RespondRequest.mode), from its connection and its route; a retriever applies its actions only in enforce, and tells SPOT what it did with SpotClient.report_action
IngestionStatus.REJECTED an HTTP error, raised as SpotAPIError

Knowledge Store

Removed (1.x) Replacement (2.0)
KnowledgeClient(url, api_key=...) KnowledgeClient(url, token, source=...), or KnowledgeClient.from_settings(settings)
SPOT_INTERNAL_API_KEY, X-Internal-API-Key the plugin's token, sent as Authorization: Bearer on every call, fetch included
KnowledgeClient.for_analysis(email) KnowledgeClient.for_analysis(request)
retrieval_limits={"max_top_k": ...} (a dict, in KnowledgeClient and FakeKnowledgeClient) RetrievalLimits(max_top_k=...)
httpx.HTTPStatusError from the client SpotAPIError
re-sync with bulk_upsert and delete replace_source(docs), which also drops the documents gone from the source

Generated models and tooling

Removed (1.x) Replacement (2.0)
spot_sdk.api_gateway.* (generated: AnalysisRequest, AnalysisResponse, EmailResponse, PluginInfo, Token, UserProfile, WorkflowSummary, ...) none in the SDK: core publishes its OpenAPI documents
the TypeScript SDK (@spot/contracts), the Postman collections none: core's OpenAPI documents
spot_sdk.testing.create_test_email, create_phishing_email, create_test_indicator, create_test_analysis_result, create_analysis_request, create_test_user spot_sdk.testing.make_view, view_from_eml
the pydantic email extra (email-validator) none
Make targets validate, generate*, build, publish, release, *-typescript* build-python, publish-python-pypi

Behaviour changes

  • PluginKind, FailureStrategy and IngestionStatus are StrEnums: str(PluginKind.ANALYZER) is "analyzer".
  • EmailView is serialised with by_alias=True: its from_ field is from in JSON.
  • PluginSettings reads a dotenv file only when the subclass sets env_file; BaseAnalyzerSettings read .env.
  • A secret file in /run/secrets wins over an environment variable of the same name.
  • KnowledgeClient.delete(doc_id) deletes one of the plugin's own documents, at DELETE /sources/{source}/documents/{doc_id}, both parts quoted, so ids holding / or # reach the store whole. It needs the plugin's id (SPOT_PLUGIN_ID, or source=): without one it raises ValueError, as replace_source does. A document's id is unique within its source, not across the store.
  • The clients use httpx2 instead of httpx. A plugin that gives a client a transport= (httpx2.MockTransport in its tests), writes a handler of httpx2.Request and httpx2.Response, or catches an error around a client call imports httpx2, and adds httpx2 to its dependencies where it uses it directly. The clients let httpx2's errors through (httpx2.HTTPError, httpx2.TransportError, httpx2.TimeoutException, httpx2.ConnectError, and httpx2.HTTPStatusError from LLMClient): an except httpx.TransportError around a client call catches nothing, and becomes except httpx2.TransportError. httpx2 logs each request, URL included, under the httpx2 logger (and httpcore2.*), which configure_logging sets to WARNING: a plugin that set the level of the httpx logger to keep URLs out of its logs needs no setting of its own. httpx2 verifies certificates against the operating system's trust store rather than certifi's bundle: an image needs the ca-certificates package, and an internal CA installed in the system store is trusted.
  • The package requires FastAPI 0.133 or later and Starlette 1.7 or later: a plugin moves to them, which assert_contract and assert_provider_contract also need, as their TestClient uses httpx2.
  • configure_logging writes JSON lines by default (LOG_FORMAT=json), or readable lines with LOG_FORMAT=text; simple and detailed raise ValueError at start. A JSON line writes the extra= fields, which 1.x dropped: give none that holds an email's content or a secret. Call configure_logging() at import, at the top of the module uvicorn loads the app from, not in a lifespan: uvicorn writes its first lines before the lifespan runs, and they stay uvicorn's text otherwise.
  • A plugin has exactly one kind: Manifest(kinds=[PluginKind.ANALYZER]) becomes Manifest(kind=PluginKind.ANALYZER), the spot.plugin.kind label holds that one value, and GET /manifest answers kind. A manifest given kinds is refused. A plugin of several kinds becomes one plugin per kind, each with its own id and image; SPOT core 2.0 lists only images whose label names one kind.