spot-cli user guide¶
spot-cli is the operator client for a deployed SPOT platform. It talks to the api-gateway over HTTP using the same OAuth2 password-grant flow as the web dashboard, and persists access tokens locally so each shell command does not require re-authenticating.
This guide walks through the everyday operator flow. If you have not put the spot-cli wrapper on your PATH yet, see the install guide -- it's a one-line symlink.
Authenticating¶
Before any other command works you need a token. spot-cli prompts interactively, or reads SPOT_USERNAME / SPOT_PASSWORD from the environment if you want to script it. The wrapper forwards both by name, so the password never sits on a command line: prefer them to -p.
spot-cli auth login # interactive prompt
SPOT_USERNAME=alice SPOT_PASSWORD="$(cat alice.pw)" spot-cli auth login # for a script
spot-cli auth whoami # who am I (and is the token still valid)?
spot-cli auth refresh # force a refresh round-trip
spot-cli auth logout # end the session, forget the cached tokens
Tokens land in ~/.config/spot-cli/ (or $SPOT_TOKEN_DIR). When the access token expires, spot-cli refreshes it automatically using the saved refresh token; you only see a re-prompt if the refresh itself fails. A refresh token is used once: each refresh saves the next one. spot-cli auth logout ends the session at the gateway, so its refresh token no longer works, then deletes the tokens, even when the gateway cannot be reached.
Health and version¶
spot-cli health calls /api/v1/system/health on the gateway, which fans out to every platform service; the table shows which dependencies the gateway considers reachable. Each enabled plugin shows its own state, as the analyzer-orchestrator reads it from the plugin's /health: healthy, starting or unhealthy, with the plugin's reason, or unknown with the reason it could not be read. spot-cli version prints the CLI version, the gateway version, and each plugin's version, as the analyzer-orchestrator reads it from the plugin's /health, side by side, which is the fastest way to confirm an upgrade landed. Both show a plugin's text as written.
Watching¶
spot-cli watch draws what spot-cli health and spot-cli analyze list show, on one screen, and reads both again every 5 seconds until you press Ctrl-C. A viewer may watch. --interval sets the seconds between two reads (at least 2) and --limit (-n) the jobs shown (at most 100).
It needs a terminal. A script reads spot-cli health -o json and spot-cli analyze list -o json instead.
watch outlives a gateway that cannot be reached, a connection lost during a read, no answer in time, and a 503: it keeps the last view it read, under the reason the next read failed, and reads again. Any other refusal, such as an expired token, ends it with exit code 1 and the gateway's reason. Ctrl-C ends it with exit code 130.
Output for scripts¶
Every command that shows data takes --output (-o): table, the default, prints for a person; json and yaml print the data for a program.
spot-cli health -o yaml | grep '^overall:'
spot-cli analyze list -o json | jq -r '.jobs[] | select(.verdict == "malicious") | .job_id'
job=$(spot-cli analyze submit -f - -o json < mail.eml | jq -r .job_id)
spot-cli workflow get strict -o yaml > strict.yaml # edit it, then update it from the file
These commands take it: auth whoami, health, version, audit, plugin list|info|schema|resources|test, profile list, workflow list|get|history, analyze submit|status|list, config show|platform|history and benchmark report. analyze submit takes it because a script needs the job id next.
These do not:
- the commands that change something (
plugin install,workflow create, ...): they print one line for a person, and their exit code tells a script the rest; - the commands that already print for a program:
plugin token,profile requirementsandprofile env; config diff, which prints a diff;benchmark datasets|list|compare: their data are the files of$SPOT_BENCHMARK_DIR, which a script reads directly;routing show, which keeps its own--output yaml|json, YAML by default, sincerouting applyreads it back;watch, a live screen.
The JSON is the gateway's answer, as the API reference describes it. A command that combines answers, or reads no gateway, holds this:
| Command | -o json holds |
|---|---|
version | the gateway's answer with cli added; cli alone when the gateway cannot be reached |
health TARGET | the target's entry of the gateway's answer |
plugin list | a list of what plugin info gives for each plugin; with --catalog, the catalog's entries |
plugin info | the plugin's configuration with catalog; with --catalog, the catalog entry |
plugin test | the report |
profile list | a list of {name, default, plugins, memory_gib, disk_gib, gpu, description} |
config show [SECTION] | spot.yaml with secrets redacted; with a section, that section |
analyze submit | the answer of POST /api/v1/analyze; with --wait or --require-verdict, the finished job, as analyze status gives it |
benchmark report | the metrics, as metrics.json holds them |
With json or yaml, standard output holds the document and nothing else. Warnings and errors go to standard error, and exit codes are as without --output; the lines a command prints for a person after a change are left out. analyze submit -f - --require-verdict -o json prints the finished job, then exits 1 on an unknown verdict.
JSON and YAML are ASCII: every other character is escaped (\u00e9 for é), so text from an email cannot drive your terminal, and a program reads the same values.
The --json flag of plugin test and benchmark report is --output json. It is deprecated and goes in 3.0.
Plugins¶
There is one plugin command group. Use --kind to scope it to a specific type (analyzer, context_provider, mail_retriever, responder); leave it off to operate across all kinds.
spot-cli plugin list # all installed plugins
spot-cli plugin list --kind responder # just responders
spot-cli plugin list --catalog --kind analyzer # available in the catalog
spot-cli plugin info analyzer-nlp # configuration + catalog entry
spot-cli plugin info analyzer-llm --catalog # a catalog entry, installed or not
spot-cli plugin schema analyzer-nlp # settings schema
spot-cli plugin install analyzer-nlp # from the catalog
spot-cli plugin install analyzer-llm --consent-content
spot-cli plugin enable analyzer-nlp
spot-cli plugin disable analyzer-nlp
spot-cli plugin restart analyzer-nlp
spot-cli plugin install analyzer-nlp --version 2.0.0 # a version the source lists
spot-cli plugin update analyzer-nlp # to the newest version that runs on this SPOT
spot-cli plugin update analyzer-nlp --version <version>
spot-cli plugin update analyzer-nlp --source spot-plugin-index # move it to the signed index
spot-cli plugin rollback analyzer-nlp # the version the last update replaced
spot-cli plugin settings provider-employee-dir \
--set LDAP_URL=ldap://ldap.example.com \
--secret LDAP_BIND_PASSWORD="$(cat bind.pw)"
spot-cli plugin resources analyzer-nlp # memory and CPU limits
spot-cli plugin resources analyzer-nlp --memory-mb 3072 --cpus 2
spot-cli plugin resources analyzer-nlp --reset
spot-cli plugin remove analyzer-nlp
spot-cli plugin token analyzer-nlp # new token; the old one stops working
spot-cli plugin token retriever-milter --kind mail_retriever
spot-cli plugin mode retriever-milter enforce # asks first; --yes does not
spot-cli plugin mode retriever-milter observe
list shows each plugin's kind, its egress class (what it sends outside SPOT: none, indicators or content), the plugin contract its image declares, its source, with --catalog its tier in the signed index (official, verified or community; - for an entry of another source), and notes: why the catalog refuses it (an image built for another contract than 2, or an entry listed by an earlier SPOT, which a catalog refresh reads again), and how many settings of its schema the catalog left out (schema warnings). For an installed plugin, the contract and the notes come from its catalog entry: the version the catalog lists for its source (the newest this SPOT runs). info prints the plugin's configuration, with previous_version, the version rollback installs, and that catalog entry under catalog, schema warnings included; info --catalog prints a catalog entry alone, for a plugin not installed yet. While the catalog is unavailable, list and info still show the installed plugins from spot.yaml, without contract or notes, and warn once.
A plugin has one kind, the section of spot.yaml that stores it. list --kind responder lists the entries of responders. Given a --kind that is not the plugin's, info, schema, enable, disable and token stop and say which kind it has.
A plugin of egress class content may send email content outside SPOT, so install and update refuse it, with the gateway's message, unless an admin passes --consent-content. The consent is recorded with the plugin: a later update to a content version needs no new one.
resources shows a plugin's memory and CPU limits: the override, what its image asks for, and the limits in force, named by where they come from (override, image's request, or default: 1024 MB and 1 CPU). --memory-mb and --cpus set the override, a value left out keeping the limit in force; --reset removes it, and cannot be combined with them. An enabled plugin is restarted to take new limits, and is unavailable for a few seconds; a disabled one takes them when it is started.
token prints only the token, so it can be captured into a secret. An installed plugin's container is recreated with the new token. For a plugin you run yourself, such as the milter compose service, deliver it as the file /run/secrets/spot_plugin_token (a compose secret); with --kind, a plugin not in spot.yaml yet is first added as a manual entry of that kind.
mode switches a mail connection (a mail retriever) between observe, where SPOT and its responders only record what they would do, and enforce, on the jobs whose route is in enforce too. It asks before enforce unless --yes is given; the next message takes the new mode, without a restart. It needs an admin, and refuses a plugin that is no mail retriever.
install, remove, enable, disable, and update write to spot.yaml through the api-gateway; the orchestrator picks the change up on its next per-job re-read, so no service restart is required. A --set setting the plugin's schema marks secret (--set MISP_TOKEN=... for analyzer-misp) is stored encrypted and reaches the plugin as the file /run/secrets/MISP_TOKEN; plugin info lists its name, never its value. --secret NAME=VALUE (repeatable, on install, settings and update) stores any setting as a secret, for a credential the image does not mark, such as LDAP_BIND_PASSWORD for provider-employee-dir. The CLI never prints a secret, but a value typed on the command line is in your shell history: read it from a file, as above, or clear the history line.
settings changes an installed plugin's settings and secrets and nothing else: --set values merge into the current settings, an empty --secret value keeps the stored secret, and it prints the plain settings and the secret names. Saving restarts an enabled plugin so it takes them: settings says the plugin is being restarted, and the plugin is unavailable for a few seconds. A disabled plugin takes them when it is started. The settings are read against the schema of the installed version. When the plugin's catalog entry, at that version, cannot be read, the gateway answers 503 and saves nothing: refresh the catalog, re-add the plugin's source, or update the plugin to a version it lists, and save again.
install takes the newest version of the plugin that runs on this SPOT, by its contract and its SPOT range, which may not be the latest one; --version asks for another that the source lists. update without --version does the same, and only when that version is newer than the installed one: otherwise the installed version stays, with the --set and --secret values given applied at it, which its source must still list (404 when it does not), and without any it says the plugin is at the newest version this SPOT runs. When no version runs on this SPOT, it is refused (409) with the reason. update --version moves the plugin to that version, older or newer, and refuses (409) one whose SPOT range excludes this SPOT. Settings given to update with --set replace all the others, so change settings with settings. An update records the version it replaced; rollback installs it again, keeps the settings and secrets as they are, and says so when the plugin was not updated since SPOT 2.1. See Versions. update --source (-s) moves the plugin to another source that lists it, as install --source picks one: --source spot-plugin-index moves a plugin installed from Docker Hub to the signed index, at its installed version unless a newer one runs, and re-creates its container once, on its image by digest (see The signed plugin index).
Changing an enabled plugin's settings or resources restarts it: no restart is needed after settings or resources. restart applies a change made elsewhere, such as a hand edit of spot.yaml after POST /api/v1/config/reload, by re-creating the container from spot.yaml when that changed, else restarting it in place; it also bounces a plugin that hangs. The plugin is unavailable for a few seconds, and the command waits as long as a re-creation takes (up to 600 s). A disabled plugin is refused: enable starts it.
Creating a plugin¶
plugin new creates a plugin's repository from spot-plugin-template, at the version your SPOT release pins, in the current directory. The wrapper runs it on the host, as you, so the files are yours; copier, which renders the template, never enters the image. It needs uv and git, which the plugin needs anyway.
spot-cli plugin new analyzer-url
spot-cli plugin new provider-vendors --data kind=context_provider --defaults
copier asks what it needs to know. --data NAME=VALUE answers ahead and --defaults takes every other answer's default; the other options of copier pass through. The command stops before copier runs when the id is missing, empty, an option or holds a slash, when a file or directory of that name exists, or when uv or git is missing.
Then:
make test:plugin follows, as Testing a plugin describes. Without the wrapper, the command it runs is:
uvx --from copier==9.19.1 copier copy --vcs-ref v1.0.0 \
--data plugin_id=analyzer-url https://codeberg.org/SPOT_Project/spot-plugin-template.git analyzer-url
To bring a later version of the template into the plugin, follow the template's README.
Testing a plugin¶
plugin test is for a plugin's author. It runs the plugin's image as SPOT runs it, without a SPOT stack, and sends it the messages of its corpus, tests/emls/. It checks the contract, then reports precision and recall, counted as spot-cli benchmark report counts them. It needs no platform and no sign-in, only Docker and an image the Docker host holds: the one you built, or one you pulled. It never pulls.
docker build -t analyzer-rules:dev . # in the plugin's repository
spot-cli plugin test analyzer-rules:dev # the corpus in tests/emls
spot-cli plugin test analyzer-rules:dev --set BUSINESS_HOURS_START=7
spot-cli plugin test analyzer-rules:dev --junit report.xml # also JUnit XML, for a pipeline
spot-cli plugin test analyzer-rules:dev -o json # the report as JSON, not tables
spot-cli plugin test analyzer-misp:dev --secret-file MISP_TOKEN=misp.token # in analyzer-misp
The command starts the plugin on a network of its own, without a route out, as SPOT runs every plugin, with the options, the limits and the secret files SPOT gives it, and serves it a fake SPOT. It removes the network and the container when the run ends, Ctrl-C included. The wrapper gives the command the Docker socket, with the socket's group (set DOCKER_GID when the wrapper cannot read it), and the current directory, where the command runs: run it from the plugin's repository. The paths of --emls, --secret-file and --junit are in that directory, the only one the container sees.
A run of analyzer-rules on its corpus, which has no corpus.yaml:
$ spot-cli plugin test analyzer-rules:dev
analyzer-rules 2.1.0b1 (analyzer, egress none) from analyzer-rules:dev
Checks
┏━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
┃ Check ┃ Result ┃ Detail ┃
┡━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
│ start │ passed │ │
│ health │ passed │ │
│ manifest │ passed │ │
│ contract │ passed │ │
│ token │ passed │ │
│ running │ passed │ │
└──────────┴────────┴────────┘
Accuracy Metrics
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Metric ┃ Value ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ Prec │ 1.000 (0.439-1.000) │
│ Recall │ 0.600 (0.231-0.882) │
│ F1 │ 0.750 │
│ F2 │ 0.652 │
│ FPR │ 0.000 (0.000-0.434) │
│ FNR │ 0.400 │
│ Acc │ 0.800 │
│ Score │ 29.8 │
└────────┴─────────────────────┘
Intervals are 95 % Wilson intervals and assume independent messages.
A message whose call failed, or whose answer missed its expectation, is listed under Messages, with why. A check of a message sent again with SPOT failing, <path> with SPOT failing, shows only for a plugin that calls SPOT.
The corpus is tests/emls/phish/*.eml and tests/emls/benign/*.eml, the directory being a message's truth, with an optional tests/emls/corpus.yaml for the plugin's plain settings, the authserv-ids of the corpus's mail server, Knowledge Store documents, a scripted model and what each message must be answered. The SDK's guide, Testing the image, describes it, with the options, each check and the report.
A run fails on a contract fault, an unmet expectation, or a benign or spam answer while SPOT fails: a failure is never benign. Metrics never fail a run.
| Code | Meaning |
|---|---|
| 0 | the run passed |
| 1 | a check failed, a call failed, or an expectation was not met |
| 2 | the run could not be made: Docker cannot be reached, the image is missing or is no plugin, the corpus is invalid, a setting is refused, the command does not run in a container, the JUnit file could not be written, or the run could not remove all it created |
| 130 | the run was interrupted (Ctrl-C), and removed what it created |
On a machine without the wrapper, such as a pipeline's, run spot-cli's image with the socket, its group and the current directory:
docker run --rm --user "$(id -u):$(id -g)" \
--group-add "$(stat -c %g /var/run/docker.sock)" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD:/work" -w /work \
--entrypoint python spotproject/spot-cli:2.1.0-beta.2 \
-m spot_cli plugin test analyzer-rules:dev
--user makes the files the run writes yours, and --group-add lets that user use the socket. On macOS, stat takes -f %g in place of -c %g. In GitLab CI, the plugin-test component of spot/ci-templates runs it, from version 1.3.0.
Profiles¶
A profile is what an install runs: its plugins, its workflow, and what its host needs. The deployment wrapper's spot-cli setup installs SPOT with one (see Getting started) through the commands below; profile apply also applies one to a platform installed by hand.
| Profile | Plugins | Workflow | Host |
|---|---|---|---|
minimal (default) | analyzer-rules, analyzer-nlp | minimal-workflow | 4 GiB of memory, 20 GiB free for Docker |
standard | plus analyzer-llm with phi4-mini, on the bundled Ollama on an NVIDIA GPU (COMPOSE_PROFILES=cli,gpu, OLLAMA_MEMORY_LIMIT=8G) | standard-workflow | 12 GiB, 30 GiB, the NVIDIA container runtime |
full | plus analyzer-llm with llama3.1:8b, on the bundled Ollama on an NVIDIA GPU (cli,gpu, OLLAMA_MEMORY_LIMIT=12G) | full-workflow | 16 GiB, 40 GiB, the NVIDIA container runtime |
Each workflow runs analyzer-rules and analyzer-nlp at once and decides by their weighted mean; standard-workflow and full-workflow then run analyzer-llm. Each also decides suspicious on analyzer-rules' signal attachment.bidi_override, an attachment name that hides its real extension behind a bidi control. Every profile gives analyzer-nlp 1536 MB of memory, twice its peak on a test instance. standard's Ollama limit is one and a half times Ollama's peak there with phi4-mini and bge-m3; full's 12G is an estimate, as the 8B model was not measured (Ollama's limit shows how to raise it).
standard and full differ by their model and memory only: both need an NVIDIA GPU. The GPU variant of the bundled Ollama, which they run, has only passed docker compose config: it has not run on a GPU host yet. On 4 CPUs, phi4-mini did not answer the test email within analyzer-llm's 120 s, so an install without a GPU takes minimal.
analyzer-llm is of egress class content: it sends the email's content to its LLM server, here the bundled Ollama. Installing it needs an admin's consent, --consent-content.
spot-cli profile list # the profiles, their plugins and hosts
spot-cli profile requirements standard # memory_gib=12, disk_gib=30, ... one per line
spot-cli profile env minimal --domain spot.example.com --acme-email ops@example.com \
--admin-email admin@example.com --docker-gid 999 < .env.example > .env
spot-cli profile apply minimal # install, create, select, then wait
spot-cli profile apply standard --consent-content --timeout 900
requirements prints memory_gib, disk_gib, gpu, compose_profiles, content_plugins (those needing the consent) and each setting the profile writes into .env (env.OLLAMA_EXTRA_MODELS=phi4-mini; none is a secret) as key=value lines, for a script: setup checks a kept .env against them. env reads .env.example on standard input and writes a new install's .env on standard output, and nothing else: every secret generated, DOMAIN, ACME_EMAIL, SPOT_ADMIN_EMAIL, DOCKER_GID, SPOT_BOOTSTRAP_ADMIN=true, and the profile's compose profiles and Ollama settings. A key it does not find is appended under # Set by spot-cli setup. It stops, naming the key, on a CHANGE_ME placeholder it does not generate. Write the file under umask 077, and never over an existing .env: its secrets protect the data.
apply (admins), in order:
- installs the profile's plugins
spot.yamldoes not hold, with their settings; a plugin it installs gets the profile's memory. Before the first install, each must be in the catalog (refreshed once when one is missing) and not refused by it, and a plugin of egress class content needs--consent-content: a refusal installs nothing; - creates the profile's workflow, unless a workflow has its id: an operator's edits are kept;
- makes it the default while the default is still the shipped
default-workflow, or none is set: a default an admin chose is kept, even when apply created the profile's workflow; - waits until each of the profile's plugins reports
healthy, reading its state every 5 s, and fails after--timeoutseconds (600 by default), naming the plugin, its state and its reason.
It prints what each step does or keeps. Applied again, or after an install made by hand, it installs only what is missing.
Workflows¶
Workflows describe how analyzers chain together. The CLI mirrors the api-gateway's CRUD endpoints:
spot-cli workflow list
spot-cli workflow get default-workflow
spot-cli workflow create strict.yaml
spot-cli workflow update strict strict.yaml
spot-cli workflow delete obsolete-workflow
spot-cli workflow history strict # its versions, newest first
spot-cli workflow restore strict a1b2c3d # an old version, as the next one
spot-cli workflow clone strict strict-2 --name "Strict 2"
The YAML or JSON file passed to create and update is validated server-side against the platform's Pydantic models. If validation fails spot-cli prints the gateway's message and exits 1. The gateway numbers the versions: the file's version and created_by are dropped, whatever they hold.
history lists a workflow's versions from the history of spot.yaml: each one's version (deleted for its delete), date (UTC), author, message (a save, a restore, a config rollback) and commit (--limit, 1 to 100, and --offset page through them). restore saves the version a commit holds as the workflow's next one, a deleted workflow included (admins); clone copies a workflow under a new id, with the source's name unless --name gives one (admins). An unknown commit or workflow, or a clone's id already taken, prints the gateway's message and exits 1.
Routing¶
Routes choose the workflow each email runs, and the shadow runs candidate workflows beside production (Routing). The routing document is three sections of spot.yaml, recipient_groups, routing and shadow, read and replaced together:
spot-cli routing show # as YAML, as spot.yaml holds it
spot-cli routing show --output json
spot-cli routing show > routing.yaml # edit it, then:
spot-cli routing apply routing.yaml
show is for analysts and admins (the groups list staff addresses), apply for admins. apply reads the file as UTF-8 and sends its three sections, and none of the file's other sections, so a whole spot.yaml can be applied too. It replaces the document whole: a section the file lacks is emptied, and a file with none of the three is refused rather than clearing the routing (an explicit routing: [] clears the routes), and a section written without a value (routing:) is sent empty, as a load reads it. The gateway refuses the document, saving nothing, when it breaks a rule: a route named twice, an unknown group or a malformed address or domain, as a load does, and, saved this way only, a route or shadow entry naming a workflow that is not loaded, or sender_domains while mail.trusted_authserv_ids is empty (a load warns of those once and skips them). apply then prints the rule and exits 1. A saved document is a commit of spot.yaml's history, by you, and applies from the next email.
A workflow's shadow differences, where it decided another verdict class than production, are listed by GET /api/v1/workflows/{workflow_id}/shadow (a viewer may read them).
Submitting an analysis¶
spot-cli analyze submit -f mail.eml # an RFC 5322 message
spot-cli analyze submit -f - < mail.eml # read from standard input
spot-cli analyze submit -f mail.eml --workflow strict
spot-cli analyze submit -f - --wait < mail.eml # wait, then show the verdict
spot-cli analyze submit -f - --require-verdict < mail.eml
spot-cli analyze status <job_id> # one job's verdict
spot-cli analyze status <job_id> --verbose # and the signals behind it
spot-cli analyze list # recent jobs
spot-cli analyze list --status queued # the jobs of one status
spot-cli analyze cancel <job_id> [<job_id>...] # cancel jobs queued or running
submit sends the message as it is, base64-encoded, to POST /api/v1/analyze, and prints the job id and the stored email's id at once. The gateway refuses a message larger than 25 MiB, or one it cannot parse, and submit prints why. Through the wrapper, the CLI sees the directory you run it from, read-only, and nothing else: give the message by a path in it (-f mail.eml, -f reported/mail.eml), or on standard input (-f -).
--wait reads the job every 2 s until it is completed, failed or cancelled, for at most --timeout seconds (600 by default), shows it as status does, and exits 1 unless it completed. --require-verdict waits too, and also exits 1 when the verdict is unknown or an analyzer's outcome is not success (timeout, error, unknown or skipped), naming each such analyzer and its outcome on standard error, or when the job's result was not stored, which leaves no outcome to check: spot-cli setup submits its test email with it, so an install's first verdict is a real one.
status shows the job's status and, once it is finished, its verdict (malicious, suspicious, spam, benign or unknown), its score (0-100, how likely the email is malicious) and decided_by: a decisive signal (signal:<name>), the workflow's method (method:<name>), all_unknown and required_unknown when the analyzers could not decide, failed for a job that failed, cancelled for a job cancelled, or v1 for an analysis made before 2.0 and converted. A failed job is unknown and shows its error. --verbose adds the signals behind the verdict, strongest first: the analyzer that emitted each, its name, its weight, and its evidence and location. The evidence and location quote the email, so the gateway gives them to analysts and admins only: a viewer sees each signal's analyzer, name and weight. list shows recent jobs with their verdict, score and decided_by (--limit, at most 100, and --offset); --status keeps the jobs of one status, such as queued, those a cancel can still stop.
cancel (analysts and admins) cancels each job in turn: a running job's analysis stops at once, and a queued one never starts. The job is recorded cancelled, unknown, decided by cancelled, and no responder is called. A job already cancelled is cancelled again without error; a job that completed or failed, or that the platform does not know, stops the command there with the gateway's message and exit code 1.
Text that comes from an email or an analyzer, such as a subject or an evidence, is shown as written: markup is not interpreted, and a control character, such as the escape that starts a terminal sequence, is shown as the replacement character U+FFFD.
Configuration¶
spot-cli config show # full live spot.yaml
spot-cli config show plugins.analyzers # walk into a section
spot-cli config platform # platform settings table
spot-cli config validate spot.yaml
spot-cli config history --limit 20 # its changes, newest first
spot-cli config diff a1b2c3d # what one change did
spot-cli config rollback a1b2c3d # preview, confirm, roll back
show returns the active configuration as the gateway sees it, with secrets redacted. validate reads the file as UTF-8 and POSTs it to /api/v1/config/validate, which parses it through the same models the loader uses at startup, the routing sections included; this is how you sanity-check a change before editing the file in place. It does not report an unknown or misspelt key, which the gateway drops when it next loads the file.
validate is read-only and never modifies the live spot.yaml.
Every change of spot.yaml is a commit of its history, by the user who made it (admins only):
historylists the commits, newest first: each one's id, date (UTC), author, message and the sections it changed (--limit, 1 to 100, and--offsetpage through them).diff <commit>prints what a change did, its added lines green and its removed lines red (--no-colorfor none). A commit is named by its id or 7 or more of its first digits; an unknown one printsUnknown commitand exits 1. A sealed secret reads(sealed), or(sealed, changed).rollback <commit>prints what rolling back to that commit would change in the settings in force, asks first (--yesdoes not), then restores every section of it butplugins:, as a commit of its own, and prints that commit. It printsNothing to roll backwhen the settings in force already match, and refuses a commit whosespot.yamlno longer validates.
See History of spot.yaml for what a rollback leaves as it is.
Audit log¶
spot-cli audit # the last 50 entries
spot-cli audit --action plugin. --since 2026-10-01
spot-cli audit --actor alice --outcome failure --limit 200
The audit log records sign-ins, plugin lifecycle actions, user accounts, plugin sources, deletions of stored data and replays of parked messages (admins only; see Audit log). audit prints its entries newest first, as a table: when (UTC), who, the action, its target, its outcome, what changed (a failure's HTTP status among it) and the address the gateway saw, then how many it printed of how many pass the filters. A refused sign-in that names no one shows unknown account, or unknown client for an API client's.
--actiontakes an action (plugin.install) or a family ending in a dot (plugin.,auth.,user.,source.,email.,knowledge.,queue.).--actorand--targettake a username or OAuth client id, and a plugin, username, source, email or document id, or queue.--outcomeissuccessorfailure;--sincea day or a time, in UTC (2026-10-01,2026-10-01T08:00:00);--limit1 to 500.
Configuration changes are not in it: spot-cli config history lists them.
Replaying parked messages¶
spot-cli queues replay orchestrator.analysis --limit 1 # one first
spot-cli queues replay orchestrator.analysis # then the rest
A message whose processing keeps failing is parked in its queue's dead-letter queue. Once its cause is fixed, an admin replays it into its queue (orchestrator.analysis, orchestrator.dispatch, orchestrator.shadow or orchestrator.benchmark, whose replay Troubleshooting describes); the command prints how many moved. Replay one first and check that it is handled: it does not come back to the dead-letter queue, and its job completes (a benchmark message has no job: its run's count moves). A message that fails again holds one of its queue's slots through its retries, about 13.5 minutes, before it is parked again. Only the messages parked when the replay starts move, each at least once.
The command waits up to 10 minutes for the gateway's answer. If it times out first, the replay is not stopped: it goes on in the api-gateway, and the dashboard's Queues page (or rabbitmqctl list_queues on the broker) shows what is left in the dead-letter queue.
Setup, backup, restore and upgrade¶
The deployment wrapper, deploy/scripts/spot-cli, runs setup, backup, restore, upgrade and plugin new on the host rather than in the image: the first four from the deploy directory and with your own Docker client, plugin new in the current directory and with uv (see Creating a plugin). Every other command runs in the image, as above. The deployment guide's Quick Start, Backup and restore and Upgrading pages give every detail.
scripts/spot-cli setup [--profile minimal|standard|full] [--domain DOMAIN] \
[--acme-email EMAIL] [--admin-email EMAIL] [--consent-content] [--yes]
scripts/spot-cli backup [--dir DIR]
scripts/spot-cli restore <backup directory> [--yes]
scripts/spot-cli upgrade <version> [--from REMOTE_OR_URL] [--yes]
setup installs SPOT on a new host with a profile (Profiles): it checks the host, writes .env with every secret generated, starts the stack, runs profile apply and analyzes a test email with --require-verdict. Run again, it keeps .env and installs only what is missing. On a terminal, without --yes, it asks what is missing, the profile included, asks an empty answer again and, when it asked anything, prints the options that give the same answers and asks before it writes .env or starts anything. Given every option, with --yes, or without a terminal, it asks nothing.
backup writes one directory under backups/ (or --dir DIR), readable by its owner only: the database, checked by a restore into a scratch database, the spot-config volume (spot.yaml and its history), .env and the override file. Its path is the only line it prints on standard output. It holds every secret of .env: keep it like .env.
restore asks first, then backs up the live state beside the backup (after a restore that did not finish, it keeps that run's safety backup instead, and backup refuses until the restore finishes). It puts back the backup's deploy release, .env, override file, database and configuration volume, and starts SPOT, on the same host or a new one. --yes answers the question without a terminal.
upgrade fetches the release's tag from origin (or --from), checks that .env holds every setting it requires, and takes a backup. Then it starts the release and waits for every health check. When something fails after the backup, it prints the restore command that goes back.
Benchmarks¶
Benchmarks live in spot-cli because the dataset registry, normalisation, batching, and metric logic are part of the same versioned artefact. The raw datasets live in a separate dataset repo that you clone yourself; point SPOT_DATASETS_DIR at it and SPOT_BENCHMARK_DIR at a writable workspace before running any benchmark command (see Benchmark directories).
spot-cli benchmark datasets # registry + normalisation status
spot-cli benchmark normalize --dataset miltchev
spot-cli benchmark run --dataset miltchev --workflow default-workflow
spot-cli benchmark report --latest
spot-cli benchmark list
spot-cli benchmark compare <run_id_1> <run_id_2>
normalize converts a raw dataset into labelled RFC 5322 messages, kept base64 in JSONL under $SPOT_BENCHMARK_DIR/normalized/<dataset>/. run submits those messages in batches through /api/v1/analyze, polls until each job completes, and appends every verdict to $SPOT_BENCHMARK_DIR/runs/<run_id>/results.jsonl. report computes precision, recall, F1, F2, FPR, FNR, and accuracy from those results (a malicious or suspicious verdict predicts phishing; an unknown one is left out and counted) and saves them to metrics.json; compare diffs the saved metrics of two runs so you can track accuracy over time.
run also records the run on the platform, with what it ran (the workflow's version, spot.yaml's commit, each analyzer's version and image, the datasets' SHA-256, the seed) and each analyzer's answers, never an email's content; the dashboard's Benchmarks page lists, shows and compares those runs. The account spot-cli signs in with must be an analyst's or an admin's to record; spot-cli benchmark run --no-store keeps a run on this machine only.
See Benchmarking for every option, the dataset list, the resume and rate-limiting behaviour of run, and what is stored.
Common environment variables¶
| Variable | Purpose | Default |
|---|---|---|
SPOT_API_URL | Base URL of the api-gateway | http://api-gateway:8000 |
SPOT_TOKEN_DIR | Where tokens are persisted | ~/.config/spot-cli/ |
SPOT_USERNAME | Skip the interactive Username prompt | unset |
SPOT_PASSWORD | Skip the interactive Password prompt | unset |
SPOT_DATASETS_DIR | Directory of raw benchmark datasets | ./datasets |
SPOT_BENCHMARK_DIR | Workspace for normalised data, runs, reports | ./benchmarks |
DOCKER_GID | The group of the Docker socket, which the wrapper gives plugin test | the group of /var/run/docker.sock |
Exit codes¶
| Code | Meaning |
|---|---|
| 0 | Command succeeded. |
| 1 | Command failed (auth error, validation error, gateway 4xx/5xx, etc.). |
| 2 | Bad invocation (missing argument, unknown command). Typer-driven. |
| 130 | plugin test or watch was ended with Ctrl-C. |
plugin test has its own, in Testing a plugin.
Troubleshooting¶
Cannot connect to http://api-gateway:8000— setSPOT_API_URLto the gateway URL reachable from your shell. The default only works inside the compose network.401 Unauthorizedon every command — the access token expired and the refresh token was rejected. Runspot-cli auth loginagain.- A file not found by
analyze submit,workflow createorupdate,routing applyorconfig validate— the wrapper shows the CLI the current directory only. Run it from the file's directory, or give the file on standard input. spot-cli benchmarkcannot find datasets — setSPOT_DATASETS_DIRto where you cloned the dataset repo.