API reference
JSON over HTTPS. Most /v1 routes accept a browser session or a bearer token and stay inside your org and project. Some management routes need a browser session or a minimum role.
The server parses model output itself. The client sends raw stdout and echoes the prompt_hash it was given.
Endpoints
Health
GET /healthz → { status, mode, version, db, feedback_available }
This route is public.
Agent skills (public)
Unauthenticated Agent Skills catalog. npx skills add <origin> discovers
/.well-known/agent-skills/index.json (v0.2.0, relative artifact URLs, sha256: digests) then
fetches each SKILL.md. Served in both SaaS and on-prem; the origin is the server the user pointed
npx skills at.
GET /.well-known/agent-skills/index.json
GET /.well-known/agent-skills/{name}/SKILL.md
Shipped skill: thespider (guided by default; autonomous when the user clearly asks to run it).
Source files live in bundled/skills/ in this repository.
Agent downloads (public)
Unauthenticated first-party proxy of prebuilt thespider-agent binaries from a private release
store. Used by the install docs, startup.md, and thespider-agent update.
GET /downloads/agent/latest.json
GET /downloads/agent/v{semver}/manifest.json
GET /downloads/agent/v{semver}/SHA256SUMS
GET /downloads/agent/v{semver}/thespider-agent-{platform}
GET /downloads/agent/latest/thespider-agent-{platform}
GET /downloads/agent/latest/SHA256SUMS
Platform IDs: linux-x86_64, linux-aarch64, darwin-x86_64, and darwin-arm64. Optional
query ?source=install|update|docs is recorded for analytics only. Binary responses include
X-Checksum-SHA256. Rate-limited per IP (429 rate_limited). Returns 404 when the channel is
disabled (AGENT_DOWNLOADS_PUBLIC=0) or the object is missing.
Device authorization (CLI setup/login)
Interactive agent login. The CLI starts a session, opens /activate?code=XXXX-XXXX, and waits
(SSE or poll) for the signed-in user to authorize the CLI as themselves. Approve issues an
agent identity token (user-linked, not org-scoped). Setup chooses the org/project after approval.
The browser never receives client_token — only the CLI does, once,
via device_code.
POST /v1/device/start
{ client_name?, project? }
→ { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }
GET /v1/device/wait?device_code=… # SSE: event pending|approved|denied|expired
POST /v1/device/token # poll fallback
{ device_code }
→ { client_token, client_id, org_id: null, user_id, kind: "agent_identity" }
| { error: authorization_pending|… }
GET /v1/device/pending?code=XXXX-XXXX # any signed-in session (no org required)
POST /v1/device/approve # any signed-in session
{ user_code }
POST /v1/device/deny # any signed-in session
{ user_code }
Agent identity (multi-org discovery + mint)
After device login, the identity token may call:
GET /v1/agent/me
→ { kind: "agent_identity", user_id, token_id, user_status }
GET /v1/agent/memberships
→ { memberships: [{ org_id, slug, name, role, org_status }] }
GET /v1/agent/orgs/{orgIdOrSlug}/projects
→ { org, projects[] } # membership required
POST /v1/agent/client-token
{ org_id | org_slug, project_id? }
→ { client_token, client_id, org_id, project_id, kind: "client" } # member+; rate-limited
POST /v1/agent/bootstrap
→ { org, project, profiles, models, readiness_url } # zero-membership identity only; idempotent
Org-scoped client tokens cannot list memberships (403 identity_required). Identity tokens
cannot claim/results or call GET /v1/me (403 client_token_required).
Enroll (headless / CI)
POST /v1/enroll
{ enrollment_code }
→ { client_token, client_id, org_id }
Enrollment codes are minted from the signed-in activation page (manual/CI path). They are single-use and expiring, and issue org/project-scoped client tokens (not multi-org identity). Prefer device authorization for interactive machines.
Identity & catalog
GET /v1/me → { org, projects[], models[], entitlements } # client token or session
GET /v1/models → [{ code, display_name, provider }] # NO command_template — ever
GET /v1/me is org-scoped (the token's or session's active org). It does not list all of a
user's memberships — use /v1/agent/memberships with an identity token for multi-org discovery.
The model catalog has no command_template; machine-specific commands live in the client's project
config.toml.
Organization and membership
GET /v1/org
POST /v1/org # SaaS browser session only
POST /v1/org/invitations # admin+
POST /v1/invitations/accept # browser session only
PATCH /v1/org/members/{userId} # admin+
DELETE /v1/org/members/{userId} # admin+
GET /v1/orgs/usage
Roles are viewer, member, admin, and owner. Owner-specific safeguards prevent removing or demoting the final owner.
Browser organization creation
POST /v1/org
Cookie: better-auth.session_token=...
{ name, slug }
→ { id, name, slug, plan, role: "owner" }
A signed-in Cloud user can create multiple organizations. Each session-only operation creates an
organization and owner membership atomically and returns 409 if the slug is taken. Invited users
can join existing organizations and create organizations of their own.
Dashboard feedback
POST /v1/feedback
Cookie: better-auth.session_token=...
{ type: feature|bug|enhancement|question, title, description, page? }
-> { sent: true }
This browser-session-only endpoint emails the feedback and authenticated account identity to FEEDBACK_RECIPIENT. It returns 503 feedback_unavailable unless both the Postmark mailer and recipient are configured, 429 rate_limited when the per-user submission budget is exhausted, and 502 feedback_send_failed when delivery fails; feedback is not persisted in the database. GET /healthz exposes a boolean feedback_available capability so clients can hide the form when it is disabled.
Projects, profiles, configuration, and enrollment codes
POST /v1/projects
{ name, default_profile?, slug?, repo_remote_url?, default_branch? }
PATCH /v1/projects/{id}
GET /v1/projects/{id}/profiles
GET /v1/projects/{id}/profiles/{security|bugs}/config-version
GET /v1/projects/{id}/profiles/{security|bugs}/config?version=N
POST /v1/projects/{id}/profiles/{security|bugs}/config-versions
GET /v1/projects/{id}/readiness?profile=security|bugs&config_version=N
PATCH /v1/projects/{id}/models
GET /v1/projects/{id}/enrollment-codes
POST /v1/projects/{id}/enrollment-codes
DELETE /v1/projects/{id}/enrollment-codes/{codeId}
Project creation, metadata changes, config-version creation, model enablement, and enrollment-code
management require admin or owner. Every project contains Security and Bugs profiles.
default_profile is security unless supplied. Unqualified legacy config routes resolve to that
default and include the resolved profile in their response; new clients use the canonical routes.
Readiness returns the selected profile/config, bucket/pass/model/planned-slice counts, enabled models,
ready, blockers, blockers_detail, and activity. Blocker codes are profile_not_found
(returned as a 404 error, not a blocker entry), config_missing, no_buckets, no_passes,
no_enabled_models, and empty_matrix.
blockers is the stable code list. blockers_detail repeats it in the same order as
{ code, message, remedy } so a terminal, the dashboard, and these docs show identical wording;
treat the codes as the contract and the strings as display text. activity is
{ last_claim_at, last_result_at } for the selected profile — both null until an agent claims.
A project created through POST /v1/projects is campaign-ready on the first response: both profiles
are seeded with the bundled starter methodology and the starter model (xai/grok-4.6) is enabled, so readiness
answers ready: true with planned_slices > 0 and no configuration step is required first.
Project reads and triage
GET /v1/projects/{id}/dashboard/risk
GET /v1/projects/{id}/dashboard/models
GET /v1/projects/{id}/campaigns
GET /v1/projects/{id}/campaigns/claimable
GET /v1/projects/{id}/config
GET /v1/projects/{id}/findings
GET /v1/projects/{id}/findings/{findingId}
GET /v1/projects/{id}/slices
GET /v1/projects/{id}/findings/export
POST /v1/projects/{id}/findings/{findingId}/status # member+
Profile-aware reads accept profile=security|bugs|all. Omission resolves to the project default,
except a selected campaign supplies its pinned profile. A contradictory campaign/profile pair is
rejected. all is explicit, never the default. Finding export includes selected-profile metadata and
only matching report provenance while retaining project-global canonical status.
Findings filters
GET /v1/projects/{id}/findings and GET /v1/projects/{id}/findings/export share one filter
implementation, so everything below applies identically to both.
status and severity accept one or more values, comma-separated. Values are trimmed, empties
are dropped, and duplicates are de-duplicated. A single value behaves exactly as it always has.
GET /v1/projects/{id}/findings?status=new,triaged
GET /v1/projects/{id}/findings?severity=critical,high
status also accepts three aliases. They mean "everything except these statuses," so an unknown
legacy status still lands on the right side. Mix aliases and literals (status=actionable,accepted);
the result is their union.
| Token | Meaning |
|---|---|
open | Everything except fixed and false_positive |
actionable | Everything except wont_fix, false_positive, accepted, and fixed (what the PR gate still blocks on) |
closed | Exactly fixed or false_positive |
accepted and wont_fix stay open (dashboard still counts them) but drop out of actionable. Use actionable to decide whether to do work; use open to report state. ?status=open matches summary.open in the same response.
severity recognizes one reserved token, none, meaning "no severity was assigned". It matches
either spelling of that: a NULL canonical_severity (the bucket severityCounts labels (none)) or
the literal string none, which a model can emit. It combines with real severities:
| Filter | Matches |
|---|---|
severity=high | exactly high |
severity=critical,high | critical or high — excludes unclassified findings |
severity=none | only findings with no severity assigned — NULL or the literal none |
severity=critical,high,none | critical, high, or unclassified |
Unknown status or severity tokens are not rejected; they are matched literally and simply return nothing. Filters still combine with AND across parameters.
Finding status writes
POST /v1/projects/{id}/findings/{findingId}/status
{ status, notes?, decider?, reason?, commits?: [sha…], commit?: sha, … }
→ { finding_id, from_status, to_status, actor_user_id }
commits (or the legacy single commit) is accepted only with status=fixed or
status=needs_retest. Any other status returns 400 commits_invalid_for_status.
fixed+ commits writes a verified fix row and setsfixed_at_committo the last SHA.needs_retest+ commits is refine's proposed fix: same commit rows,fixed_at_commitset, finding stays actionable. Only a finished retest writesfixedorstill_vulnerable. See Refine.false_positiverequiresreason(400 missing_reasonotherwise).
Campaigns
POST /v1/projects/{id}/campaigns
{ profile, type, git:{branch,commit,remote_url,dirty}, base_commit?, changed_paths?, config_version? }
→ { campaign_id, profile, profile_id, config_version, config_version_id, slice_count, status, reused }
changed_paths for an incremental campaign,
or use the GitHub webhook path. Full campaign identity includes profile and pinned config ID.
Retests require an explicit profile and return 422 retest_bucket_unmapped when the finding's source
bucket code does not exist in the target profile config.Claim, heartbeat, results
POST /v1/campaigns/{id}/claim
{ client_id, models:[...], max_slices, lease_seconds }
→ { leases:[ { lease_id, slice_id, model_code, pass_code, bucket_code,
prompt:"<full rendered prompt.md>", prompt_hash, marker,
timeout_seconds, lease_expires_at } ] }
POST /v1/leases/{lease_id}/heartbeat → { lease_expires_at } # best-effort, online clients only
POST /v1/leases/{lease_id}/results
{ exit_code, duration_ms, model_code, client_version, prompt_hash,
stdout:"<raw output.txt>",
stderr_tail }
→ { accepted, parsed_report_count, warnings[] } # idempotent on (lease_id, prompt_hash)
Gate
GET /v1/campaigns/{id}/gate → { status: pass|fail|pending, new_findings:[...] }
Cheap CI poll — no model creds or long runtimes in CI.
The gate route is authenticated. CI needs a TheSpider token even though it does not need model credentials.
Lease policy
The client requests lease_seconds sized to its batch (server caps it, e.g. 24h). Heartbeats extend a lease when the client is online but are not required — claim and submit are the only calls that need connectivity. On expiry the slice requeues.
- A late result is still accepted if no other lease has completed the slice.
- If one has, first-accepted-wins and the late output is stored as a provenance artifact.
- Slice-quota usage is recorded once at claim, idempotent per
slice_id. Billing integration is not shipped.
Error semantics
| Status | Meaning | Client action |
|---|---|---|
200 | accepted | — |
202 | accepted-as-artifact / superseded | — |
409 | prompt_hash mismatch | re-claim |
413 | stdout exceeds MAX_ARTIFACT_BYTES | reduce output or enable --redact; presigned upload is not implemented |
429 | rate limited | retry with backoff |
5xx | server error | retry with backoff |
Network errors, 429, and 5xx are retried with exponential backoff + jitter by the agent.
Detailed integration contracts:
Webhooks (optional)
The Cloud webhook integration is not self-service yet. When TheSpider enables it for a project,
POST /v1/webhooks/github verifies GitHub's signature, maps pull_request opened, synchronize,
or reopened events to a project, fetches changed paths, and creates an incremental campaign.