Agent CLI contracts
Machine contract for thespider-agent findings, wait, progress, refine, campaigns cancel, models disable / models enable, and the diagnostics status, check-models and log list: the JSON envelope, exit codes, and markdown packet. Scripts and coding agents should pin this page, not scrape the human tables.
What is stable
Three contracts ship together and are versioned independently:
| Contract | Version marker | Where |
|---|---|---|
| JSON envelope | schemaVersion: 1 | every --json document |
| Exit codes | this page | findings *, wait, progress, and refine |
| Markdown packet | thespider_packet: 1 | the first line of the packet front matter |
schemaVersion policy
Within a
schemaVersion, fields may only be added. No field is removed, renamed, or changes type. A value that is known to be absent isnull; keys are never omitted. Array fields are[], nevernull. Any other change bumpsschemaVersion.
Ids are opaque. Every id, *_id and *_ids value is a positive integer below 2^53 (a JSON number that fits
a 64-bit integer and a JavaScript Number), unordered, and carries no meaning beyond naming one object. The
JSON types and keys did not change, so schemaVersion stays 1: only the values did. Do not sort or compare
ids and do not parse meaning from them. Campaign lists are newest first, which is how you find the newest
campaign (previous_campaign_id names the one before). Findings lists follow sort (default priority:
severity priority, then most recently updated first), not creation order, so never infer order from finding ids.
Rows from before opaque ids keep their old values. The id of a history row (history,
classification_history, fixes, false_positives) is a per-response ordinal, 1 for the oldest entry, not a
stable key. See Identifiers.
The envelope is an allow-list the CLI builds, not a pass-through of the server body. Field names match findings export (snake_case).
thespider_packet policy
Within
thespider_packet: 1, headings may only be appended after the last one. Renaming, reordering, removing, or changing the nesting level of any heading requires bumpingthespider_packet. Prose inside a section may change freely; the heading set and its order may not.
stdout purity
With
--json, stdout carries exactly one JSON document terminated by a single newline, and nothing else. All human text, progress ticks, warnings, and errors go to stderr. A non-zero exit still emits one JSON document on stdout. Without--json, stdout carries only the command's primary output (the human table, or the markdown packet); diagnostics still go to stderr.
wait's per-poll progress line goes to stderr in both modes, as does findings copy's "copied"
notice. clipboard's OSC 52 escape sequence is also written to stderr, so a consumer parsing stderr
should expect it.
Commands
thespider-agent [--auth <PATH>] <COMMAND>
findings # alias: finding
list [flags]
show <ID> [flags]
triage <ID> --status <STATUS> [flags] # alias: set-status
classify <ID> [--severity L] [--category C] [--basis B] [--clear F]... [flags]
link <ID>... (--url <URL> | --mr <N>) [flags]
unlink <ID>... (--url <URL> | --mr <N>) [flags]
packet <ID> [flags]
copy <ID> [flags]
wait --campaign <ID> [flags]
progress [--campaign <ID>] [--profile security|bugs|hygiene|pipeline] [--json]
refine [flags] # see the refine section and /docs/guide/refine
campaigns # alias: campaign
cancel <ID> [--reason TEXT] [--dry-run] [--yes|-y] [--json]
status [--json] # diagnostics: see the Diagnostics section
check-models [--dir DIR] [--json]
log list [--slice N] [--limit N] [--all-servers] [--project SEL | --all-projects] [--json]
findings list
| Flag | Type | Default | Notes |
|---|---|---|---|
--json | bool | false | Emit the envelope; the stdout purity rule applies |
--status <CSV> | string | none | The eight statuses plus open, closed, actionable |
--severity <CSV> | string | none | critical, high, medium, low, info, plus reserved none |
--open | bool | false | Sugar for --status open; conflicts with --status and --actionable |
--actionable | bool | false | Sugar for --status actionable; conflicts with --status and --open |
--linked | bool | false | Only findings with a merge request, pull request or issue linked; conflicts with --unlinked |
--unlinked | bool | false | Only findings with nothing linked yet; conflicts with --linked |
--campaign <ID> | integer | none | Findings sighted in this campaign (the id run and progress print); also selects the campaign's profile |
--branch <NAME> | string | none | |
--bucket <CODE> | string | none | Matches the finding's displayed bucket (the bucket it was moved to, if it was moved) |
--category <CODE> | string | none | Any supporting report tagged this category code. Not the finding's canonical category |
--model <CODE> | string | none | Findings this model code reported (for example anthropic/claude-opus-5), at any effort |
--basis <CSV> | string | none | Evidence basis: demonstrated, speculative (a hardening note), unclassified. --basis demonstrated,unclassified hides hardening notes; --basis speculative lists only them |
--bucket-review | bool | false | Only findings whose bucket is flagged for review |
--query <TEXT> / -q | string | none | |
--sort <MODE> | string | priority | priority, updated_desc, updated_asc, title |
--profile <P> | string | project default | security, bugs, hygiene, pipeline, or all |
--limit <N> | integer | 100 | Above 500 is a usage error, never a silent clamp |
--offset <N> | integer | 0 | |
--all | bool | false | Complete filtered set via /findings/export; conflicts with --limit/--offset |
--basis accepts exactly those three tokens. hardening, confirmed, observation and
exploitable are refused by the argument parser (exit 2), because the same three words are used
everywhere: server, web UI, CLI and export.
--basis and --bucket-review need a server that knows them. An older server ignores an unknown
query parameter and answers with the unfiltered list. The CLI detects that, in page mode because
summary.speculative is missing and in --all mode because the export does not echo the filter, and
fails with exit 6 and error.codeserver_too_old instead of presenting the unfiltered rows as
the filtered set. Upgrade the server or drop the flag. --model is checked the same way: in page mode
the server's modelCounts must be present, and in --all mode the export must echo model.findings show / packet / copy
<ID> positional, --json, --profile. packet and copy additionally take --redact.
findings triage
<ID> positional and --status <STATUS> (required), plus --reason, --note, --commit
(repeatable), --decider, --json, --profile. Exactly one finding per invocation — callers loop.
findings classify
Sets a finding's severity, category or evidence basis by hand, or reverts one to the computed value.
<ID> positional plus at least one of:
| Flag | Notes |
|---|---|
--severity <LEVEL> | critical, high, medium, low or info. none is a filter token, not a value |
--category <CODE> | A category code of the finding's own methodology. The server moves the finding to the bucket that owns the code, clears any bucket review, and refuses a code that several buckets own (ambiguous_category) or that the methodology does not have (invalid_category, which lists valid codes) |
--basis <BASIS> | demonstrated or speculative. Sent as evidence_basis |
--clear <FIELD> | severity, category or basis; repeatable. Drops a manual override, or a legacy imported severity, and reverts the field to what the reports say. --clear basis returns to the computed basis (model-asserted or heuristic), which is unclassified only when nothing computes one |
plus --note <TEXT> (recorded on the history entry, up to 2000 characters), --decider <NAME>
(default thespider-agent@<host>, up to 200 characters), --json, --profile. Lengths are counted
as the server counts them, in UTF-16 code units, so a character outside the Basic Multilingual Plane
(most emoji) counts as two. --category is capped at 200 the same way.
A value set here is sticky: later reports no longer recompute that field. Severity is still never
lowered by a merge, so merging with a finding whose severity is higher keeps the higher value and
records the superseded override in classification_history. A write identical in value and source is
a no-op (changes: []), so retrying is safe; setting a value equal to the computed one still records
that a human confirmed it.
A token the flag does not accept (--severity none, --basis hardening, --clear evidence) is an
argument-parser error: exit 2, usage text on stderr, and no JSON document on stdout, even with
--json (see the error envelope).
Client-side usage errors, before any request (exit 5, with an envelope): empty_classification
(nothing to change), conflicting_classification (the same field set and cleared),
invalid_category (empty code), invalid_classification (over-long note, decider or category). A
merged finding is refused before the write with merged_finding_not_classifiable, naming the finding
to classify instead. Server refusals keep their own codes: 400 (invalid_category,
ambiguous_category, category_unavailable) and 409 finding_merged are exit 5; 403
(insufficient_role, read-only license) is exit 6. A server that predates the classification route
answers the write with a 404 it does not explain, after the pre-flight read found the finding: that
is exit 6 with server_too_old, and nothing was changed. Writes need the member role, as triage
does. The route itself is in the API reference.
findings link / unlink
One or more <ID> positionals (one merge request usually addresses several findings), and exactly one
of:
| Flag | Notes |
|---|---|
--url <URL> | The full address. Any http:// or https:// URL: GitLab, GitHub, Bitbucket, Azure DevOps, Gitea, Jira, Linear, or anything else. |
--mr <N> (alias --pr) | A merge request or pull request number. The address is built from the repository's git remote: origin if there is one, otherwise the first remote configured. The host decides the shape (/-/merge_requests/N on GitLab, /pull/N on GitHub, /pull-requests/N on Bitbucket, /pulls/N on Gitea). |
--remote <NAME> | With --mr/--pr: use this remote instead of origin or the first. |
--forge <F> | With --mr/--pr: github, gitlab, bitbucket or gitea, for a self-hosted host whose name does not say which it is. Azure DevOps needs --url. |
--json | Emit the envelope. |
Neither --url nor --mr is a usage error (exit 5), not clap's exit 2. Linking is idempotent:
linking a URL the finding already carries changes nothing and reports already_linked, so an agent can
retry freely. The server canonicalizes the URL first, so …/merge_requests/9, …/merge_requests/9/diffs
and …/merge_requests/9#note_4 are one link. unlink removes by the same canonical form, and a link
the finding does not carry is exit 5 (link_not_found), not a silent success.
With several ids the command still writes one at a time; each result is reported, and the exit code is
5 if every failure was a caller mistake (unknown finding, merged finding) or 6 if any was an
environment failure. A merged finding refuses links — link the finding it was merged into
(finding_merged names it).
wait
--campaign <ID> (required), --timeout <SECS> (default 1800; 0 means no timeout),
--interval <SECS> (default 15; the real delay adds 0–20 % jitter), --fail-on <CSV> (default
critical,high), --profile, --json.
--fail-on none and --fail-on off are different. none is the reserved severity token
meaning "no severity was assigned", so --fail-on critical,high,none blocks on unclassified
findings too. off — legal only as the entire value — disables the findings verdict, after which
wait can only exit 0 or 4.progress
--campaign <ID> (optional), --profile security|bugs|hygiene|pipeline, --json. Read-only. Without
--campaign it selects the newest campaign on the profile with outstanding work (pending, running,
or failed with attempts left), else the newest campaign on the profile; with --campaign, that
campaign's own profile wins (profileSource: "campaign"). An unknown campaign is
404 campaign_not_found, exit 5.
open vs actionable
Resolved on the server as "everything except these statuses," so an unknown legacy status still lands on the right side.
| Token | Meaning | Used by |
|---|---|---|
open | Not fixed or false_positive | dashboard count, findings list --open |
actionable | Not wont_fix, false_positive, accepted, or fixed | wait exit 0/1, the PR gate's block set |
closed | fixed or false_positive | findings list --status closed |
wait can exit 0 while findings list --open still returns rows. Those rows are accepted risks. Use actionable to decide whether to do work; use open to report state.thespider-agent findings list --actionable --severity critical,high --json | jq '.pagination.total' # work queue
thespider-agent findings list --open --severity critical,high --json | jq '.pagination.total' # dashboard number
Alias membership is defined server-side in exactly one place and the CLI forwards the token verbatim, so the two can never drift.
Exit codes
| Code | Name | wait | findings list/show/packet | findings copy | findings triage / classify | progress | Meaning |
|---|---|---|---|---|---|---|---|
0 | OK | ✔ | ✔ | ✔ | ✔ | ✔ | Success. For wait: campaign terminal, failed == 0, no actionable finding matches --fail-on. For progress: also when the profile has no campaign yet (campaign: null). |
1 | FINDINGS | ✔ | — | — | — | — | Findings verdict only. Campaign terminal and at least one finding sighted in it is actionable and matches --fail-on. |
2 | (reserved — never produced) | — | — | — | — | — | Emitted only by clap's own argument parser and by the legacy gate / update --check. The one exit code that never writes a JSON document — see the error envelope. |
3 | TIMEOUT | ✔ | — | — | — | — | --timeout elapsed before the campaign became terminal. |
4 | DEGRADED | ✔ | — | — | — | — | Campaign terminal with failed > 0, skipped > 0 or status cancelled, and no actionable --fail-on match. Nothing may be called clean. |
5 | USAGE | ✔ | ✔ | ✔ | ✔ | ✔ | Caller's input is wrong: client-side validation failure, or HTTP 400 / 404 / 409 / 413 / 422. |
6 | OPERATIONAL | ✔ | ✔ | ✔ | ✔ | ✔ | Environment failure: HTTP 401 / 402 / 403 / 429 / 5xx, exhausted retries, unparseable response, missing .thespider/config.toml, unresolvable org or project, a server too old for the command (server_too_old). |
7 | INTERRUPTED | ✔ | — | — | — | — | SIGINT/SIGTERM received while waiting. |
8 | CLIPBOARD | — | — | ✔ | — | — | Packet rendered successfully but the clipboard write failed. The packet is on stdout (human mode) or in packet (JSON mode). |
wait evaluates its verdict in this order:
interrupted -> 7
timeout -> 3
matches > 0 -> 1 (even when failed > 0; `degraded: true` is still set)
failed > 0 or skipped > 0 or campaign cancelled -> 4
otherwise -> 0
A cancelled campaign is terminal the moment it is cancelled, from its status and not from its
slice counts, so wait returns on the first poll that sees it (even with slices still counted
running, which the server settles shortly after) and does not exit 0: exit 4, or 1 when an
actionable finding matches --fail-on. The same holds for a 0.6.0 agent against a current server,
because the server counts cancelled slices in skipped. The one exception is a narrow race where a
cancel commits having moved zero slices, described in the
API reference. The campaign.skipped and campaign.cancelled
notes in the wait envelope below cover the counters.
A findings verdict deliberately outranks degradation: an agent scripted on "fix the findings first"
must not skip real criticals because one slice failed. Degradation always stays visible through
degraded, campaign.failed, campaign.skipped, and a stderr warning.
--fail-on counts hardening notes (evidence_basis: "speculative") like any other finding, the same
way the PR gate does. Triage them accepted or wont_fix to stop them blocking.
wait --fail-on can start failing after a server upgrade. Older servers stored a
finding's severity exactly as the first model wrote it, so a "High" never matched --fail-on critical,high and never counted in severe_open. The server now normalizes severities to the five
lowercase levels (and takes the highest any report gave), so those findings start to count. A CI job
that passed the day before the upgrade can fail the day after on the same findings. That is the
correct result; triage the findings or adjust --fail-on.wait exit 0 does not mean "no open findings" — it means "nothing actionable". A campaign
whose only critical finding has been triaged accepted exits 0 while
findings list --open --severity critical --json still reports it. Scripts that want the dashboard
number must ask for it explicitly with --status open.wait is not gate
Do not mix their exit codes in one script. gate 2 means "poll again." wait uses 3 for timeout and never emits 2 (clap already uses 2 for bad flags). gate 1 is any new finding; wait 1 respects --fail-on. A triage of accepted or wont_fix quiets both. See PR gate.
JSON envelope
Every --json document starts with the same envelope:
{
"schemaVersion": 1,
"command": "findings.list",
"generatedAt": "2026-07-28T14:03:11Z",
"agentVersion": "0.3.0",
"ok": true,
"exitCode": 0,
"server": "https://thespider.xyz",
"project": { "id": 42, "slug": "payments-api", "selector": "acme/payments-api" },
"profile": "security",
"profileSource": "project-default"
}
command is one of findings.list, findings.show, findings.triage, findings.classify,
findings.link, findings.unlink, findings.packet, findings.copy, wait, progress, campaigns.cancel, models.disable, models.enable, and the diagnostics status, check-models and log.list (see Diagnostics). profileSource is flag, campaign, project-config, project-default,
or fallback. generatedAt is UTC RFC 3339 at second precision.
findings.list
{
"filters": { "status": "open", "severity": "critical,high", "campaignId": null,
"branch": null, "bucket": null, "category": null, "model": null, "q": null, "sort": null,
"since": null, "has_link": null, "basis": null, "bucket_review": null },
"pagination": { "limit": 100, "offset": 0, "returned": 100, "total": 240,
"truncated": true, "mode": "page" },
"summary": { "total": 240, "open": 180, "severe_open": 12, "needs_retest": 3, "linked": 41,
"speculative": 9, "bucket_review": 2 },
"findings": [ { "id": 501, "…": "…" } ]
}
filters.status echoes the expanded token ("open" / "actionable"), never the flag name.
filters.since echoes the --since selector exactly as sent (a campaign id as a string, or the ISO
date/timestamp) — a set that was cut off at a point in time is meaningless without saying which.
filters.basis echoes the normalized --basis set (for example "demonstrated,unclassified") and
filters.bucket_review is true with --bucket-review, otherwise null. summary.speculative
counts the hardening notes in the filtered set and summary.bucket_review the findings whose bucket
is flagged; both are null against a server that predates them. truncated is offset + returned < total — the CLI never silently paginates. In --all mode
pagination.limit is null, mode is "all", and truncated is false.
Each finding record always carries every one of these keys:
| Field | Type | Null? |
|---|---|---|
id | integer | no |
fingerprint | string | no |
canonical_title | string | no |
canonical_severity | string | yes |
status | string | no |
bucket_code, bucket_name | string | yes |
discovered_in_campaign_id | integer | yes |
campaign_name | string | yes |
primary_file | string | yes |
reports | integer | no |
models, passes, profiles_seen | string | no ([]) |
participants | object | no ([]) |
first_seen_commit, last_seen_commit, fixed_at_commit | string | yes |
first_seen_branch, last_seen_branch | string | yes |
updated_at | string | yes |
links | object | no ([]) |
web_url | string | no |
severity_source | string | yes |
severity_reason | string | yes |
severity_report_id | integer | yes |
severity_model_code | string | yes |
canonical_category | string | yes |
category_source | string | yes |
bucket_review | string | yes |
suggested_category | string | yes |
discovered_bucket_code | string | yes |
evidence_basis | string | yes |
evidence_basis_source | string | yes |
evidence_basis_reason | string | yes |
The last twelve are the finding's classification and where it came from. Against a server that
predates them every one is null, which means "unknown", not "none":
canonical_severityis the highest severity any supporting report gave, normalized tocritical,high,medium,loworinfo.severity_sourcesays who decided it:reports,manual(set withfindings classifyor in the web UI) orlegacy(a value from before this rule, or from a workspace import, kept until a report rates the finding at least as high).severity_reasonis a human sentence, for examplehighest of 3 reports: high (report #17, openai/gpt-x); distribution high 1, medium 2 (spread 1).severity_report_idandseverity_model_codename the report and model that won; the model code survives even if that report is later removed.bucket_code/bucket_nameare the bucket the finding is shown in.discovered_bucket_codeis the bucket of the slice that found it; the two differ when the finding was moved.canonical_categoryis the category the finding is filed under, andcategory_sourceisreports,manualorlegacy.bucket_reviewis a reason string when the bucket needs a human look (the reports' categories point at several buckets, or the text suggests, for example, a secrets exposure in a build image), otherwisenull.suggested_categoryis the category to consider, ornull.evidence_basisisdemonstrated,speculative(a hardening note: no concrete exploit or failure path was shown) ornull(unclassified).evidence_basis_sourceismodel,heuristicormanual, and isnullexactly when the basis is.evidence_basis_reasonexplains it.
Values are model- or legacy-derived text where noted (severity_reason, bucket_review,
evidence_basis_reason can quote report text). JSON carries them verbatim; render them as text.
web_url opens the finding in the web app under the profile the command read it with
(…/findings?profile=hygiene&finding=501). With --profile all it carries no profile, and the web
app opens the finding in a profile that has reports for it.
links[] holds the merge requests, pull requests and issues linked to the finding, oldest first:
{ "id": 7, "url": "https://gitlab.com/acme/api/-/merge_requests/128",
"provider": "gitlab", "kind": "merge_request", "ref": "!128", "host": "gitlab.com",
"actor_user_id": null, "created_at": "2026-10-03T10:00:00Z" }
provider is one of github, gitlab, bitbucket, azure_devops, gitea, jira, linear, other;
kind is merge_request, pull_request, issue or other; ref is the short reference (!128,
#57, PROJ-9) or null. All three are derived by the server from the URL — nothing about a
link is taken on trust from the caller — so a URL on an unrecognised self-hosted domain is other
unless its path gives it away (/-/merge_requests/N is always GitLab). Only the URL is stored: an
improved classifier relabels existing links with no migration. summary.linked counts the findings
in the filtered set that carry at least one link.
How pagination.total and summary.* relate
Both are computed server-side over the same filtered set — the WHERE clause you asked for.
pagination.total is summary.total. summary.open and summary.severe_open additionally
apply the summary predicate status NOT IN ('fixed','false_positive'), which is exactly the open
predicate. Consequences, all true by construction:
- With
--status open(or--open):pagination.total == summary.total == summary.open, and all three equal the number the web dashboard andcampaign.open_findingsshow. - With
--status actionable(or--actionable):pagination.total == summary.total == summary.opentoo, becauseactionable ⊂ open— but the number is smaller than the dashboard's, by exactly the count ofaccepted+wont_fixfindings. That gap is the answer to "how many risks have we deliberately accepted?" - With
--status closed:summary.open == 0. - With no
--status:summary.totalis everything andsummary.openis its open subset.
In --all mode the export endpoint carries no summary block, so the CLI computes summary.open,
summary.severe_open, summary.needs_retest, summary.linked, summary.speculative and
summary.bucket_review from the exported records themselves (the last two are null only when the
server predates those fields). They are
numbers, including 0 — a --all export is the complete filtered set, so the counts are
knowable and a null there would wrongly read as "unqueried". summary.total is the export's own
total. Two more --all caveats: reports, models, passes, and primary_file are derived
from the export's nested reports[] (count; distinct sorted model_code; distinct sorted
pass_code; lexicographically lowest cited path), and profiles_seen is derived from report
provenance rather than from sightings. participants is derived the same way, from each report's
model_code and effort.
participants lists the distinct (code, effort) pairs that reported the finding, as
[{ "code": "anthropic/claude-opus-5-5", "effort": "xhigh" }], sorted by code with the default
effort (null) first. models keeps listing codes only. A server that predates effort
participants sends no pairs; every report it holds is at the default effort, so findings list
reports each of its models codes with effort: null, the same pairs findings show and --all
derive from the reports. Added in 0.8.0.
findings.show
finding carries the list record plus merged_into_finding_id, fixed_at_branch, notes, and
created_at. Then reports[], sightings[], history[], fixes[], false_positives[],
links[] (the same records as finding.links), and classification_history[].
findings list. On show, reports,
models, passes, and profiles_seen are derived from the reports[] and sightings[] this
response carries, and those are filtered to the resolved profile. On list the same-named columns
count the whole finding regardless of profile. So for a finding sighted under several profiles,
findings list --profile security may report "reports": 4, "models": ["anthropic/claude-opus-5","anthropic/claude-sonnet-5"] while
findings show --profile security reports "reports": 2, "models": ["anthropic/claude-sonnet-5"] for the same id.
Pass --profile all to make the two agree.One element of reports[]:
{
"id": 702, "ordinal": 1, "title": "…", "severity": "high",
"confidence": null, "cvss_v4_score": null, "cvss_v4_vector": null,
"evidence": "…", "exploit_scenario": null, "impact": "…", "recommendation": "…",
"model_code": "anthropic/claude-sonnet-5", "effort": null, "model_name": "Claude Sonnet 5",
"pass_code": "http", "pass_name": "HTTP surface",
"bucket_code": "core", "profile": "security",
"slice_id": 9, "slice_status": "completed",
"categories": ["authz"],
"cwes": ["CWE-89"],
"files": [ { "path": "src/db.ts", "line": 42 } ],
"reproduction_steps": null, "expected_behavior": null,
"actual_behavior": null, "root_cause": null, "test_suggestion": null,
"usage_evidence": null, "removal_risk": null, "cost_driver": null, "verification_plan": null,
"normalized_severity": "high",
"evidence_basis": "demonstrated", "evidence_basis_source": "model",
"recommendation_issues": [
{ "code": "sqlite3_positional_bind",
"message": "The sqlite3 command-line shell does not bind extra arguments to SQL placeholders: …",
"excerpt": "sqlite3 \"$DB\" \"INSERT INTO t VALUES (?);\" \"$VALUE\"" }
]
}
categories, cwes, and files come from the server's structured arrays and are copied
verbatim — see What the CLI never does. The five bug fields are
emitted always (null under the security profile) so keys are never omitted. So are the four
profile fields usage_evidence, removal_risk (null unless the report's profile is hygiene) and
cost_driver, verification_plan (null unless the report's profile is pipeline), which also read
null against a server that predates them.
severity is the report's raw value, exactly as the model wrote it (reports are never rewritten);
normalized_severity is that value mapped onto the five levels, or null when it is not a
recognized severity. evidence_basis / evidence_basis_source are this report's own vote
(model when the model asserted it, heuristic when the server inferred speculative from explicit
wording such as "no known vulnerability" in a low or info security report, null otherwise).
recommendation_issues are recommendation warnings: the server checks each report's
recommendation when you read it (nothing is stored) and flags advice that would not work as written.
Today the only check is sqlite3_positional_bind: a sqlite3 command line that passes values as extra
arguments for ? placeholders, which the sqlite3 shell does not bind. excerpt is the flagged
command (model text, at most 200 characters). The array is [] when there is nothing to flag and
against an older server.
classification_history[] — how severity, bucket, category and evidence basis changed, newest
first:
{ "id": 91, "finding_id": 26, "field": "category",
"from_value": "input-validation", "to_value": "secrets-exposure",
"from_source": "reports", "to_source": "manual", "cause": "manual",
"report_id": null, "model_code": null, "merged_from_finding_id": null,
"actor_user_id": 4, "decider": "alice@acme.example", "notes": "the .db file ships in the image",
"created_at": "2026-10-13T10:00:00Z" }
field is severity, bucket, category or evidence_basis; cause is ingest, merge, repair,
manual or clear. id and finding_id are integers; report_id, merged_from_finding_id and
actor_user_id are int|null (actor_user_id is null for a token-authenticated write); the rest
are string|null. A row is written only when a field's value or source changed.
The other four arrays are answers to four different questions. Each is [] when empty, never
null, and every key below is always present.
sightings[] — where and when this finding has been seen. One row per campaign that sighted it.
{ "campaign_id": 81, "profile": "security", "branch": "main",
"commit": "9f2c1ab", "first_seen_at": "2026-07-21T09:12:04Z", "last_seen_at": "2026-07-28T14:00:51Z" }
campaign_id is an integer; profile, branch, commit, first_seen_at, and last_seen_at are
string|null. profiles_seen on the finding record is the distinct sorted profile set of this
array.
history[] — the triage timeline, newest first. This is what the web UI's history panel shows,
and what findings triage appends to.
{ "id": 3311, "from_status": "new", "to_status": "accepted",
"decider": "thespider-agent@build-01", "actor_user_id": null,
"notes": "risk accepted for the 2026-Q3 release", "created_at": "2026-07-28T14:00:51Z" }
id is an integer, actor_user_id is int|null (null for a CLI-driven change), and
from_status, to_status, decider, notes, created_at are string|null. decider is the
attribution string — history[0].decider is how you check who last moved a finding.
fixes[] — remediation records linked to this finding.
{ "id": 44, "status": "proposed", "description": "parameterized the query",
"pr_url": null, "ticket_url": null,
"commits": ["9f2c1ab", "c4d5e6f"], "resolved_at": "2026-07-27T18:22:10Z" }
id is an integer, commits is a string[] ([] when none), and status, description,
pr_url, ticket_url, resolved_at are string|null.
pr_url is deprecated and always null. A pull request or merge request belongs to the finding,
not to one fix, so it now lives in links[]. The key stays because
schemaVersion: 1 never omits a key. Existing values were carried over to links.false_positives[] — dismissals, each with the commit it was recorded against.
{ "id": 12, "reason": "test fixture, not reachable in production",
"decider": "alice@acme.example", "applies_until_commit": "9f2c1ab",
"created_at": "2026-07-24T11:03:00Z" }
id is an integer; reason, decider, applies_until_commit, and created_at are
string|null. applies_until_commit is the commit the dismissal was recorded through — the web UI
renders it as "Applies through <commit>".
A merged finding is reported, never silently redirected: merged_into_finding_id names the
canonical id, and findings triage refuses to write against a tombstone.
findings.triage
{ "change": { "finding_id": 501, "from_status": "new", "to_status": "triaged",
"decider": "thespider-agent@build-01", "actor_user_id": null,
"reason": null, "notes": null, "commits": [] } }
decider is never null: it defaults to thespider-agent@<host> (or thespider-agent when no
hostname is available) and is overridable with --decider. It is what the web UI history timeline
attributes the change to.
findings.classify
{ "change": {
"finding_id": 26,
"changes": [ { "field": "category", "from_value": "input-validation", "to_value": "secrets-exposure",
"from_source": "reports", "to_source": "manual" },
{ "field": "bucket", "from_value": "injection-validation", "to_value": "secrets-config",
"from_source": "reports", "to_source": "manual" } ],
"finding": { "canonical_severity": "low", "severity_source": "reports", "severity_reason": "…",
"bucket_code": "secrets-config", "canonical_category": "secrets-exposure",
"category_source": "manual", "bucket_review": null, "suggested_category": null,
"evidence_basis": null, "evidence_basis_source": null, "evidence_basis_reason": null } } }
changes[] lists every field whose value or source changed, and is [] for a write identical to the
current state. finding is the classification after the write; its eleven keys are always present.
Human mode prints one line per change, for example
finding #26: category input-validation (reports) -> secrets-exposure (manual).
findings.link and findings.unlink
{
"url": "https://gitlab.com/acme/api/-/merge_requests/128",
"results": [
{ "finding_id": 501, "status": "linked", "link": { "id": 7, "url": "…", "provider": "gitlab",
"kind": "merge_request", "ref": "!128", "host": "gitlab.com", "actor_user_id": null,
"created_at": "2026-10-03T10:00:00Z" }, "error": null },
{ "finding_id": 502, "status": "already_linked", "link": { "…": "…" }, "error": null },
{ "finding_id": 503, "status": "failed", "link": null,
"error": { "kind": "usage", "code": "finding_merged", "httpStatus": 409, "message": "…" } }
]
}
url is the address that was sent (for --mr, the one built from the git remote). results[] has one
entry per distinct id, in the order given. status is linked, already_linked, unlinked, or
failed; link is the canonical link record (null on failure) and error is null on success.
ok is false and exitCode is non-zero when any result failed. With exactly one id, a failure
is the ordinary error envelope instead, so existing error handling applies.
findings.packet and findings.copy
packet (the full markdown, as a string) plus a finding object with id, fingerprint,
canonical_severity, status, merged_into_finding_id, and web_url. findings.copy adds
copied (bool) and bytes (int). packet is present even when copied is false, so a JSON
consumer always has the payload.
copied means a clipboard method accepted the document, which is not always a verifiable fact.
A local clipboard tool (pbcopy / wl-copy / xclip / xsel) exits non-zero on failure, so its
success is checked. The fallback — an OSC 52 escape sequence written to the terminal — has no reply
of any kind, so all the CLI ever learns is that the bytes were written; the terminal may have
ignored them. When OSC 52 is the only method that claimed the copy, human mode also prints the
packet to stdout and says so on stderr, so you are never left with neither a clipboard nor a
document. copied stays a plain bool in schemaVersion: 1; --json consumers should read
packet, which is always there.
wait
{
"campaign": { "id": 81, "status": "completed", "profile": "security",
"slice_count": 12, "pending": 0, "stalled": 0, "running": 0, "completed": 11,
"failed": 1, "retryable": 0 },
"degraded": true,
"reason": "terminal",
"waited_seconds": 312,
"polls": 21,
"fail_on": ["critical", "high"],
"verdict": {
"status_filter": "actionable",
"matched": 3,
"truncated": false,
"findings": [
{ "id": 501, "canonical_severity": "critical", "canonical_title": "SQL injection in the payments reader", "status": "new", "primary_file": "src/db.ts" },
{ "id": 517, "canonical_severity": "high", "canonical_title": "Missing authz check on /admin/export", "status": "new", "primary_file": "src/routes/admin.ts" },
{ "id": 522, "canonical_severity": "high", "canonical_title": "Reflected XSS in the search page", "status": "needs_retest", "primary_file": "web/app/search.vue" }
]
},
"summary": { "total": 3, "open": 3, "severe_open": 3, "needs_retest": 0, "linked": 0,
"speculative": 0, "bucket_review": 0 }
}
campaign.retryable is the subset of campaign.failed that a claim will still be offered, and it is
part of what "terminal" means: wait returns only when pending, running and retryable are all
zero, which is the same "still outstanding" set the server uses for campaign completion, the CI gate
and the daemon's claimable poll — so wait can never report a campaign finished while workers are
still claiming into it. A failed slice whose attempts are spent counts in failed but not in
retryable: it is terminal and degraded (exit 4), never clean. A server older than this field
omits it and it reads 0.
campaign.stalled is the subset of campaign.pending whose lease expired with attempts left.
It stays outstanding and is excluded from campaign.running. It is claimable only when its
participant is enabled. Do not add it to
pending again. An older server omits it and a current client reads zero. Human output names the
expired leases and a bounded command to resume the campaign.
campaign.skipped counts slices taken out of the campaign because their model was disabled
(thespider-agent models disable). They are not outstanding, so they never keep wait waiting, but
that coverage was never audited: a terminal campaign with skipped > 0 is degraded (exit 4) and
degraded is true. Added within schemaVersion: 1; a server older than this field omits it and it
reads 0.
Cancelled campaigns. When a member cancels the campaign,
campaign.status is "cancelled", degraded is true, reason is "terminal", and these keys are
added to campaign (still schemaVersion: 1: fields may only be added, and every key is present):
| Field | Type | Meaning |
|---|---|---|
campaign.cancelled | integer | Slices that were cancelled. A subset of campaign.skipped: the server folds cancelled slices into skipped so that agents that predate this key still exit 4. skipped - cancelled is the slices skipped because their model was disabled. 0 on a campaign that is not cancelled (and against a server that predates it). |
campaign.cancel | object|null | { "cancelled_at", "cancel_reason", "cancelled_by": { "kind": "user"|"token"|null, "id", "label" } }; null unless cancelled. |
campaign.cancelled_at, campaign.cancel_reason, campaign.cancelled_by_label | string|null | The same three facts, flat. null unless cancelled; cancel_reason is also null when no reason was given. |
"campaign": { "id": 18, "status": "cancelled", "profile": "security", "slice_count": 6,
"pending": 0, "running": 0, "completed": 1, "failed": 0, "retryable": 0,
"skipped": 5, "cancelled": 5,
"cancel": { "cancelled_at": "2026-10-14T12:00:00Z", "cancel_reason": "broken runner env",
"cancelled_by": { "kind": "user", "id": 2, "label": "Colin" } },
"cancelled_at": "2026-10-14T12:00:00Z", "cancel_reason": "broken runner env",
"cancelled_by_label": "Colin" }
cancel_reason and cancelled_by_label are text a person typed or a name they chose. The JSON
carries them verbatim; render them as text. The human output prints them with terminal control
characters removed.
reason is terminal, timeout, or interrupted. verdict.status_filter is "actionable"
whenever the verdict query ran — the one predicate schemaVersion: 1 uses — and null when it did
not, so the envelope states which predicate produced matched rather than leaving a consumer to
assume it is the dashboard's open count. fail_on is [] when the verdict was disabled with
--fail-on off.
reason before trusting any counter. When the loop stops before the campaign is
terminal — a timeout or an interrupt, possibly before the very first poll returned — nothing was
counted, so every campaign.* field except id is null, and verdict.* and summary.* are
null as described below. degraded stays a plain false on that path: it means "nothing is
known to have failed", not "nothing failed". A null means unqueried; a 0 means counted and
genuinely zero.verdict.findings carries up to 20 projected rows in the full findings.list record shape
documented above — each element in the example is abbreviated, but the real document carries every
key of that table. verdict.matched is the complete count of matching findings, and
verdict.truncated is true exactly when matched exceeds the 20-row cap, i.e. when
verdict.findings does not list everything matched counted. The array is never null; it is []
when nothing matched.
The verdict query only runs on a terminal campaign, and unobtained numbers are null, not 0.
When reason is timeout or interrupted, or when --fail-on off disabled the verdict, no
verdict query was ever issued — so verdict.status_filter, verdict.matched, verdict.truncated
and every field of summary are null, and verdict.findings is []. summary carries the same
seven keys on every path (total, open, severe_open, needs_retest, linked, speculative,
bucket_review); only their values change. A zero in those fields
always means "counted, and it was zero". Branch on reason (or the exit code) before you read them.
progress
{
"selected_by": "newest_outstanding",
"campaign": {
"id": 24, "type": "full", "status": "running", "profile": "security",
"target_branch": "main", "target_commit": "abc1234def…",
"slice_count": 24, "pending": 7, "stalled": 0, "running": 0, "leased": 0, "completed": 17,
"failed": 0, "retryable": 0, "exhausted": 0, "skipped": 0, "cancelled": 0,
"cancel": null, "cancelled_at": null, "cancel_reason": null, "cancelled_by_label": null,
"claimable": 7, "invalid": 0,
"models_reported": true,
"models": [
{ "code": "openai/gpt-5.6-sol", "effort": null, "enabled": true, "here": "runnable",
"slices": 8, "pending": 7, "stalled": 0, "running": 0, "leased": 0, "completed": 1,
"failed": 0, "retryable": 0, "exhausted": 0, "skipped": 0, "cancelled": 0,
"claimable": 7, "invalid": 0 },
{ "code": "xai/grok-4.6", "effort": null, "enabled": true, "here": "not bound",
"slices": 8, "pending": 0, "stalled": 0, "running": 0, "leased": 0, "completed": 8,
"failed": 0, "retryable": 0, "exhausted": 0, "skipped": 0, "cancelled": 0,
"claimable": 0, "invalid": 0 }
]
}
}
campaign.status is cancelled for a cancelled campaign. progress carries the same add-only
cancel keys as wait, on campaign and, for cancelled, on every campaign.models[] row
(still schemaVersion: 1; every key is always present):
| Field | Type | Meaning |
|---|---|---|
campaign.cancelled, campaign.models[].cancelled | integer | Slices a cancel took out. A subset of skipped at the same level: skipped is the server's legacy fold (model-disabled + cancelled), so skipped - cancelled is the slices skipped because their model was disabled. 0 when the campaign is not cancelled, and against a server that predates cancel. |
campaign.cancel | object|null | { "cancelled_at", "cancel_reason", "cancelled_by": { "kind": "user"|"token"|null, "id", "label" } }; null unless cancelled. |
campaign.cancelled_at, campaign.cancel_reason, campaign.cancelled_by_label | string|null | The same three facts, flat. null unless cancelled; cancel_reason is also null when no reason was given. |
pending, running, retryable and failed never include cancelled slices. A cancelled campaign
has no outstanding work, so newest_outstanding never selects it. In the human table, skipped is the
same folded count, and a cancelled column appears only when a cancel took slices out.
campaign.models[] has one row per participant: a model planned at two efforts gives two rows with
the same code, each with its own effort (null for the default effort) and counts. The rows come
from the server's participants[]; a server that predates it sends only models[], which the client
reads instead (there every row is at the default effort). here is
judged for that exact pair; a binding at another effort of the same model does not make it
runnable. The human table names the row code@effort.
selected_by is explicit (--campaign), newest_outstanding, or newest (no campaign on the
profile has outstanding work). When the profile has no campaign at all, selected_by and
campaign are both null and the exit code is 0.
Every count is the server's own, from GET /v1/projects/:id/campaigns/:id/progress; the campaign
totals are the sums of the per-model rows. claimable is exactly what a claim would hand out now: a
model explicitly disabled for the profile (enabled: false) is never claimable. leased counts
slices held by a live lease; an expired lease is never leased — with attempts left and its participant enabled it is claimable,
on its last attempt it is failed — so claimable and leased never overlap. running excludes
expired leases. With attempts left, an expired lease is stalled and included in pending.
stalled is an additive subset of pending, so outstanding work remains
pending + running + retryable; never add stalled again. failed includes slices out of attempts.
Stalled work of a disabled participant remains pending but is not claimable. Re-enable the
participant to run it, or repeat the disable operation to skip its remaining slices.
The server publishes stalled on campaign lists, campaign progress and each participant/model row.
Older servers omit it; a current client reads that as zero. The slices API publishes stalled rows
as status: "pending" with stalled: true and the lease_expires_at timestamp. Human wait and
progress output distinguishes enabled work that can resume from disabled participants that need
re-enabling or skipping. Neither
the gate nor wait treats stalled work as completed coverage.
The claim_exclude capability allows a runner to send exclude_slice_ids (at most 10,000 public
ids) before it claims another batch. The claim's additive excluded_claimable count distinguishes
work already attempted by this invocation from a campaign with nothing claimable. A runner never
truncates its exclusion set or drops a returned lease. It stops before another claim at the limit;
against an older server without claim_exclude, it runs one batch and checks progress. A confirmed
empty selection exits 0 without resume advice. Selected work that remains claimable, or unknown
eligibility, produces continuation guidance and exit 4; a slice failure still takes precedence
with exit 1.
Three keys are derived by the client, not the server: exhausted (failed - retryable: failed
slices nothing will claim again until repair), here (whether this checkout can run the model, by
the same checks run applies: runnable, bound (command missing), bound (disabled), or
not bound), and models_reported. Keys are never omitted. On a server older than these fields,
leased and a model's enabled are null (unknown, not zero), and a server that sends no per-model
split at all gives models_reported: false and models: [] — an empty array with
models_reported: true is a campaign with no slices. claimable and invalid arrived with the
per-model split, so with models_reported: false they are null as well, never a fabricated 0.
refine
command is refine. Flag semantics live on Refine. Same exit-code numbers as wait, different predicates:
| Code | Name | refine meaning |
|---|---|---|
0 | OK | The selected backlog (actionable ∩ filters) is empty. |
1 | FINDINGS | Work remains after --max-iterations. With --list: work remains now. |
3 | TIMEOUT | A retest wait exceeded --retest-timeout. |
4 | DEGRADED | Any retest campaign terminated with failed > 0 or without a conclusion. |
5 | USAGE | Bad input — including 409 commit_mismatch (both SHAs named), unbound/unenabled models, a model bound at several efforts without --effort (error.code model_ambiguous), an --effort no binding carries (model_not_bound), and a fix or retest binding with an effort (or OpenCode variant) against a server without effort participants (effort_unsupported, checked before enablement). |
6 | OPERATIONAL | Environment failure. |
7 | INTERRUPTED | SIGINT/SIGTERM; the worktree is kept and resume hints are printed. |
Precedence when several conditions apply: 7 > 3 > 4 > 1 > 0.
The payload extends the shared envelope fields with:
| Field | Type | Meaning |
|---|---|---|
iterationsUsed | integer | Iterations actually run (0 under --list). |
findingsSelected | integer | Findings selected by the filters for this run. |
fixedNow | integer | Findings a completed retest concluded fixed during the run. |
stillOpen | integer | Selected findings still actionable at exit. |
campaignIds | integer | Every retest campaign created ([] when none). |
worktree | string|null | The worktree path used, or null (--list, --in-place). |
branch | string|null | The original checkout branch recorded on campaigns — never the worktree branch. |
degraded | boolean | Any terminal retest campaign failed or reached no conclusion. |
fixesWithoutCommits | object | Fix runs that committed nothing, each { findingId, modelOutput }. modelOutput is the fix model's own explanation (for example, that it could not confirm a removal is safe), sanitized for the terminal. [] when every fix committed. |
elapsedMs | integer | Wall-clock duration of the run. |
campaigns cancel
thespider-agent campaigns cancel <ID> [--reason TEXT] [--dry-run] [--yes] [--json] (alias
campaign) ends a new or running campaign for good. It follows the models disable pattern: check
the server's campaign_cancel capability, read GET .../cancel-preview, show it, confirm, then
POST .../cancel. Server routes and behaviour are in the
API reference; what it means for users is on
Campaigns.
| Flag | Notes |
|---|---|
<ID> | Required positive integer; 0 or text is an argument-parser error (exit 2). |
--reason <TEXT> | Recorded with who and when. At most 500 characters, counted in UTF-16 units as the server counts them, so an emoji is two (checked before any request). |
--dry-run | Preview only; nothing is written. |
--yes, -y | Consent without prompting. Off a terminal it is required. |
--json | Emit the envelope. Human narration goes to stderr, so stdout is exactly one document. |
Exit codes for this command: 0 ok, including a dry run and "already cancelled" (idempotent, the
original actor, time and reason are kept, nothing is written); 5 usage (unknown campaign, a
completed campaign or one with nothing left to cancel, both error.code campaign_terminal (the
message says "nothing left to cancel" when no slice is unfinished), a reason over 500 characters,
confirmation needed without --yes or declined); 6 operational (a server that does not advertise
campaign_cancel, in which case nothing is sent, not even the preview; a role below member; 401,
402, 429, 5xx; or error.code outcome_unknown when the cancel request was sent once and its answer was lost, so it may or may not have been applied: the request is never retried, because it cannot be replayed safely, so rerun with --dry-run to see whether the campaign is now cancelled). Exit 1 is never produced by this command.
command is campaigns.cancel. The shared keys are as above; profile is the campaign's own profile and
profileSource is campaign. The payload, with every key always present:
{
"schemaVersion": 1,
"command": "campaigns.cancel",
"ok": true,
"exitCode": 0,
"action": "cancel",
"campaign": { "id": 18, "name": "full-…", "status": "cancelled",
"cancelled_at": "2026-10-14T12:00:00Z", "cancel_reason": "broken runner env",
"cancelled_by": { "kind": "user", "id": 2, "label": "Colin" } },
"dry_run": false,
"changed": true,
"already_cancelled": false,
"cancellable": true,
"slices": { "total": 6, "to_cancel": 5, "pending": 1, "running": 4, "retryable": 0,
"completed": 1, "skipped": 0, "failed": 0, "cancelled": 0 },
"live_leases": [ { "lease_id": 90, "slice_id": 213, "model": "openai/gpt-6.1-sol", "effort": null,
"holder": "colin@build-01", "age_seconds": 1680 } ],
"cancelled_slices": 5,
"superseded_leases": 4,
"contended_slices": 0,
"claims_billed": 5,
"billing_note": "Cancelling does not refund anything: …"
}
| Field | Meaning |
|---|---|
dry_run | true with --dry-run. Nothing was written, and campaign.status is the status before the cancel. |
changed | This invocation cancelled the campaign. false on a dry run and on "already cancelled". |
already_cancelled | The campaign was cancelled before this call. |
slices | The preview, taken before the write: what a cancel would act on. slices.skipped is the model-disabled count only (not folded with cancelled), unlike wait's campaign.skipped. |
cancelled_slices, superseded_leases, contended_slices | What the write did. null on a dry run and when nothing was written. contended_slices are slices a concurrent claim or result held locked; the server settles them itself and they never become claimable. |
claims_billed | Slice claims already billed for this campaign. They are not refunded. |
billing_note | The server's plain statement of the billing rule. |
models disable and models enable
thespider-agent models disable <code> [--effort LEVEL] [--profile P] [--project SEL] [--dry-run] [--yes] [--keep-local] [--json]
and models enable <code> [--effort LEVEL] [--profile P] [--project SEL] [--json]. The shared keys (schemaVersion,
command, generatedAt, agentVersion, ok, exitCode, server, project) are as above;
command is models.disable or models.enable, profile is the profile flag or all, and
profileSource is flag or all-profiles.
Exit codes for these two commands: 0 ok (including "already disabled"), 1 the server does not
know the model code (error.kind not_found, error.code model_not_found), 5 usage (a bad
argument, or confirmation needed without --yes or declined), 6 operational (including a role
below admin: error.code insufficient_role, message requires role: admin). Exit 1 here means
"not found", not a findings verdict.
--effort LEVEL targets that effort participant of the model, and its absence targets the default
effort. An invalid effort is exit 5 (error.code invalid_effort) before any request. A server
without the model_effort capability is exit 6 (error.code server_too_old) before any request
that writes. An answer for another pair (a server that ignored effort) is exit 6
(error.code effort_mismatch), and nothing more is written after it. The value is trimmed, as
setup and refine trim theirs.
Without --effort, disable acts on the default-effort pair only. When the server reports the
code's other efforts, the human output names the pair code (default effort) and lists the efforts
still enabled with the flag that disables each (still enabled at: xhigh; add --effort xhigh to disable it).
{
"schemaVersion": 1,
"command": "models.disable",
"ok": true,
"exitCode": 0,
"profile": "all",
"profileSource": "all-profiles",
"action": "disable",
"model": { "code": "xai/grok-4.6", "effort": null },
"dry_run": false,
"changed": true,
"already_disabled": false,
"profiles": ["bugs", "hygiene", "pipeline", "security"],
"previously_enabled_profiles": ["bugs", "hygiene", "pipeline", "security"],
"skipped_slices": {
"pending": 6, "retryable": 0, "expired_lease": 0, "total": 6, "contended": 0,
"campaigns": [{ "campaign_id": 15, "name": "full-…", "status": "running", "skipped": 6 }]
},
"in_flight_leases": 0,
"local_config": { "action": "removed", "path": "/repo/.thespider/config.toml", "reason": null },
"other_enabled_participants": [{ "code": "xai/grok-4.6", "effort": "high" }]
}
| Field | Meaning |
|---|---|
profiles, previously_enabled_profiles | Profile codes in the server's order (alphabetical), passed through unchanged; the human output sorts them security, bugs, hygiene, pipeline. |
dry_run | true when --dry-run was given. Nothing was written; skipped_slices is what would be skipped and local_config.action is would-remove rather than removed. |
changed | The server's enablement changed (the model was enabled in at least one targeted profile). |
already_disabled | The model was already off in every targeted profile. The command still skips anything stranded behind it, so skipped_slices.total can be non-zero. |
skipped_slices.total | pending + retryable + expired_lease. retryable is failed slices with attempts left; expired_lease is running slices whose lease had already run out. |
skipped_slices.contended | Slices a concurrent claim or result held locked, not skipped. Run the command again. |
in_flight_leases | Slices running on a live lease. Left to finish, never revoked, and not part of total. |
model | code and effort (null for the default effort). Added effort in 0.8.0. |
other_enabled_participants | The same code's OTHER efforts still enabled in a targeted profile after the write (for --dry-run or a no-op, now), as [{ "code", "effort" }] (effort null for the default). [] when there are none; null against a server that predates the key. Added in 0.8.0. |
local_config.action | removed, would-remove, not-bound (the file has no block for that code at that effort), kept (--keep-local, or --profile), or not-applicable (--project named a different project than this checkout's). Only the block of the targeted effort is removed. path and reason are always present, null when absent. |
models enable adds action: "enable", model, profiles, previously_enabled_profiles, and
bound_locally (true / false for a binding of that code at that effort in this checkout,
null with --project).
It never edits the config file.
Human narration goes to stderr under --json, so stdout is still exactly one document.
Diagnostics: status, check-models and log list
thespider-agent status --json, check-models --json and log list --json carry the same ten
envelope keys. Their context can be partly unknown (check-models never calls the server, and
log list --all-projects has no project), so server, profile and profileSource may be null,
project is null when no project selector is known, and inside project, id and slug stay
null until a server has resolved them. For these three commands ok is exitCode == 0:
they have no findings verdict.
status
Exit codes: 0 the credential was verified and every read succeeded; 1 anything else (any
unverified auth.state, or a read that failed after the credential was accepted, reported in
error). An unreadable or malformed auth store, or an unparseable project config, is the
error envelope with exit 6.
{
"schemaVersion": 1, "command": "status", "ok": false, "exitCode": 1,
"server": "https://thespider.xyz",
"project": { "id": null, "slug": null, "selector": "acme/payments-api" },
"profile": "security", "profileSource": "project-config",
"auth_store": { "path": "/home/dev/.local/share/thespider/auth.toml", "concurrency": 1,
"default_server": "https://thespider.xyz",
"known_servers": [{ "server": "https://thespider.xyz", "token_source": "OS keychain (service thespider-agent)" }] },
"project_config": { "path": "/repo/.thespider/config.toml", "selector": "acme/payments-api",
"server": "https://thespider.xyz", "profile": "security", "bindings": 1,
"agent_isolation": [ { "code": "xai/grok-4.6", "effort": null,
"summary": "requested on; `check-models` shows what the installed CLI supports" } ],
"warnings": [], "notes": [] },
"auth": { "state": "unreachable", "verified": false, "server": "https://thespider.xyz",
"token_source": "OS keychain (service thespider-agent)", "http_status": null,
"error_code": null, "transport": "dns", "token_pin": "stored",
"message": "request to GET /v1/agent/me failed after retries: … Dns Failed: …" },
"org": null, "visible_projects": [], "readiness": null, "models": [],
"harnesses": [ { "agent": "opencode", "executable": "opencode", "available": true, "auth": "unknown",
"availability_probe": "command -v opencode", "auth_probe": "opencode auth list",
"probe_error": { "message": "Error: Unexpected error — Unknown: FileSystem.open (/home/dev/.local/share/opencode/log/opencode.log)",
"class": "filesystem",
"path": "/home/dev/.local/share/opencode/log/opencode.log" } } ],
"error": null
}
auth.state | Meaning | What fixes it |
|---|---|---|
verified | The server accepted the stored credential. | — |
no_credential | No usable credential could be read locally: nothing stored, an unset env: variable, an unreadable token file, or an OS keychain that could not be read (see message). No request was made. | thespider-agent login --server <url>; for a pinned token (token_pin env or literal) set the variable or edit auth.toml, because login cannot replace it. |
rejected | The server refused the credential: 401 (invalid, expired or revoked), or a 403 that refuses the account, org or grant itself (account_banned, org_deleted, invalid_audience, …). http_status and error_code say which. | Log in again with an active account (or replace a pinned token). |
unreachable | No HTTP response: transport is dns, connect, tls, timeout, proxy or io. The credential was never checked. | Network, DNS, proxy, VPN or TLS interception. Logging in again does not help. |
verification_failed | A credential is present and was not refused, but could not be confirmed: a 5xx after retries (http_status), an unparseable response, a response that is not HTTP (transport: "protocol"), an unusable server URL (transport: "invalid_url"), or a valid credential that lacks access to the project (a 403 with not_a_member, insufficient_role, insufficient_scope, identity_required or client_token_required). | Retry; check server health and that the project's org matches this login. |
project_config.agent_isolation lists what each binding requests (on/off and its source, or none for a command), without probing any CLI; check-models --json has the level for the installed CLI.
auth is null only when there is no project config (there is no server to verify against).
auth.token_pin is how auth.toml holds this server's token: stored (keychain or token file,
written by login), env, or literal. auth.message is secret-masked.
org, visible_projects and readiness are filled only once the credential is verified. models[]
lists each participant's standing, one row per (code, effort) pair: code, state (ready,
enabled_unbound, command_unavailable, bound_disabled, or effort_unsupported: bound at a
non-default effort against a server without effort participants, which run skips), agent (the
binding's agent name, also when a custom command runs and the agent is only probed; null for a
bare freeform binding and for an enabled pair nothing here binds), model_id (null when unset,
when a custom command runs instead, and for an unbound pair), effort (the pair's effort, null
for the default effort) and
effort_source (rendered, declared, or null, as on check-models below; null for an enabled
pair nothing here binds), and
command_source (agent, custom, or null for an unbound code). A command template is never
included, because templates routinely inline an API key. harnesses[] is the same row
check-models --json emits, below; status probes them whatever the server said.
check-models
Exit codes are the text report's: 0, or 1 when a binding to a built-in agent is blocked (the
agent is missing or known-logged-out, or an OpenCode model id is not in OpenCode's catalog). An
unparseable project config is the error envelope with exit 6. Still offline: no server call.
{
"schemaVersion": 1, "command": "check-models", "ok": true, "exitCode": 0,
"server": "https://thespider.xyz",
"project": { "id": null, "slug": null, "selector": "acme/payments-api" },
"profile": null, "profileSource": null,
"search_dir": "/repo",
"harnesses": [ { "agent": "grok", "executable": "grok", "available": true, "auth": "unsupported",
"availability_probe": "command -v grok", "auth_probe": "grok login status",
"probe_error": null } ],
"project_config": {
"path": "/repo/.thespider/config.toml", "warnings": [],
"notes": ["note: [[model]] `xai/grok-4.6` sets both `agent` and `command`; `command` is what runs and agent `grok` is used only for probes"],
"bindings": [ { "code": "xai/grok-4.6", "agent": "grok", "probed": true, "command_source": "custom",
"model_id": null, "variant": null, "effort": null, "effort_source": null,
"command_available": true,
"model_unresolved": null, "alias_of": null, "blocks": false,
"agent_isolation": { "requested": null, "source": null, "level": "none", "agent": "grok",
"harness_version": null, "flavor": null,
"gaps": ["command runs as written"],
"remedy": "remove `command` to let agent `grok` render an isolated command",
"error": null } } ]
}
}
| Field | Meaning |
|---|---|
harnesses[].auth | authenticated, unauthenticated, unsupported (the CLI has no auth-status command), unknown (the probe could not be classified — never blocking), or null when the CLI is not installed. |
harnesses[].probe_error | Why a probe could not be classified, from the CLI's own output: message (headline plus the line naming a path or failure class, ANSI-stripped, secret-masked, at most 240 characters), class (permission_denied, read_only_filesystem, disk_full, not_found, filesystem, timeout, network, or null), and path (the filesystem path involved, or null). null when nothing failed. |
bindings[].probed | The agent is a built-in one, so its install and login are probed. |
bindings[].command_source | custom when the block sets command (it runs as written), agent when the agent renders the command. |
bindings[].variant | The raw OpenCode variant key as written, null when unset. Reported beside a custom command too, where it names the participant but is not added to the command (the config load warns about that). |
bindings[].effort | The participant effort the binding claims under (effort, or OpenCode's variant). null when none is set. Added in 0.8.0; since effort participants it is reported beside a custom command too. |
bindings[].effort_source | rendered when the agent's own flag carries the effort, declared when a custom command runs as written (the effort is the label it claims under; TheSpider does not check that the command runs at it), null when no effort is set. |
bindings[].alias_of | When code is a lab's own models.dev alias, the model models.dev currently says it points at (deepseek/deepseek-v4.1-flash for deepseek/deepseek-flash). Read from the cached catalog, with no network call. null for a model of its own, or when no catalog is cached. Always present. Advisory only: reports are recorded under code. |
bindings[].blocks | This binding is why the exit code is 1. |
bindings[].agent_isolation | The agent isolation an audit of this binding requests from the installed CLI. Always present. level is full, partial (gaps lists what still loads), off (agent_isolation = "off" or THESPIDER_AGENT_ISOLATION), none (a command runs as written; remedy says how to get isolation), or unknown (the CLI is not installed or its probe failed; error says why). requested is on, off or null (for none), source is default, config, THESPIDER_AGENT_ISOLATION or null. harness_version is the first line of the CLI's --version. flavor is opencode-v1, opencode-v2, claude-safe-mode, claude-fallback, claude (off), codex, grok, or null. Every level is what TheSpider requested, never a verified fact. Never blocks the exit code on its own; a model only a user plugin provides shows up in model_unresolved. |
project_config | null when no .thespider/config.toml was found above search_dir. |
log.list
Exit codes: 0 rows listed, 1 no lease logs, 6 the inventory is incomplete (incomplete: true;
the rows that could be read are still in entries) or the log root is unreadable (error envelope).
{
"schemaVersion": 1, "command": "log.list", "ok": true, "exitCode": 0,
"server": "https://thespider.xyz",
"project": { "id": null, "slug": null, "selector": "acme/payments-api" },
"profile": null, "profileSource": null,
"logs_root": "/home/dev/.local/share/thespider/logs",
"incomplete": false,
"entries": [ { "server_id": "3f2a…", "server": "https://thespider.xyz", "slice_id": 88, "lease_id": 12,
"model_code": "xai/grok-4.6", "effort": null, "pass_code": "p1", "bucket_code": "b1",
"client_version": "0.4.0", "started_at": "2026-09-01T10:00:00Z",
"project": "acme/payments-api", "project_source": "slice_sibling",
"project_source_lease_id": 31,
"state": "finished", "pid": 4242, "outcome": "completed", "exit_code": 0,
"timed_out": false, "disposition": "accepted", "parsed_report_count": 3,
"duration_ms": 61000, "bytes": 18234, "redacted": false,
"dir": "/home/dev/.local/share/thespider/logs/3f2a…/88-12" } ],
"attribution": { "recorded": 0, "slice_sibling": 1, "not_recorded": 0,
"not_recorded_client_versions": [] }
}
server is null with --all-servers; project is the project filter, null with
--all-projects. state is running, interrupted (no terminal record and the writer PID is
gone), running_unverified, finished, or terminal_unreadable; the result fields are null
unless it is finished. An empty store is the normal "no runs yet" case: entries: [], exit 1, ok: false, nothing on stderr.
project_source | Meaning |
|---|---|
recorded | meta.json names the project. Every lease since agent 0.5.0. |
slice_sibling | The record predates meta.project, but another attempt of the same slice (same server, slice id and prompt hash) recorded one (project_source_lease_id). A slice belongs to exactly one project; the prompt hash keeps a slice id reused after a database reset behind the same URL from matching. |
null | Neither: the record was written by an agent older than 0.5.0 (client_version), its meta.json has no project field, and no attempt of that slice recorded one. No agent version has ever recorded a campaign id, so only the server could map the slice to a project, and log never calls the server. |
Error envelope
Emitted on every non-zero terminating path the command itself reaches — including transport and
auth failure (an unreadable or malformed auth store is an operational envelope with exit 6,
not a bare stderr line).
Exit 2 is the one exception. Argument parsing happens in clap, before the command body runs:
an unknown flag, a missing value, or a value your --severity / --status / --fail-on list
rejects (or that findings classify --severity / --basis / --clear rejects) makes clap print usage text to stderr and exit 2 with zero bytes on stdout. There
is no JSON document to parse. Branch on 2 before you read stdout:
thespider-agent findings list --severity bogus --json > out.json; rc=$?
case "$rc" in
0) jq '.pagination.total' out.json ;;
2) echo "bad arguments (usage text is on stderr; out.json is empty)" >&2; exit 2 ;;
*) jq -r '.error.message' out.json >&2; exit "$rc" ;;
esac
{
"schemaVersion": 1,
"command": "wait",
"generatedAt": "2026-07-28T14:03:11Z",
"agentVersion": "0.3.0",
"ok": false,
"exitCode": 6,
"error": { "kind": "operational", "code": "invalid_token",
"httpStatus": 401, "message": "HTTP 401: invalid_token" }
}
error.kind is usage or operational. error.code is the server's error code, or one of the
CLI-originated codes auth_store_unreadable, missing_project_config, invalid_limit,
reason_required_for_false_positive, commit_requires_fixed_status,
merged_finding_not_triageable, merged_finding_not_classifiable, empty_classification,
conflicting_classification, invalid_category, invalid_classification, server_too_old,
export_too_large, interrupted, timeout, or null.
server_too_old (exit 6) means the server predates what the command needs: a filter flag reached a
server that silently ignored it (see findings list), or findings classify reached
a server without the classification route (see findings classify).
auth_store_unreadable is the code behind the auth-failure guarantee above: an auth.toml that is
missing, unreadable, or malformed produces this envelope with exitCode: 6, rather than the bare
exit 1 an unhandled error would give.
project, profile, and the payload keys are omitted on the error envelope when they could not
be resolved. This is the single documented exception to "keys are never omitted": the error envelope
guarantees only schemaVersion, command, generatedAt, agentVersion, ok, exitCode, and
error.
Worked examples
# The AC1 one-liner: open high-severity findings as JSON, no web UI.
thespider-agent findings list --open --severity critical,high --json | jq '.pagination.total'
# The agent work queue, with ids.
thespider-agent findings list --actionable --severity critical,high --json | jq -r '.findings[].id'
# The same queue without hardening notes, and the hardening notes on their own.
thespider-agent findings list --actionable --basis demonstrated,unclassified --json | jq -r '.findings[].id'
thespider-agent findings list --actionable --basis speculative --json | jq '.summary.speculative'
# Findings whose bucket needs a human look, with the reason and the suggestion.
thespider-agent findings list --bucket-review --json | jq -r '.findings[] | "\(.id) \(.bucket_review) -> \(.suggested_category)"'
# A human agreed with the suggestion: file finding 26 under secrets-exposure, then undo it.
thespider-agent findings classify 26 --category secrets-exposure --note "the .db file ships in the image"
thespider-agent findings classify 26 --clear category
# Did we see everything, or is the page truncated?
thespider-agent findings list --json | jq '.pagination | {returned, total, truncated}'
# Block on a campaign, then branch on the documented exit code.
thespider-agent wait --campaign "$CAMPAIGN_ID" --json > wait.json; rc=$?
case "$rc" in
0) echo "clean" ;;
1) jq -r '.verdict.findings[].canonical_title' wait.json ;;
2) echo "bad arguments; usage text is on stderr and wait.json is empty" >&2; exit 2 ;;
3) echo "timed out; still running" ;;
4) echo "degraded: $(jq '.campaign.failed' wait.json) slice(s) failed" ;;
*) jq -r '.error.message' wait.json >&2; exit "$rc" ;;
esac
Markdown packet
findings packet <ID> writes a version-marked markdown document to stdout; findings copy <ID>
puts the same document on the system clipboard. It is rendered client-side from the finding detail
endpoint — there is no packet endpoint — and the renderer version is pinned to the binary version.
Front matter
---
thespider_packet: 1
finding_id: 501
fingerprint: "sha256:…"
project: "acme/payments-api"
project_id: 42
profile: "security"
status: "new"
severity: "high"
merged_into_finding_id: null
redacted: false
generated_by: "thespider-agent/0.3.0"
generated_at: "2026-07-28T14:03:11Z"
server: "https://thespider.xyz"
web_url: "https://thespider.xyz/projects/42/findings?profile=security&finding=501"
---
Keys are fixed, always present, in this order. Values are emitted as JSON scalars so model-authored text cannot break the block.
Ordered heading list
Every heading is emitted unconditionally and in this exact order. A section with no content
renders _none_, so a consumer can rely on the heading set rather than on presence.
| # | Heading | Contents |
|---|---|---|
| 1 | # {canonical_title} | the finding title |
| 2 | ## Identity | finding id, fingerprint, project, profile, bucket, status, severity, first/last seen commit and branch, report count, web_url |
| 3 | ## Merge status | _not merged_, or the canonical id to triage instead |
| 4 | ## Location | primary file, then a deduplicated (path, line)-sorted list of every citation |
| 5 | ## Reports | one row per report, oldest first |
| 6 | ## Evidence | per-report blocks, oldest report first |
| 7 | ## Exploit scenario | same |
| 8 | ## Impact | same |
| 9 | ## Recommendation | same |
| 10 | ## Bug details | the five bug fields; _none_ under the security profile |
| 11 | ## Sightings | campaign, profile, branch, commit, first/last seen |
| 12 | ## Triage history | from → to, decider, actor, notes, timestamp |
| 13 | ## Finding quality | a table of severity, severity source, severity reason, bucket, discovered bucket, category, category source, bucket review, suggested category, evidence basis, basis source and basis reason; then Recommendation warnings: one ### model · pass · report N block per report that has any, one line per warning (code, message, excerpt), or _none_ |
| 14 | ## Hygiene details | per-report usage evidence and removal risk, rendered like section 10; _none_ when no report has them |
| 15 | ## Pipeline details | per-report cost driver and verification plan, rendered like section 10; _none_ when no report has them |
Headings 13–15 were appended within thespider_packet: 1, in that order; headings 1–12 and the
front matter are unchanged. The quality table shows the raw values (speculative, heuristic, …) so an agent can pass
them straight back to findings classify; a cell is — when the value is absent, which is every cell
against a server that predates these fields.
Reports appear oldest first — the order the server returns — and are never re-sorted
client-side. A file with line: null renders as a bare path; a file with a line renders
path:line, so a real line 0 is never confused with "no line".
Model-authored text is treated as hostile. Inside a free-text block, three kinds of line are backslash-escaped, because each of them can change the heading set a markdown parser sees:
- a level-1 or level-2 ATX heading (
# …,## …) — it would forge a section boundary. Level 3 and below are left intact, so a model's### Detailsstill renders as a subheading; - a fenced-code opener (three or more backticks or tildes) — the packet emits no fences of its own, so a single unbalanced one would leave a fence open for the rest of the document and every later heading would parse as literal code text;
- a setext underline (a line of only
=or only-) — it would promote the line above it to an H1/H2 that is not in the list, with no#anywhere.
File citations — including the primary file: line — are rendered inside a code span wide enough to
survive a backtick in the path; a path containing a newline is emitted as a JSON string literal, so
one citation always stays on one line.
A recommendation warning's excerpt is model text too, so it is rendered like a file citation: one code span on one line, with a newline or control character made visible rather than emitted.
Table cells and the H1 title get one more treatment: the packet is written straight to your
terminal, so every control character in them is rendered as its \u{…} escape rather than passed
through. A finding title carrying \x1b]52;c;… or \r\x1b[2K cannot retitle your window, silently
overwrite your clipboard, or erase and fabricate a line of output.
Redaction
--redact is off by default. The evidence for the highest-severity finding class — a hardcoded
credential — is the secret, so masking by default would destroy the packet's purpose, and the data
already lives on the server. With --redact the assembled document is passed through the same
secret-masking rules run --redact uses and the front matter records redacted: true. The packet
is never truncated.
What the CLI never does
- No local findings cache. Every call is a fresh server read.
- No writes into
.thespider/config.toml. - One finding per
triageorclassifycall. Loop if you have many. - No client-side classification. Severity, bucket, evidence basis and recommendation warnings are computed by the server; the CLI only reports them.
open/closed/actionableare sent verbatim. Membership is server-side.categories,cwes, andfilescome from structured arrays, not string splitting.
[] for categories, cwes, and files rather than failing. Upgrade
the server to get report provenance in findings show and the packet.The finding-quality fields degrade the same way: an older server yields null for the twelve
classification keys, [] for recommendation_issues and classification_history, and null for
summary.speculative / summary.bucket_review. Two things fail outright with server_too_old
(exit 6): the two filter flags, because an ignored filter would return the wrong set, and
findings classify, because the server has no route to write to (nothing is changed).Next steps
- The agent — every command and flag.
- Triage — what each status means for the dashboard and the gate.
- PR gate — why
gateandwaitare different tools. - Findings export — the server-side bulk contract.