Get Started
Reference

Agent CLI contracts

Stable JSON envelope, exit codes, and markdown packet format for thespider-agent findings, wait, progress, refine, campaigns cancel, models disable/enable, and the status, check-models and log list diagnostics

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:

ContractVersion markerWhere
JSON envelopeschemaVersion: 1every --json document
Exit codesthis pagefindings *, wait, progress, and refine
Markdown packetthespider_packet: 1the 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 is null; keys are never omitted. Array fields are [], never null. Any other change bumps schemaVersion.

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 bumping thespider_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

FlagTypeDefaultNotes
--jsonboolfalseEmit the envelope; the stdout purity rule applies
--status <CSV>stringnoneThe eight statuses plus open, closed, actionable
--severity <CSV>stringnonecritical, high, medium, low, info, plus reserved none
--openboolfalseSugar for --status open; conflicts with --status and --actionable
--actionableboolfalseSugar for --status actionable; conflicts with --status and --open
--linkedboolfalseOnly findings with a merge request, pull request or issue linked; conflicts with --unlinked
--unlinkedboolfalseOnly findings with nothing linked yet; conflicts with --linked
--campaign <ID>integernoneFindings sighted in this campaign (the id run and progress print); also selects the campaign's profile
--branch <NAME>stringnone
--bucket <CODE>stringnoneMatches the finding's displayed bucket (the bucket it was moved to, if it was moved)
--category <CODE>stringnoneAny supporting report tagged this category code. Not the finding's canonical category
--model <CODE>stringnoneFindings this model code reported (for example anthropic/claude-opus-5), at any effort
--basis <CSV>stringnoneEvidence basis: demonstrated, speculative (a hardening note), unclassified. --basis demonstrated,unclassified hides hardening notes; --basis speculative lists only them
--bucket-reviewboolfalseOnly findings whose bucket is flagged for review
--query <TEXT> / -qstringnone
--sort <MODE>stringprioritypriority, updated_desc, updated_asc, title
--profile <P>stringproject defaultsecurity, bugs, hygiene, pipeline, or all
--limit <N>integer100Above 500 is a usage error, never a silent clamp
--offset <N>integer0
--allboolfalseComplete 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:

FlagNotes
--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.

One or more <ID> positionals (one merge request usually addresses several findings), and exactly one of:

FlagNotes
--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.
--jsonEmit 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.

TokenMeaningUsed by
openNot fixed or false_positivedashboard count, findings list --open
actionableNot wont_fix, false_positive, accepted, or fixedwait exit 0/1, the PR gate's block set
closedfixed or false_positivefindings 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

CodeNamewaitfindings list/show/packetfindings copyfindings triage / classifyprogressMeaning
0OK✔✔✔✔✔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).
1FINDINGS✔————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.
3TIMEOUT✔————--timeout elapsed before the campaign became terminal.
4DEGRADED✔————Campaign terminal with failed > 0, skipped > 0 or status cancelled, and no actionable --fail-on match. Nothing may be called clean.
5USAGE✔✔✔✔✔Caller's input is wrong: client-side validation failure, or HTTP 400 / 404 / 409 / 413 / 422.
6OPERATIONAL✔✔✔✔✔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).
7INTERRUPTED✔————SIGINT/SIGTERM received while waiting.
8CLIPBOARD——✔——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.

Upgrade note: 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:

FieldTypeNull?
idintegerno
fingerprintstringno
canonical_titlestringno
canonical_severitystringyes
statusstringno
bucket_code, bucket_namestringyes
discovered_in_campaign_idintegeryes
campaign_namestringyes
primary_filestringyes
reportsintegerno
models, passes, profiles_seenstringno ([])
participantsobjectno ([])
first_seen_commit, last_seen_commit, fixed_at_commitstringyes
first_seen_branch, last_seen_branchstringyes
updated_atstringyes
linksobjectno ([])
web_urlstringno
severity_sourcestringyes
severity_reasonstringyes
severity_report_idintegeryes
severity_model_codestringyes
canonical_categorystringyes
category_sourcestringyes
bucket_reviewstringyes
suggested_categorystringyes
discovered_bucket_codestringyes
evidence_basisstringyes
evidence_basis_sourcestringyes
evidence_basis_reasonstringyes

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_severity is the highest severity any supporting report gave, normalized to critical, high, medium, low or info. severity_source says who decided it: reports, manual (set with findings classify or in the web UI) or legacy (a value from before this rule, or from a workspace import, kept until a report rates the finding at least as high). severity_reason is a human sentence, for example highest of 3 reports: high (report #17, openai/gpt-x); distribution high 1, medium 2 (spread 1). severity_report_id and severity_model_code name the report and model that won; the model code survives even if that report is later removed.
  • bucket_code / bucket_name are the bucket the finding is shown in. discovered_bucket_code is the bucket of the slice that found it; the two differ when the finding was moved. canonical_category is the category the finding is filed under, and category_source is reports, manual or legacy.
  • bucket_review is 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), otherwise null. suggested_category is the category to consider, or null.
  • evidence_basis is demonstrated, speculative (a hardening note: no concrete exploit or failure path was shown) or null (unclassified). evidence_basis_source is model, heuristic or manual, and is null exactly when the basis is. evidence_basis_reason explains 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 and campaign.open_findings show.
  • With --status actionable (or --actionable): pagination.total == summary.total == summary.open too, because actionable ⊂ open — but the number is smaller than the dashboard's, by exactly the count of accepted + wont_fix findings. That gap is the answer to "how many risks have we deliberately accepted?"
  • With --status closed: summary.open == 0.
  • With no --status: summary.total is everything and summary.open is 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[].

