Skip to content

Reference

The models, the parser, the clients and the test helpers of spot-sdk-python 2. The overview shows how they fit together, and Writing plugins how to use them.

The email view

spot_sdk.email.EmailView is the message as every analyzer sees it. Core builds it once, at intake, with spot_sdk.mime.parse. Anyone can send a message, so the parser never fails on its content: it keeps what does not decode as written, and records what mail clients may read differently as an anomaly.

Field What it holds
id, received_at, source the stored email's id, when it arrived, and the retriever's id (or "api")
size_bytes, sha256 of the raw message
envelope the SMTP envelope, when an MTA gave it: mail_from, rcpt_to, client_address, client_name, helo
headers the headers, in order, each one that fits the room left in their budget: unfolded, RFC 2047 words decoded (undecodable ones kept as written), raw 8-bit bytes read as UTF-8. X-SPOT-* headers are removed, in any case. header(name) returns every value of a header, case-insensitively
subject, from_ (from in JSON), reply_to, to, cc, date, message_id, in_reply_to, references parsed from the first header of each name in the whole message, even past the headers kept. An address that does not parse is kept whole, with an empty domain; a domain is read as browsers read a host (see url_host, without the percent-decoding); an unusable date is None
authentication the results of Authentication-Results headers whose authserv-id is one of SPOT's own MTAs, and only above the first header from another one, so headers added on the way do not count. This holds when those MTAs remove incoming headers that carry their authserv-id (RFC 8601, section 5)
parts the MIME tree, depth-first: "1", "1.1", "1.2.1". Each Part has its content type, charset, disposition, filename, content id, decoded size and SHA-256. Containers (multipart/* and message/*, an attached message included) have size 0 and no hash: an attached message's leaves (1.2.1, ...) are fetched with SpotClient.part, the whole message with SpotClient.raw. attachments lists the parts marked as attachments, or named and not empty, so it may list an attached message
text, html the first text/plain and text/html body parts, decoded with their charset (latin-1 when it is unknown), cut to 1 MiB of JSON
visible_text the text when it is not blank, else what a browser shows of the HTML: scripts, styles and text hidden by inline styles are left out. Either way, without the characters that render as nothing (spot_sdk.mime.visible): a text of them alone is blank
urls every URL, with found_in: header (List-Unsubscribe), text, html_href (an attribute, with the link's anchor_text) or html_text (written in the HTML's visible text). host is what a browser would reach (see url_host)
truncated some content was cut at a bound
anomalies what the parser read past, as Anomaly(code, location, detail)

Anomalies

Each anomaly has a code, a location ("part:1.2", "header:Subject@1", "param:Content-Type.boundary@1.2", "url:3" (the index in urls of the link concerned), "envelope", "source") and, for the ambiguities, a detail holding the values concerned as JSON strings. The location and the detail take at most 200 bytes of JSON each. The codes are constants of spot_sdk.mime, listed in ANOMALY_CODES, and each has a signal name in spot_sdk.signals.ANOMALY_SIGNALS (mime.<code>).

Code Meaning
param_malformed an RFC 2231 parameter that does not decode was dropped
param_duplicate a parameter, or a section of one, is given twice: the first counts
param_conflict two values of a parameter differ (plain and RFC 2231 forms, two boundaries, a filename and a name)
boundary_unusable a boundary is empty or does not decode: the next candidate counts
boundary_too_long a boundary is longer than MAX_BOUNDARY_LENGTH
boundary_missing a multipart part was not split: no usable boundary appears in it
boundary_ambiguous a multipart body holds the delimiters of two candidate boundaries
header_duplicate a part repeats Content-Type, Content-Disposition, Content-Transfer-Encoding or MIME-Version
transfer_encoding_junk a base64 body goes on after its padding
html_hidden_text text a reader may see was left out of visible_text (hidden by a style, in <head>, <template>, ...); the detail is how many characters, counted as visible_text counts, never the text; absent when some hidden text could not be counted (a <template> not read)
html_style_unresolved an inline style hides text only at rendering time (var(), calc()): the text was kept
html_limit the HTML is over the parser's budget: its text and URLs were read by pattern, and hidden text is not detected
invisible_characters characters that render as nothing (a zero-width space, a soft hyphen, a tag...) were removed from visible_text, or from a text part they left blank; the detail is how many, the location the part they were removed from. Also noted where the view keeps them as written: the subject, the display names of an address header, a link's anchor text and a file name, located at that field: header:Subject@1 (the header holding the name, for a display name), url:3 (one note per link), param:Content-Disposition.filename@1.2 (param:Content-Type.name@1.2 for a name given there only)
bidi_control a string holds explicit bidi embedding, override or isolate controls (U+202A to U+202E, U+2066 to U+2069), which reorder what a reader sees (invoice<RLO>fdp.exe shows as invoiceexe.pdf); noted where invisible_characters is, which a string holding them gets too; the detail is how many
depth_limit, header_limit, part_limit, url_limit a bound was hit: nesting, headers, parts or URLs beyond it were not read, or were left out of the view's list (url_limit also when a URL was cut)
host_limit the message's hosts hold more than MAX_A_LABEL_CHARACTERS characters of distinct A-labels, read in the view's order: the from, reply_to, to and cc domains, then the URLs, the first entry a list leaves out included. A host whose A-labels not yet decoded do not fit in what is left keeps all its A-labels encoded (xn--), as UTS 46 maps them. The From domain is always decoded, and url_host(url.url) reads a host with all its A-labels decoded
text_truncated a string was cut at its budget: the text, the HTML or the visible text, a header value, a string of a part, a field read from a header, the envelope or the source
anomaly_limit more anomalies were met than are recorded

Bounds

The parser bounds its work on hostile input, and the size of the view. Each bound hit is an anomaly; those that leave content out set truncated.

Bound Value
headers per part (MAX_HEADERS) 10 000
nesting (MAX_DEPTH) 32
parts (MAX_PARTS) 10 000
bytes read of the text and HTML parts, and of each header value (max_text_bytes) 1 MiB
URLs (MAX_URLS) 10 000
characters of distinct A-labels the hosts decode (MAX_A_LABEL_CHARACTERS) 32 768
anomalies (MAX_ANOMALIES, MAX_ANOMALIES_PER_CODE) 1 000, 100 of each code, always the first of each

The view's size

The view's JSON, as model_dump_json(by_alias=True) writes it in UTF-8, takes at most MAX_VIEW_BYTES, 7 172 781 bytes (about 6.84 MiB), whatever the message, with max_text_bytes at most its default. Core refuses a view over 8 MiB, so no message is refused for its view's size.

Each list and each long string of the view has a budget, in bytes of its JSON, escapes included: a control character takes six bytes (\u0001), a quote or a backslash two. A list keeps its first entries, but headers keeps each header that fits the room left, so one long header does not hide the ones after it. A string keeps its start, and no value is altered. Each cut sets truncated and is an anomaly.

Field Budget, in bytes of JSON Anomaly
text, html, visible_text (MAX_TEXT_BYTES, max_text_bytes' default) 1 MiB each text_truncated
headers (MAX_HEADERS_BYTES) 1 MiB header_limit
parts (MAX_PARTS_BYTES), each string of a part 1 MiB, 1 KiB part_limit, text_truncated
urls (MAX_URLS_BYTES), each url, each host and anchor_text 1 MiB, 8 KiB, 1 KiB url_limit
the fields read from the headers, the envelope and the source (MAX_FIELDS_BYTES) 410 KiB: subject 16 KiB; reply_to, to, cc, references, authentication and the envelope's rcpt_to 64 KiB each; each other string 1 KiB text_truncated
anomalies (MAX_ANOMALIES_BYTES) about 450 KiB: each location and detail 200 bytes
the keys and the fields of fixed size 1 KiB

The fields read from the headers come from the first header of each name in the whole header block, even past MAX_HEADERS or when the headers list leaves them out, so padding the headers cannot push them out. A text full of control characters, quotes or backslashes holds fewer than a million characters in its 1 MiB. The URLs and the visible text are read from the whole decoded body (its first max_text_bytes bytes), not from the stored cut, so a link after a long run of control characters is still found.

Parsing in a plugin

from spot_sdk.mime import parse, part_bytes

view = parse(
    raw,
    email_id=email_id,
    source="api",
    received_at=received_at,
    trusted_authserv_ids={"mx.example.com"},
)
data, content_type = part_bytes(raw, "1.2")

part_bytes numbers parts as parse does, and raises PartNotFoundError (a KeyError) for an unknown id or a container (multipart/* or message/*): it serves leaves only. Plugins usually get the view from core and the parts from the gateway; view_from_eml wraps parse for tests.

visible(text) removes the characters that render as nothing: Unicode 16.0's Default_Ignorable_Code_Point, 4 174 code points, such as the zero-width space and joiners, the soft hyphen, bidi controls, fillers, variation selectors and tag characters. They split words a reader sees whole. parse builds visible_text with it; the subject, the headers, display names, anchor text, text and html keep them, as written, so call it on them before matching words.

from spot_sdk.mime import visible

visible("p\u200b\u0430\u200bypal")  # "p\u0430ypal"
visible("Pay\u00adPal")  # "PayPal"

url_host(text) is the function parse uses to fill Url.host, for a plugin that meets a URL outside the view (a redirect target, a QR code's text, a settings value): the host a browser would reach, "" when there is none. A backslash reads as a slash, and www. is accepted without a scheme. The host is the one Python's urlsplit finds, after the userinfo and before the port, and there is none where urlsplit refuses the URL: a bracket without its pair, brackets misplaced or around no IPv6 or IPvFuture address, or a character NFKC turns into a delimiter (U+2100 is a/c). The SDK finds it in linear time, where urlsplit takes time quadratic in a run of combining marks. The host is read as browsers read it, as the WHATWG URL Standard has them do:

  • percent-decoded first, as UTF-8 (a NUL becomes U+FFFD, as everywhere in the view);
  • mapped as UTS 46 maps it, by the idna package: case, width, the ideographic full stop, the characters it ignores (the soft hyphen, a variation selector);
  • each A-label (xn--) decoded from Punycode when it decodes to a label UTS 46 leaves as it is: valid code points, in NFC. Symbols and emoji, which IDNA 2008 refuses, decode as they do in browsers (xn--g6h is a heart);
  • without its trailing dots.

The SDK reads the host; it does not decide whether a browser would accept it. A host browsers refuse, such as one holding a space or a slash once percent-decoded, a label against the Bidi rule or a joiner out of context, is read all the same and kept: it is evidence.

An A-label stays encoded when it is not Punycode (xn--zz), decodes to ASCII alone (xn--a-), or decodes to a label UTS 46 would change: a fullwidth or a capital letter, a soft hyphen, a full stop, a code point it does not map. Browsers refuse such an A-label, and mapping it would read it as another host: xn--mi7ca7anb7c.com, a fullwidth paypal, would read as paypal.com. A label UTS 46 does not map stays as written, lower-cased.

A host that can be no DNS name is only lower-cased: neither mapped nor decoded. It is counted on what a resolver could see, without its invisible characters (those visible removes) and its trailing dots: a host that keeps more than 1 012 characters, a label more than 252, or more than 63 combining marks in a row as UTS 46 maps them, can be no DNS name. A DNS name holds 253 characters at most, and a label 63 (RFC 1035), and the count can shrink no further: UTS 46 removes only invisible characters, and NFC composes at most 4 code points into one. A run of 64 marks keeps at least 61 code points that are not ASCII, and an A-label spells each with a character at least, after its xn--. So padding never hides a host: pay, 250 soft hyphens, then pal.example reads as paypal.example, as browsers read it. An A-label longer than 63 characters stays encoded, as it is a DNS label itself. These bounds keep the time each host takes to read in check, since CPython reorders combining marks for NFC in time quadratic in their run, and Python decodes Punycode in time quadratic in a label. A host is mapped once while it is among the last 256 the process mapped. url_host never raises. Address.domain is read the same way, without the percent-decoding.

from spot_sdk.mime import url_host

url_host("HTTPS://Mail.XN--PYPAL-4VE.example/c")  # "mail.p\u0430ypal.example"
url_host("https://p%D0%B0ypal.example./")  # "p\u0430ypal.example"
url_host("http://xn--g6h.example/")  # "\u2665.example"
url_host("www.Example.org/e")  # "www.example.org"
url_host("mailto:alice@example.com")  # ""

parse decodes each A-label once a message, and at most 32 768 characters of distinct A-labels (MAX_A_LABEL_CHARACTERS), 520 of the longest: Python decodes Punycode a character at a time. The hosts are read in the view's order: the domains of from, reply_to, to and cc, then the URLs' hosts. A list reads the first entry it leaves out before leaving it out, so that entry's host counts too. A host whose A-labels not yet decoded do not fit in what is left keeps all its A-labels encoded, as UTS 46 maps them, and the view notes host_limit; a later host whose A-labels fit is still decoded. One host holds far fewer A-label characters than the budget, so the From domain, read first, is always decoded.

A sender can spend the budget on purpose, to leave a lookalike link encoded (xn--pypal-4ve.com): host_limit is the evidence. url_host(url.url) reads one host with all its A-labels decoded, for a host to compare with a brand.

Verdicts and signals

AnalyzerVerdict (spot_sdk.verdict):

Field Meaning
verdict VerdictClass: malicious, suspicious, spam, benign or unknown
score 0-100: how likely the email is malicious; a benign verdict has a low score
confidence 0-100: how sure the analyzer is of its own verdict
signals the evidence, as Signals
summary one human-readable sentence; with unknown, why
details analyzer-specific data, stored and shown raw
model_version the model behind the verdict, when there is one

A Signal has a name (dotted and lower-case: ^[a-z0-9_]+(\.[a-z0-9_]+)+$), a weight in score points (-100 to 100), an evidence and a location. Workflows can make a signal decisive, by exact name or by prefix (misp.*).

AnalyzerVerdict.unknown(reason, *, details=None, model_version=None) is the answer of an analyzer that cannot decide: unknown, a score and a confidence of 0, and the summary No verdict: <reason>.

AnalyzerVerdict.from_probability(probability, *, signal, location=None, details=None, model_version=None) is the verdict of a model that gives the probability that the email is phishing, from 0 to 1: the score is round(100 * probability), the class follows it as the default malicious_threshold and suspicious_threshold of VerdictPolicy have it (else benign), the confidence is max(p, 1 - p) in percent, and the one signal, signal, weighs the score with the probability as its evidence (0.9312) and location. A probability out of 0-1, or NaN, gives unknown, with a summary saying so. details and model_version go to the verdict either way.

spot_sdk.signals names the signals of the official analyzers:

Constant Name
AUTH_SPF_FAIL auth.spf_fail
AUTH_DMARC_FAIL auth.dmarc_fail
HEADER_REPLY_TO_MISMATCH header.reply_to_mismatch
URL_TEXT_HREF_MISMATCH url.text_href_mismatch
TEXT_HOMOGLYPH_MIXED_SCRIPT text.homoglyph_mixed_script
NLP_PHISHING_PROBABILITY nlp.phishing_probability
ML_PHISHING_PROBABILITY ml.phishing_probability
MISP_IOC_MATCH misp.ioc_match
LLM_PREFIX llm.: analyzer-llm emits llm.<indicator>
MIME_PREFIX, ANOMALY_SIGNALS mime.: ANOMALY_SIGNALS[code] for each anomaly code

The plugin runtime

create_plugin_app

def create_plugin_app(
    manifest: Manifest,
    settings_cls: type[SettingsT],
    *,
    analyze: AnalyzeHandler[SettingsT] | None = None,
    respond: RespondHandler[SettingsT] | None = None,
    sync: SyncHandler[SettingsT] | None = None,
    lifespan: Lifespan[FastAPI] | None = None,
    health: HealthCheck | None = None,
    load: Loader[SettingsT, LoadedT] | None = None,
) -> FastAPI: ...
Handler Signature Route
analyze (AnalyzeRequest, settings) -> AnalyzerVerdict; with load, (AnalyzeRequest, settings, loaded) POST /internal/analyze
respond (RespondRequest, settings) -> None POST /internal/respond, answered 204
sync (settings) -> int, the documents written POST /internal/sync (202) and GET /internal/state

The manifest's kind and the handlers must match: the declared kind without its handler, or a handler of another kind, raises ValueError. A mail retriever has no handler.

Each handler may be async def, or an object whose __call__ is async def (run on the event loop), or def (run in a worker thread).

SyncState (spot_sdk.app): in_progress, last_sync_at, last_finished_at, last_sync_count, last_error.

load(settings), a plain function, loads what the analyzer needs once the app starts, after lifespan's startup, in a daemon thread: the app answers meanwhile. It needs analyze, which then gets what it returned as its third argument; create_plugin_app raises ValueError otherwise, and for an async load.

The load /health /internal/analyze
running starting, reason loading unknown: No verdict: loading.
raised unhealthy, reason the load failed (OSError), for good unknown: No verdict: the load failed (OSError).
returned health's answer; healthy without one the handler's verdict

A failed load is logged as a handler's failure: by the exception's type and place, never its message.

The app's OpenAPI document, app.openapi(), describes the contract as the app runs it; the app does not serve it (/openapi.json is 404). Core generates the plugin contract's document from it. The request bodies are components of the app's own document, so read it from the plugin app itself: another FastAPI app that includes its routes leaves their $ref dangling.

Route Request body Answers
POST /internal/analyze AnalyzeRequest 200 AnalyzerVerdict, 401, 422, 500
POST /internal/respond RespondRequest 204 with no body, 401, 422, 500
POST /internal/sync none 202 SyncState, 401
GET /internal/state none 200 SyncState, 401

Every /internal/* route declares the HTTP Bearer scheme (HTTPBearer), the plugin's own token; /health and /manifest declare none. The 401 and 500 answers are an ErrorAnswer ({"detail": "unauthorized"}, "analysis failed", "response failed"), and the 422 an InvalidRequestAnswer: the fields refused (type, loc, msg), never the values sent.

health, a def or async def without arguments, returns the plugin's state as a PluginHealth (spot_sdk.app, frozen, and validated again wherever it is read): status, one of healthy, starting and unhealthy, and an optional reason of at most 200 characters, without control characters (C0 and DEL), which holds no content: /health is open, and core's logs and the dashboard show the reason. GET /health calls the check on each request and always answers 200 with {"status": ..., "version": ...}, and "reason" when there is one; without health, status is healthy.

Prefer an async def check that reads state the plugin keeps: a def check runs in the handlers' thread pool, so under load it waits behind them. Report the expected conditions (starting, "the model did not load") as a PluginHealth, never by raising: a check that raises, or returns no valid PluginHealth, answers unhealthy with the reason the health check failed, and logs an ERROR line on every call, by the exception's type and place only.

Logs and correlation

Call configure_logging() once, at the top of the module uvicorn imports the app from, not in a lifespan: uvicorn writes Started server process and Waiting for application startup. before the lifespan runs.

from spot_sdk import configure_logging, create_plugin_app

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

Each record is then written to stdout, and to LOG_FILE when it is set: one JSON object on one line (LOG_FORMAT=json, the default), or, for development, a readable line, the traceback after it (LOG_FORMAT=text: time, level, logger, [correlation ID], message). Any other LOG_FORMAT raises ValueError. LOG_LEVEL sets the level, INFO by default.

Field of a JSON line Holds
timestamp the time, in UTC to the millisecond: 2026-10-13T09:06:55.186Z
level, logger the record's level and logger name
message the message, its arguments filled in
correlation_id the current correlation ID; left out outside any work
exception, stack the traceback of logger.exception, the stack of stack_info=True
any other the record's extra= fields, as JSON; all as text when JSON cannot hold one of them (a NaN, a tuple as a key)

uvicorn's lines take the same form: its access lines without the query string, and an exception that no handler caught, a lifespan's failure included, by its type and frames, never its message. Python's warnings are log lines (py.warnings), and httpx, httpx2, httpcore and httpcore2 log from WARNING: httpx and httpx2 write each request's URL at INFO, and httpcore and httpcore2 the raw headers at DEBUG. Under --reload or several --workers, the parent process, which never imports the app, writes uvicorn's own text lines.

A line never holds an email's content (a subject, a body, a header value, an address), a secret, a token or a query string. Log an exception whose message can quote one of them by its type and place, as the SDK logs a handler's failure: logger.exception writes the message. And give extra= no field that can hold one: the line writes every extra field.

Each request to the plugin app runs under the X-Correlation-ID core sent, or a new ID when it has none, or one that is not 1 to 128 letters, digits and ., _, :, -; the answer carries it, a 500 included (CorrelationMiddleware, which wraps the whole app: a middleware the plugin adds runs under the ID too). While a handler runs, on the event loop or in a worker thread, and during the sync it starts, spot_sdk.correlation.current() returns the ID, every log line holds it, and the SDK's clients send it on to SPOT.

Work the plugin starts itself has none: run it under correlated(), a new ID, or the one given when it is valid, so that its lines and its calls to SPOT share it. A mail retriever, for each message:

from spot_sdk.correlation import correlated

for raw in messages:
    with correlated():
        await client.ingest(raw)

PluginSettings

spot_sdk.settings.PluginSettings is a pydantic-settings class. Subclass it with the plugin's own fields.

Field Source Default
spot_plugin_token (SecretStr) /run/secrets/spot_plugin_token empty: every /internal call is refused
spot_plugin_id SPOT_PLUGIN_ID empty
spot_gateway_url SPOT_GATEWAY_URL http://api-gateway:8000
spot_knowledge_url SPOT_KNOWLEDGE_URL http://knowledge:8000/api/v1/knowledge

Precedence: constructor, /run/secrets files, environment, dotenv (when the subclass sets env_file). Names are case-insensitive. Tests pass _secrets_dir=tmp_path to read another directory.

Behind an egress proxy that needs a user and a password, core forwards the proxy variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and their lower-case forms) without them, and mounts them as /run/secrets/spot_proxy_credentials (PROXY_SECRET), user:password percent-encoded as a URL carries them: they are never in the container's configuration (docker inspect does not show them). create_plugin_app first calls apply_proxy_credentials(secrets_dir=None, environ=None), which puts them back into each proxy variable of the process whose URL names no user, so the plugin's HTTP clients reach the proxy authenticated. After the call the process's proxy variables hold them, and a child process it starts inherits them. An httpx2 client reads them when it is built: build one after create_plugin_app (one built above the app = create_plugin_app(...) line does not get them), or, in a plugin that builds no app, after calling apply_proxy_credentials() itself. The SDK's own clients build theirs per call, so they get them wherever they are made. It returns whether the file exists, and never logs them; a file that does not hold user:password as a URL carries them raises ValueError naming the file, never its content.

The manifest declares each setting as a SettingField (spot_sdk.manifest): its type (string, integer, number, boolean, url, secret), required, default, description, secret, and enum, the values allowed. SettingField refuses an enum that no value can satisfy, so the plugin fails when it builds its Manifest, not when an admin configures it: an empty one, or one whose values do not fit type (integer: int, never bool; number: int or float, never bool; boolean: bool; string, url and secret: str).

multiline (default false) marks a value of several lines: the dashboard renders a textarea and keeps the line breaks. It is for string and secret settings only; on any other type the manifest is refused.

"misp_ca_cert": SettingField(
    type=SettingType.STRING,
    multiline=True,
    description="PEM CA certificate (-----BEGIN CERTIFICATE-----...)",
)

minimum and maximum bound an integer setting (by ints) or a number setting, and pattern a string setting, so that core refuses a value out of them when it is saved, rather than the plugin at its next start. The pattern is matched against the whole value, as Python's re.fullmatch reads it ([a-z]+ refuses ab-cd): core checks it, and the dashboard shows it as a hint only, since browsers read regular expressions otherwise. A label without them reads as unbounded. The manifest is refused when a bound is on another type, the minimum exceeds the maximum, the pattern does not compile, or the default or an enum value is out of them; and when a number's default or enum value is not finite (NaN escapes every bound, and neither it nor infinity is JSON).

"MAX_LENGTH": SettingField(
    type=SettingType.INTEGER,
    default=512,
    minimum=1,
    maximum=512,
    description="How many tokens of the text the model reads",
),
"MODEL_NAME": SettingField(
    type=SettingType.STRING,
    default="decision_tree",
    pattern="^[A-Za-z0-9][A-Za-z0-9_.-]*$",
),

spot_sdk.testing.assert_bounds_agree(MANIFEST, Settings) checks, in the plugin's tests, that the manifest states the bounds of the settings model. Pydantic searches a field's pattern anywhere in the value: anchor it, ^...$, so that it means what the manifest's does, and write the same text in both; the anchors change nothing to a whole match.

Model input

spot_sdk.model_input holds the text a phishing model reads and the manifest its files carry, so that training (spot-models) and the model analyzers read the same text.

model_text(subject, body, version="spot-text-1") -> str raises ValueError for a version outside TEXT_VERSIONS. The steps of spot-text-1, in order:

  1. HTML entities are decoded and the characters that render as nothing are removed (spot_sdk.mime.visible).
  2. The angle markers of MeAJOR v2, <|URL|> and <|EMAIL|>, become the words URL and EMAIL; <|SYMBOL|>, <|SIMBOL|>, <|EMOJI|>, <|EMAIL_SEPARATOR|>, <|FILE|>, <|PHONE|> and <|PGP|> are dropped. This comes before the tags, as they look like one.
  3. <script> and <style> elements are dropped with their contents (an unclosed one to the end), as the email view's visible_text leaves them out. Then tags are dropped: <, an optional /, a name of letters, digits, _ and - with an optional prefix: (Outlook's <o:p>), then attributes up to >; or <! or <? up to > (comments, doctypes, XML declarations). A > inside a quoted attribute or a comment ends the tag (<a title='x>y'> leaves y'>). Anything else stays: 1 < 2 and 3 > 1, and the angle brackets of Bob <bob@corp.example> and <https://evil.example/x>, whose address and URL are replaced in the next step. Escaped markup (&lt;b&gt;x&lt;/b&gt;) was decoded in step 1, so it is dropped as well.
  4. Each URL (http://, https:// or www., up to the next space or angle bracket: trailing punctuation and a closing parenthesis go with it) and each address becomes the word URL or EMAIL. An address has a local part of up to 64 characters, labels of up to 63 and up to 8 dots after the @: a longer local part, or one glued to a longer run of letters, digits and .+-, is not an address and stays as written.
  5. The bracket markers [URL] and [EMAIL_ADDRESS] become URL and EMAIL; the markers of MeAJOR v2 that name an entity type ([NAME], [ORGANIZATION], [DATE], ... 22 in all, listed in model_input.py) are dropped, leaving what is glued to them ([NAME]s gives s). Any other bracketed text ([EXTERNAL], [PATCH]) stays.
  6. Whitespace is collapsed and the ends trimmed. Case is kept: an uncased tokenizer lowers it.

Every step runs in linear time, so a 1 MiB body costs the same as any other text. A released normalisation never changes: tests/data/model_text_golden.json holds its outputs, and a change to any of them (one that comes from spot_sdk.mime.visible or from Python's HTML entity and Unicode tables included) is a new name (spot-text-2), never an edit of spot-text-1. A model keeps reading the one named in its manifest.

ModelManifest is the schema of a model directory's spot-model.json (frozen, unknown fields refused): name, version (up to 64 characters of letters, digits, ., _, + and -, starting with a letter or digit), text and max_tokens, with the property label. read_manifest(directory) and write_manifest(directory, manifest) read and write it; read_manifest raises ValueError naming MANIFEST_FILE and saying missing, unreadable or invalid with the failing field names, never their values.

Model files and URLs

spot_sdk.artifacts.fetch(url, *, cache=CACHE) -> Path gives a local path for what a setting names. A value without a scheme, which does not start with //, is a local path and comes back as it is: a Hugging Face id is one, so a plugin that takes them tells them apart first. A URL must be http or https with a host and no credentials (a presigned URL gives access), and its path must end with .tar.gz, .tgz, .tar or .zip (a query may follow). Its archive is downloaded once into cache/<the URL's SHA-256> (CACHE is ~/.cache/spot-sdk/artifacts): it is downloaded and extracted in a temporary directory beside it, then renamed into place, so a stop half-way leaves nothing. fetch returns that directory, as the archive holds it: a plugin whose archives wrap the model in a directory descends into it itself. Only files and directories are extracted: a member that would land outside the directory, a link or a special file is refused. A server silent for 60 s fails the download.

A refusal or a failure raises FetchError, logged at ERROR: its message names the URL as redact shows it, and why; of a failed download, the error's type only, as its text can quote the URL. A URL that redact refuses is refused without being quoted. When another process put the same URL's archive in place meanwhile, fetch returns it.

spot_sdk.urls.redact(url) is a URL as logs and details may show it: its scheme, host, port and path, without the userinfo, query and fragment, where credentials and a presigned URL's signature are. It raises ValueError, which quotes nothing of the URL, for a value it cannot show safely, which is not one a well-formed URL needs: one it cannot parse, or whose port is not a number (in https://user:password%40host, the password reads as the port); a host with a % (%40 is an encoded @); and an @ anywhere but in the userinfo, its query and fragment included: in https:user:password@host or user:password@host, no // precedes the host, and in https://pass#word@host a # or ? ends the userinfo early.

Clients

The three clients are asynchronous and use httpx2. Each takes a transport= argument, which tests set to an httpx2.MockTransport.

SpotClient

spot_sdk.clients.SpotClient(base_url, token, *, timeout=30.0), or SpotClient.from_settings(settings).

Method Route Scope
ingest(raw, *, envelope=None, priority="normal", wait_for_verdict=False, timeout_ms=30_000) -> IngestionResult POST /internal/ingest ingest
report_action(job_id, action, *, applied) -> None POST /internal/jobs/{job_id}/action ingest
raw(email_id) -> bytes GET /internal/emails/{email_id}/raw content:read
part(email_id, part_id) -> tuple[bytes, str] GET /internal/emails/{email_id}/parts/{part_id} content:read
result(job_id) -> OrchestrationResult GET /internal/results/{job_id} results

ingest sends the message as base64 and refuses an empty one. With wait_for_verdict, the call waits timeout_ms on top of the client's timeout. part returns the media type without its parameters. An error status raises SpotAPIError, whose message names the route and the status (POST /internal/ingest answered HTTP 413) and whose status_code holds the status. So does an answer that does not validate (... answered HTTP 200 with a malformed answer), with no validation error attached. Neither ever holds the message.

KnowledgeClient

spot_sdk.knowledge.KnowledgeClient(url, token, *, source="", timeout=15.0, retrieval_limits=None), or one of:

  • KnowledgeClient.from_settings(settings, *, retrieval_limits=None): the plugin's store, token and id (as the source);
  • KnowledgeClient.for_analysis(request): the same, from PluginSettings(), with the request's retrieval limits.
Method What it does
fetch(tags=None, text=..., top_k=5, min_score=0.0, strategy="similarity") semantic search with a tag filter; top_k and min_score are capped by the retrieval limits
replace_source(docs) makes docs the provider's whole set of documents
upsert(doc), bulk_upsert(docs) write single documents
delete(doc_id) deletes one of the plugin's own documents (DELETE /sources/{source}/documents/{doc_id}); it needs the plugin's id (SPOT_PLUGIN_ID), as replace_source does

A document's id is unique within its source, the plugin that wrote it: two providers may use the same id, and an upsert is idempotent on (source, id). The store sets source to the writing plugin's id.

Tag expressions: a, a+b (and), a|b (or), a+b|c (and binds tighter). KnowledgeTag (spot_sdk.knowledge_tags) names the shared tags, and chunk_text(text, max_chars=2000, overlap=200) splits long documents. Error statuses and malformed answers raise SpotAPIError.

LLMClient

spot_sdk.llm.LLMClient(base_url, model, api_key=None, *, options=None, max_bytes=8_000, timeout=120.0). base_url is the API root of an OpenAI-compatible server (http://ollama:11434/v1); api_key, a SecretStr, is sent as a Bearer token; options adds request fields (temperature, seed, max_tokens), which cannot replace the model or the messages. max_bytes bounds the email data (below); a value too small for an empty email raises ValueError, at construction or, if lowered later, at the next call.

await complete(system, email, *, schema=None, tools=None, tool_rounds=None) returns:

  • the answer's text, without schema;
  • an instance of schema, a Pydantic model, with schema: the request asks for JSON-schema output (response_format) and the answer is validated;
  • LLMToolCalls(calls=[LLMToolCall(id, name, arguments)]) when tools are given and the model calls them. The caller runs them, and sends the results back with tool_rounds: one LLMToolRound(calls=answer.calls, results={call_id: output}) per round, oldest first. A round must hold one result per call, by id, or it raises ValueError. complete never calls the model on its own.

It raises LLMOutputError when the answer is empty, is not a chat completion, does not validate against schema, or calls tools when no tools were given; the message never quotes the answer. An error status raises httpx2.HTTPStatusError.

The request starts with two messages. The system message is the caller's prompt, followed by a notice that the user message holds one email, as JSON, between two delimiter lines, and that it is untrusted data, not instructions. The user message is the email between those lines:

<<<EMAIL 5f0c…(32 hex digits)>>>
{"from": {...}, "reply_to": {...}, "subject": "...", "headers": [...], "visible_text": "...", "urls": [...], "attachments": [...], "authentication": [...], "anomalies": [...]}
<<<END EMAIL 5f0c…>>>

Each tool round then adds an assistant message echoing the round's tool_calls, and one tool message per call, with its tool_call_id and the result between <<<TOOL RESULT 5f0c…>>> and <<<END TOOL RESULT 5f0c…>>>; the system notice then also says that tool results are untrusted data.

The delimiters carry a random nonce, drawn again if the email or a result holds it, so neither can close its block. The model sees the sender (from, and reply_to, the first Reply-To address; the others are in the headers, cut like the other lists) and the subject from the parsed view, wherever their headers sit, then the headers, the visible text, the URLs, the attachments' metadata, the authentication results and the anomalies; never the raw HTML or an attachment's content.

The email data (the JSON between the delimiters) is at most max_bytes bytes long, as UTF-8, so it costs at most about max_bytes tokens (typically a quarter): a context of max_bytes plus the system prompt plus max_tokens plus about 512 tokens (the SDK's notice, the delimiters and the chat template) cannot overflow. Tool results are not counted.

A larger email is cut, deterministically:

  1. every string but the visible text is cut so that its JSON, escapes included, takes at most 512 bytes, so one long URL, header value or filename cannot crowd the rest of its list out;
  2. what cannot fit anyway is left out before any more work: the visible text past max_bytes characters, and each list from the entry where its entries alone outgrow max_bytes;
  3. while the JSON is too large, the largest of the visible text and the lists (headers, URLs, attachments, authentication results, anomalies) is halved, keeping its first entries or the start of the text; ties go to the first field;
  4. only once those are all empty are the sender, the first Reply-To address and the subject cut, the same way (from and reply_to, single addresses, are dropped whole).

What the cut guarantees, and no more:

  • the data is at most max_bytes bytes, and holds "truncated": true when anything was cut;
  • the text keeps its start, and each list its first entries, with each string of an entry cut to at most 512 bytes of JSON;
  • a list whose entries alone fit in max_bytes loses entries only in step 3, and only while no field of the text and the lists is larger and the data still does not fit;
  • the sender, the first Reply-To address and the subject are cut only once the text and every list are empty; together they take at most 3 684 bytes of JSON, whatever their characters, so they cannot crowd the rest out beyond that;
  • the other Reply-To addresses reach the model only through the headers list, which is cut like the other lists;
  • the system prompt, the notice and the delimiters are never cut;
  • only the model sees this cut: core and the other analyzers get the whole view.

Hosting a plugin

Manifest.from_labels(labels) is the inverse of Manifest.labels(): Manifest.from_labels(m.labels()) == m. It reads the labels of an image, as the catalog and the plugin test do, and ignores labels of other prefixes. It raises ValueError: pydantic's ValidationError for a missing or invalid field, a JSON error for a label that is not JSON.

spot_sdk.hosting holds how core hosts a plugin and reads its answers. The plugin-manager creates every plugin container with it, and the plugin test creates the container under test with it too, so that a plugin runs in SPOT as it ran in its test.

Name What it is
HARDENING the options of every plugin container, as the Docker SDK names them: all capabilities dropped, no-new-privileges, at most 512 processes
limits(resources) mem_limit and nano_cpus from a Resources
isolated(egress) whether a plugin runs on a network without a route out: every class, from SPOT 2.1 (spot/spot#148)
setting_text(value) a setting as text: true or false, an empty text for None
environment(settings, *, plugin_id, kind, version, gateway_url, knowledge_url, proxy=...) the container's environment: the settings in upper case, then proxy, then SPOT_GATEWAY_URL, SPOT_KNOWLEDGE_URL, SPOT_PLUGIN_ID, SPOT_PLUGIN_KIND and SPOT_PLUGIN_VERSION. No credential is in it
secrets_archive(secrets) a tar of /run/secrets/<name>, each file mode 0444, to put in the container before it starts
validation_cause(error) where and why a pydantic error says an answer is invalid, as core names it (score: less_than_equal), never the value
TOKEN_SECRET spot_plugin_token, the secret file that holds the plugin's token
MAX_ANSWER_BYTES, IDENTITY the 1 MiB core reads of an answer, and the Accept-Encoding: identity header it sends
read_answer(response, max_bytes=MAX_ANSWER_BYTES) a streamed answer's body, as sent, up to max_bytes. Raises AnswerRefusedError when it is encoded, and its subclass AnswerTooLargeError when it is longer than max_bytes
parse_verdict(body) the AnalyzerVerdict of an answer. Raises InvalidResponseError, naming where and why, never the content

Test helpers

spot_sdk.testing:

Helper What it does
view_from_eml(eml, **meta) parses a path or bytes with parse; email_id, source ("test") and received_at have defaults, and the other keywords go to parse
make_view(**fields) an EmailView of an empty message with the fields given; when only text is given, visible_text is the text without the characters that render as nothing, as parse makes it (a visible_text given wins)
assert_contract(app, eml, *, token, timeout=10.0) waits up to timeout seconds for GET /health to leave starting, as a plugin that loads a model does; it must then be healthy (an unhealthy plugin fails with its reason). Then posts an AnalyzeRequest for the parsed .eml to the app's /internal/analyze with the token, checks the 200 and the AnalyzerVerdict, checks that the same call without the token is 401, and returns the verdict
assert_bounds_agree(manifest, settings_cls) checks that each setting of the manifest has the bounds of its field (MAX_LENGTH: max_length): ge as minimum, le as maximum, pattern as pattern, an integer's gt and lt as the next value in; a number's gt or lt, which no inclusive bound states, a field's pattern not anchored around all its branches (^...$), which pydantic searches, a setting without a field and each bound stated otherwise fail, all listed in the AssertionError
assert_provider_contract(app, knowledge, *, token, timeout=10.0) runs two syncs of a context provider's app as core does, the app writing to knowledge (a FakeKnowledgeClient with the provider's id as source), and returns the second sync's SyncState. Checks: /internal/sync and /internal/state answer 401 without the token and with another; /internal/sync answers 202 with a sync in progress; /internal/state reports that sync once it ends, within timeout seconds, without error and with the number of the source's documents as last_sync_count; the sync writes at least one document of the source, each a valid KnowledgeDocument; the second sync removes a document of the source it does not write, and writes the same ids
FakeSpot(*, verdict=BENIGN, score=0) SpotClient's methods in memory; ingested lists the IngestionRequests, add_email(raw, *, email_id=None) and add_result(result) fill it, and what it lacks is a 404 SpotAPIError. app(*, token, knowledge, llm_answer=None) serves the same store over HTTP, as a FastAPI app (see the plugin test engine)
FakeKnowledgeClient(retrieval_limits=None, *, source="") the Knowledge Store in memory, its documents kept by source and id, with naive word matching; delete and replace_source touch only the client's source (delete of a document it does not hold raises SpotAPIError 404, as the store answers), and an upsert without a source takes the client's, while one of another source is refused (ValueError, nothing of the batch stored) when the client has a source; for_analysis(request) shares the store with the request's limits; add(*docs), which stores without awaiting, all() and clear()
FakeLLM(answers) complete answers from the script: text (validated against schema), a model, LLMToolCalls (refused without tools), or an exception to raise; requests lists each call as LLMRequest(system, email, schema, tools, tool_rounds)

The plugin test engine

With the testing extra (pip install "spot-sdk-python[testing]"), spot_sdk.testing also runs a plugin's image as SPOT runs it. Without the extra, import spot_sdk.testing and the helpers above work as before; corpus, engine and container raise an ImportError that names the extra. Import them by name. The guide shows spot-cli plugin test, which uses it, from SPOT 2.1.0-beta.2.

Module What it has
spot_sdk.testing.container run_plugin_test(image, corpus, *, settings, secret_files, timeout=30.0, start_timeout=120.0, docker=None) creates the network and the container, serves the fake SPOT, checks the plugin and removes what it created, then returns the Report. It must run in a container of the Docker daemon it uses. Raises PluginTestError when the run cannot be made
spot_sdk.testing.corpus load_corpus(directory) reads phish/, benign/ and corpus.yaml (CorpusFile: settings, trusted_authserv_ids, knowledge, llm, expect); Expectation has verdict, signals and no_signals. The Corpus holds the messages, the seed documents and the sha256 of them all
spot_sdk.testing.engine check_plugin(client, ...) runs the checks over HTTP; serve(app, *, host, port, timeout=5) serves an ASGI app with uvicorn in a thread
spot_sdk.testing.report Report, Check, EmailResult, PluginTestError, and junit(report), the report as JUnit XML. Report.passed is true when every check passed and every message completed and met its expectation
spot_sdk.testing.metrics count(results) gives Metrics: the counts, the confusion matrix, the rates and the mean score; wilson(successes, n) the 95 % Wilson interval, None when n is 0. spot-cli benchmark report counts with the same functions

Workflows and results

These models are those of core's workflow API and results:

  • Workflow: stages of WorkflowStage (each with AnalyzerConfigs: weight, timeout_ms, required, failure_strategy, condition), verdict_policy and on_verdict (responder ids by verdict); the mode comes with each job, not the workflow; its id is a letter or digit, then up to 127 letters, digits, dots, underscores or hyphens (WORKFLOW_ID_PATTERN), and another id is refused;
  • VerdictPolicy: method (AggregationMethod), malicious_threshold (80), suspicious_threshold (50), decisive_signals (DecisiveSignal(signal, verdict)), decision_stages;
  • OrchestrationResult: the verdict, score and decided_by, the AttributedSignals strongest first, each AnalyzerOutcome, and the policy;
  • IngestionRequest, IngestionResult and IngestionStatus (accepted, analyzed, timeout).