Context Providers Guide¶
Context providers ingest organisational data (employee directory, threat intelligence, wiki pages, policies, ...) into the SPOT Knowledge Store. They run as standalone HTTP services. They do not see emails and do not return verdicts: core asks each one to sync on a schedule, the provider writes its documents into the store, and analyzers retrieve them on demand by tag and semantic similarity.
SDK reference
Providers are built on spot-sdk 2. The SDK's Writing plugins guide shows a complete provider, and the Python SDK reference covers the classes used below (create_plugin_app, KnowledgeClient, KnowledgeDocument, KnowledgeTag, chunk_text). To write a provider in another language, the API documents describe the routes a plugin serves and the knowledge service's API.
When to write a context provider¶
Use a context provider when:
- You have an external source of organisational facts (LDAP, HRIS, threat feed, wiki, ticketing system) from which analyzers can benefit.
- The data refreshes on a schedule, not per email.
- Analyzers can find what they need by tag expression and free-text query.
The routes a provider serves¶
The SDK's create_plugin_app(manifest, Settings, sync=sync) serves them all:
POST /internal/sync: core's scheduler calls it on the provider'ssync_schedule, and an admin through the manual trigger. It answers202at once with the provider'sSyncStateand runs thesynchandler in the background. While a sync runs, another call starts nothing and answers202with the running sync's state.GET /internal/state: theSyncState, which the dashboard's sync panel shows:in_progress,last_sync_at(when the last sync started),last_finished_at,last_sync_count(the documents the last successful sync wrote) andlast_error(the exception's type when it failed).GET /healthalways answers200with{"status", "version"}: the status ishealthy,startingorunhealthy, with a shortreasonwhen there is one. It comes from thehealthcheck the plugin givescreate_plugin_app(healthywithout one;unhealthywhen the check fails). Core reads the status, the reason and the version, and the plugin views show them asplugin_health, beside the container's state. Core keeps the status whatever the reason and the version: it shows the reason with each character that is not printable (a control, format or separator character, a no-break space among them) replaced with U+FFFD, cut at 200 characters, and leaves out a version that is not printable text of at most 64 characters.GET /manifestanswers the plugin's manifest. Both are open.
Every /internal/* route checks the provider's own token, sent by core as Authorization: Bearer, and answers 401 without it. Core reads at most 1 MiB of an answer to /internal/sync or /internal/state, and refuses an encoded one.
Implementing a provider (Python)¶
from pydantic import SecretStr
from spot_sdk import (
Egress,
KnowledgeClient,
KnowledgeDocument,
KnowledgeTag,
Manifest,
PluginKind,
PluginSettings,
SettingField,
SettingType,
create_plugin_app,
)
MANIFEST = Manifest(
id="provider-employee-dir",
version="2.0.0",
kind=PluginKind.CONTEXT_PROVIDER,
egress=Egress.NONE,
settings={
"LDAP_URL": SettingField(type=SettingType.URL, required=True),
"LDAP_BIND_PASSWORD": SettingField(type=SettingType.SECRET, secret=True),
},
)
class Settings(PluginSettings):
ldap_url: str
ldap_bind_password: SecretStr
async def sync(settings: Settings) -> int:
employees = await fetch_from_ldap(settings)
documents = [
KnowledgeDocument(
id=f"employee:{e.email}",
content=f"{e.name}, {e.title}, {e.department}. Email: {e.email}.",
tags=[
KnowledgeTag.EMPLOYEE,
*([KnowledgeTag.EXECUTIVE] if e.is_executive else []),
e.department.lower(),
],
metadata={"email": e.email, "title": e.title},
)
for e in employees
]
await KnowledgeClient.from_settings(settings).replace_source(documents)
return len(documents)
app = create_plugin_app(MANIFEST, Settings, sync=sync)
KnowledgeClient.from_settingsreads the store's URL (SPOT_KNOWLEDGE_URL), the token (/run/secrets/spot_plugin_token) and the provider's id (SPOT_PLUGIN_ID), which is thesourceof its documents.- A document is identified by its source and its id: the ids need only be unique among the provider's own documents. Another provider may use the same id; each keeps its own document under it.
replace_source(documents)makes them the provider's whole set, in one transaction (PUT /sources/{source}/documents): the store deletes the provider's other documents, so a record gone from the source system disappears.upsert,bulk_upsertanddeletechange documents one by one instead.- For long documents (wiki pages, policy PDFs, ...), split with
spot_sdk.chunk_text(text, max_chars=2000, overlap=200)and write each chunk withmetadata["parent_id"]pointing back at the source: embeddings are better per chunk and retrieval is finer-grained.
provider-employee-dir is the reference implementation.
Authentication¶
A provider holds its own opaque token (spot_pt_...). It is the only credential a plugin gets.
- File. The token is the file
/run/secrets/spot_plugin_token, and each secret setting the file/run/secrets/<NAME>. The container environment holds no credential: the other settings,SPOT_GATEWAY_URL,SPOT_KNOWLEDGE_URL,SPOT_PLUGIN_ID,SPOT_PLUGIN_KINDandSPOT_PLUGIN_VERSION. From SPOT 2.1 a plugin gets no proxy variable and no proxy credentials file: the egress gateway goes through the operator's proxy for it. The credentials file,/run/secrets/spot_proxy_credentials, is SPOT 2.0's only. - Header. Every call to SPOT carries
Authorization: Bearer <token>. Nothing else authenticates a plugin. - Scopes. They come from the plugin's kind:
| Kind | Scopes | Allows |
|---|---|---|
context_provider | knowledge:write | write, replace and delete the provider's own documents |
analyzer | knowledge:read, content:read | query the Knowledge Store; read a stored message or one of its parts |
mail_retriever | ingest | POST /internal/ingest, GET /internal/jobs/{job_id} on the gateway |
responder | results, content:read | GET /internal/results/{job_id}; read a stored message or one of its parts |
- Own documents only. A provider writes, replaces and deletes only documents whose
sourceis its plugin id. An emptysourcetakes the id; a write naming anothersource, or a deletion of another source's document (DELETE /sources/{source}/documents/{id}), is refused with403. A write never touches another provider's document, whatever id it uses. - Core's calls. The scheduler sends the provider's own token on
/internal/syncand/internal/state. A provider without a usable token is not called: the trigger is recorded as failed, with the reason.
The platform stores the token's SHA-256 for lookups and a copy encrypted with ENCRYPTION_KEY (table plugin_tokens), so it can hand the token back when it recreates the container. Removing a plugin from spot.yaml revokes its token: at once through the config API, and for a hand edit at the next api-gateway start or config reload (POST /api/v1/config/reload).
Manual plugins¶
A plugin that is not installed by the platform (run by your own compose file) gets its token from the CLI:
--kind adds the plugin to spot.yaml as a manual entry when it is not there yet. Mount the file as the secret spot_plugin_token, and set SPOT_PLUGIN_ID, which replace_source needs:
services:
provider-employee-dir:
environment:
SPOT_PLUGIN_ID: provider-employee-dir
secrets:
- spot_plugin_token
networks:
- spot-plugins
secrets:
spot_plugin_token:
file: ./spot_plugin_token
networks:
spot-plugins:
external: true
spot-plugins is the network of the plugins you run yourself: it reaches only the api-gateway, the knowledge service and the analyzer-orchestrator. A plugin left on the old spot_spot-network keeps working but is not isolated: it still reaches the mail-orchestrator, the plugin-manager and, in dev, adminer.
Running the command again rotates the token: the old one stops working at once. For an installed plugin, the platform also recreates the container with the new token.
Never the internal key
Do not give SPOT_INTERNAL_API_KEY to a provider you run yourself: the knowledge service takes whoever sends it for core, allowed to overwrite any provider's documents. Give it a token instead.
Tags and the shared vocabulary¶
Tags are the only categorisation axis the Knowledge Store understands. Use the shared constants from spot_sdk.knowledge_tags (EMPLOYEE, EXECUTIVE, WIKI_PAGE, POLICY, FINANCE, ...) so analyzers and providers stay aligned. Custom tags like acme:jira_issue are fine where the shared vocabulary doesn't fit. Tokens must match [a-z0-9][a-z0-9_\-:]*; the Knowledge Store guide documents the AND / OR query grammar analyzers use against them.
Registering a provider¶
Context providers live under plugins.context_providers in spot.yaml:
plugins:
context_providers:
employee-dir:
enabled: true
url: http://provider-employee-dir:8000
sync_schedule: "0 */6 * * *" # every 6 hours
sync_timeout_ms: 120000
settings:
LDAP_URL: ldap://ldap.example.com
| Field | Description |
|---|---|
enabled | Whether the scheduler runs syncs for this provider |
url | Base URL the scheduler calls |
sync_schedule | Cron expression (or empty for manual-only) |
sync_timeout_ms | How long the scheduler waits for /internal/sync to answer |
settings | Provider-specific settings, as its image's spot.plugin.settings declares them |
secrets | Secret settings, encrypted; set them through the API |
Supported cron syntax: fixed values, *, comma lists (0,15,30,45), ranges (3-6), steps (*/10), and @hourly / @daily / @weekly / @monthly aliases. Absent or blank sync_schedule means "manual-only": the scheduler skips the provider but the manual trigger still works.
You can also manage providers via the API or the Plugins page in the web dashboard, which writes to the same spot.yaml:
| Method | Path | Action |
|---|---|---|
| GET | /api/v1/config/plugin/context_provider | List IDs |
| GET | /api/v1/config/plugin/context_provider/{id} | Get one |
| POST | /api/v1/config/plugin/context_provider | Create |
| PUT | /api/v1/config/plugin/context_provider/{id} | Update |
| DELETE | /api/v1/config/plugin/context_provider/{id} | Delete |
| POST | /api/v1/config/plugin/context_provider/{id}/enable | Enable |
| POST | /api/v1/config/plugin/context_provider/{id}/disable | Disable |
The same URL shape with analyzer, mail_retriever and responder covers the other plugin kinds.
Triggering a sync manually¶
The dashboard's "Sync now" button on the provider's detail page calls:
POST /api/v1/plugins/context_provider/{id}/sync # admin: ask the provider to sync now
GET /api/v1/plugins/context_provider/{id}/sync # viewer: the last trigger and the sync state
Both answer the provider's SyncState (read live from its /internal/state for the GET) with the last trigger: last_triggered_at, trigger_status (started or failed) and trigger_error. The GET adds the schedule, its next_sync_at and any schedule_error.
Failure handling¶
- Trigger failed: the provider is unreachable, answers an error, has no usable token, or does not answer within
sync_timeout_ms. The trigger is recorded asfailed, with the reason, and the scheduler tries again at the next scheduled time.sync_timeout_msbounds only the wait for that answer: the sync itself runs in the provider's background. - Sync failed: the
synchandler raised. The provider records the exception's type inlast_errorand logs its type and where it was raised, never its message, which may hold a directory URL with credentials. Documents already in the store stay: areplace_sourcethat did not complete changed nothing, so analyzers keep retrieving the last good snapshot. - Knowledge Store unreachable, or its embedding backend (Ollama) down: the store answers
503to writes, andKnowledgeClientraisesSpotAPIError(httpx2errors when it cannot connect). Let it raise: the sync fails as above.
The dashboard's Knowledge readiness banner aggregates these signals so operators see a single status line.
Packaging¶
- Package the app as a Docker image; it listens on the manifest's
port(8000 by default). - Label the image with the labels the manifest renders (
Manifest.labels()), so the catalog reads whatGET /manifestanswers: spot.plugin.id,spot.plugin.version;spot.plugin.kind=context_provider;spot.plugin.contract=2: the catalog refuses any other contract;spot.plugin.egress:none,indicatorsorcontent. An image without it counts ascontent, and installing it needs an admin's consent;spot.plugin.settings: each setting as an object with atype(string,integer,number,boolean,url,secret) and optionallyrequired,default,description,secretandenum. Mark a secret setting (a bind password, an API key)secretso SPOT stores it encrypted and delivers it as the file/run/secrets/<NAME>. A field that does not parse is left out, with a schema warning in the catalog.- Publish it where a plugin source reaches it, and install it from the catalog, or run it yourself (see Manual plugins) and register it via
spot.yamlor the dashboard.
The installer gives the container its token, its secrets as files, and SPOT_KNOWLEDGE_URL and SPOT_PLUGIN_ID in its environment. A provider built on SDK 1 (contract 1) is refused by the catalog: port it to SDK 2 (Migrating from 1.x).
Providers can be written in any language as long as they serve the routes of the context provider's document and call the knowledge service as its document describes, with the token as Bearer.