The four rollups are scoped differently here than on 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).

{
  "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):

FieldTypeMeaning
campaign.cancelledintegerSlices 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.cancelobject|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_labelstring|nullThe 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.

Branch on 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):

FieldTypeMeaning
campaign.cancelled, campaign.models[].cancelledintegerSlices 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.cancelobject|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_labelstring|nullThe 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:

CodeNamerefine meaning
0OKThe selected backlog (actionable ∩ filters) is empty.
1FINDINGSWork remains after --max-iterations. With --list: work remains now.
3TIMEOUTA retest wait exceeded --retest-timeout.
4DEGRADEDAny retest campaign terminated with failed > 0 or without a conclusion.
5USAGEBad 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).
6OPERATIONALEnvironment failure.
7INTERRUPTEDSIGINT/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:

FieldTypeMeaning
iterationsUsedintegerIterations actually run (0 under --list).
findingsSelectedintegerFindings selected by the filters for this run.
fixedNowintegerFindings a completed retest concluded fixed during the run.
stillOpenintegerSelected findings still actionable at exit.
campaignIdsintegerEvery retest campaign created ([] when none).
worktreestring|nullThe worktree path used, or null (--list, --in-place).
branchstring|nullThe original checkout branch recorded on campaigns — never the worktree branch.
degradedbooleanAny terminal retest campaign failed or reached no conclusion.
fixesWithoutCommitsobjectFix 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.
elapsedMsintegerWall-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.

FlagNotes
<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-runPreview only; nothing is written.
--yes, -yConsent without prompting. Off a terminal it is required.
--jsonEmit 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: …"
}
FieldMeaning
dry_runtrue with --dry-run. Nothing was written, and campaign.status is the status before the cancel.
changedThis invocation cancelled the campaign. false on a dry run and on "already cancelled".
already_cancelledThe campaign was cancelled before this call.
slicesThe 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_slicesWhat 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_billedSlice claims already billed for this campaign. They are not refunded.
billing_noteThe 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" }]
}
FieldMeaning
profiles, previously_enabled_profilesProfile codes in the server's order (alphabetical), passed through unchanged; the human output sorts them security, bugs, hygiene, pipeline.
dry_runtrue 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.
changedThe server's enablement changed (the model was enabled in at least one targeted profile).
already_disabledThe 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.totalpending + retryable + expired_lease. retryable is failed slices with attempts left; expired_lease is running slices whose lease had already run out.
skipped_slices.contendedSlices a concurrent claim or result held locked, not skipped. Run the command again.
in_flight_leasesSlices running on a live lease. Left to finish, never revoked, and not part of total.
modelcode and effort (null for the default effort). Added effort in 0.8.0.
other_enabled_participantsThe 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.actionremoved, 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.stateMeaningWhat fixes it
verifiedThe server accepted the stored credential.—
no_credentialNo 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.
rejectedThe 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).
unreachableNo 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_failedA 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 } } ]
  }
}
FieldMeaning
harnesses[].authauthenticated, 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_errorWhy 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[].probedThe agent is a built-in one, so its install and login are probed.
bindings[].command_sourcecustom when the block sets command (it runs as written), agent when the agent renders the command.
bindings[].variantThe 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[].effortThe 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_sourcerendered 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_ofWhen 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[].blocksThis binding is why the exit code is 1.
bindings[].agent_isolationThe 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_confignull 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_sourceMeaning
recordedmeta.json names the project. Every lease since agent 0.5.0.
slice_siblingThe 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.
nullNeither: 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.

#HeadingContents
1# {canonical_title}the finding title
2## Identityfinding 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## Locationprimary file, then a deduplicated (path, line)-sorted list of every citation
5## Reportsone row per report, oldest first
6## Evidenceper-report blocks, oldest report first
7## Exploit scenariosame
8## Impactsame
9## Recommendationsame
10## Bug detailsthe five bug fields; _none_ under the security profile
11## Sightingscampaign, profile, branch, commit, first/last seen
12## Triage historyfrom → to, decider, actor, notes, timestamp
13## Finding qualitya 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 detailsper-report usage evidence and removal risk, rendered like section 10; _none_ when no report has them
15## Pipeline detailsper-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 ### Details still 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 triage or classify call. 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 / actionable are sent verbatim. Membership is server-side.
  • categories, cwes, and files come from structured arrays, not string splitting.
One degraded case: against a server older than this release the three structured arrays do not exist, and the CLI reports [] 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 gate and wait are different tools.
  • Findings export — the server-side bulk contract.
Copyright © 2026