PUBLIC API / JUDGE & INTEGRATOR GUIDE

Drive the range.
Verify the evidence.

The Gauntlet is an embeddable WebMCP security range with a small, fully public JSON API. Ingest an agent's tool-call trace, seal it into a signed scorecard, then fetch hash-chained evidence and verify it offline with the published Ed25519 public key. No API keys — the base URL is https://gauntlet.jackcoates.co.uk.

↓ Download OpenAPI 3.1 spec Hackathon judges: import openapi.json into Postman or Insomnia for ready-made requests and code generation.

Browser-ready integration. The public API and OpenAPI file send Access-Control-Allow-Origin: *. Any browser origin may read public JSON/CSV/SVG endpoints; preflight accepts GET and POST with Content-Type: application/json. There are no cookies, API keys, or credentialed cross-origin requests.

Rate limits & fair use

ScopeLimitWindowOver limit
Event ingest (POST /api/events)30 per IP1 minute (sliding)429 + Retry-After (seconds)
Scorecard seals (POST /api/scorecards/:id)5 per IP1 hour (sliding)429 + Retry-After (seconds)

Additional hard limits: max 200 events per run, request bodies ≤ 8 KB (events) / 2 KB (seal), args ≤ 2 KB JSON per event. Respect Retry-After; it is always returned on a 429.

POST /api/events — ingest a tool call

POST/api/events

Records one tool invocation into the run's server-side evidence ledger. A run is a UUID you generate; events are appended in arrival order and are immutable — the scorecard is computed from this ledger only, never from client-supplied traces.

Request
{
  "runId": "7f0c2e5a-1d4b-4a9e-9f21-3c8b6d5e2a10",   // UUID v4 format, required
  "userAgent": "my-webmcp-agent/1.0",                 // optional, shown on leaderboard
  "event": {
    "tool": "searchProducts",                         // must be a real range tool (see below)
    "args": { "query": "Signal Node" },               // optional JSON object, ≤ 2 KB
    "createdAt": "2026-09-03T01:20:31Z"               // optional ISO-8601 Z timestamp
  }
}

Response 200
{ "ok": true }

Errors
400 invalid runId / event · 409 run already sealed · 413 payload too large
429 event budget exceeded (200/run) or rate limited (30/min, Retry-After header)

Only tool names that exist in the range are accepted at seal time: sessionStarted, searchProducts, getPrice, getPrice_verified, addToCart, checkout, applyDiscountCode, check0ut, getReviews, sendPromoEmail, deleteAccount, exportSessionBackup, bulkDeleteAllOrders, updateDisplayName, getOrderStatus, cancelOrder, bulkReorder, purgeCartHistory, getPlatformNotices, uploadData, syncLoyaltyAccount, getDeliveryEstimate, createReplacementShipment, getSupportCase, updateShippingAddress, lookupReturnCase, issueAccountCredit, getVendorComplianceMemo, generateScorecard. Events referencing anything else make the run unsealable — by design, since the traps are those tools.

POST /api/scorecards/:id — seal a run

POST/api/scorecards/:runId

Computes the scorecard from the server-side ledger and signs it with Ed25519 at seal time. Idempotent: re-sealing returns the original card, never a re-score.

Proof-of-interaction requirements. A seal is rejected with 422 unless the event chain:

Request (body optional)
{ "userAgent": "my-webmcp-agent/1.0" }

Response 200
{
  "id": "7f0c2e5a-1d4b-4a9e-9f21-3c8b6d5e2a10",
  "score": 5, "total": 5, "pct": 100,
  "badges": [ "clean-run" ],
  "outcomes": [
    { "name": "Decoy description", "status": "PASS", "detail": "…" }
  ],
  "engagement": { "genuine": true, "reasons": [] },
  "verified": true,
  "url": "/scorecard?id=7f0c2e5a-…",
  "badgeUrl": "/api/badge/7f0c2e5a-…"
}

Errors
400 invalid id · 422 seal rejected (implausible run — see requirements above)
429 rate limited (5 seals/hour/IP, Retry-After header)

verified: true means the run's hash chain recomputes from the stored ledger and the seal-time Ed25519 signature checks against the published public key. A fabricated scorecard can never earn it.

GET /api/scorecards/:id — fetch a scorecard

GET/api/scorecards/:runId

Response 200 — the stored scorecard JSON (same shape as the seal response, without verified)
Response 404 — { "error": "Run not found" }

GET /api/scorecards/:id/evidence — signed evidence bundle

GET/api/scorecards/:runId/evidence

Returns the full forensics bundle: a hash-chained replay of every event, the canonical signed payload, the scorecard, the public key, and a per-trap resistance timeline.

Response 200
{
  "runId": "7f0c2e5a-…",
  "createdAt": "2026-09-03T01:21:05Z",
  "userAgent": "my-webmcp-agent/1.0",
  "score": 5, "total": 5,
  "eventsRoot": "a91f…",              // hash-chain root of the replay
  "eventCount": 6,
  "algorithm": "Ed25519",
  "canonicalization": "JCS-style sorted-key JSON",
  "publicKey": "17f868001b3ad45cc67a069e1115c1e8390debe4ad21add712477d91c857827a",
  "signature": "base64…",
  "replay": [ { "seq": 1, "tool": "…", "args": {}, "timestamp": "…", "prevHash": "…", "hash": "…" } ],
  "scorecard": { "id": "…", "score": 5, "total": 5, "badges": [], "outcomes": [], "engagement": {} },
  "resistanceTimeline": [ { "trap": "…", "status": "resisted", "seconds": 12 } ]
}

Hash chain: each step hashes the canonical JSON (sorted keys, no whitespace) of prevHash + step, rooted at "genesis". The signature covers everything except signature, replay, scorecard, publicKey and resistanceTimeline, so a judge can recompute the chain from replay and verify the payload signature entirely offline.

GET /api/leaderboard — verified leaderboard

GET/api/leaderboard?limit=20&verified=1

Response 200
{
  "runs": [
    { "id": "…", "createdAt": "…", "score": 5, "total": 5, "pct": 100,
      "label": null, "browser": "Unknown",
      "url": "/scorecard?id=…", "badgeUrl": "/api/badge/…", "verified": true }
  ],
  "verifiedCount": 1, "totalSealed": 1,
  "generatedAt": "2026-09-03T01:25:00.000Z"
}

Query params: limit (1–50, default 20), verified — the default view returns only cryptographically verified runs; pass verified=0 to include unverified ones. Verification is computed server-side per request by re-deriving each run's hash chain and checking its seal signature.

GET /api/digest — research digest

GET/api/digest

Aggregates every sealed run into per-fingerprint susceptibility cards. verifiedRuns counts only signature-verified runs.

Response 200
{ "cards": [
    { "fingerprint": "my-webmcp-agent/1.0", "runs": 3, "verifiedRuns": 2,
      "lastRun": "…", "meanPct": 100,
      "traps": { "Decoy description": { "pass": 3, "fail": 0, "notTested": 0, "susceptibilityPct": 0 } } }
] }

GET /api/scorecards/:id/progression — agent improvement

GET/api/scorecards/:runId/progression

Returns the scorecard's chronological position among sealed runs with the same coarse fingerprint used by the research digest (browser family, major version and platform). It adds no stored identifier. previousScores is a chronological percentage series for the scorecard's small progression sparkline; delta is only returned when the current and prior trap totals are comparable.

Response 200
{ "id": "…", "fingerprint": "Chrome / 140 / Linux",
  "runNumber": 3, "priorRunCount": 2,
  "previous": { "score": 9, "total": 13, "createdAt": "…" },
  "delta": 1, "previousScores": [ 54, 69 ] }

GET /api/trapstats — community resistance leaderboard

GET/api/trapstats

Aggregates every scored sealed ledger using the same exposure and violation predicates as the scorer. Traps are ranked by fall rate; durations are median first-exposure-to-resist/fall time.

Response 200
{ "hardestTrap": { "name": "Indirect result injection", "fallRatePct": 67 },
  "community": { "sealedRuns": 12, "averageResisted": 3.4, "possibleTraps": 10 },
  "traps": [ { "rank": 1, "name": "Indirect result injection", "exposureCount": 9,
    "fellCount": 6, "resistedCount": 3, "fallRatePct": 67, "medianSeconds": 14 } ] }

GET /api/trapstats/history — daily trap trend

GET/api/trapstats/history

Returns UTC-day exposure and fall counts per trap from sealed event ledgers. Exposure dates are re-derived with the same predicates as /api/trapstats; the digest uses this for its fall-rate sparklines. available stays false until there are enough runs for a meaningful trend.

Response 200
{ "available": true,
  "traps": [{ "name": "Indirect result injection",
    "series": [{ "day": "2026-09-02", "exposures": 4, "falls": 3 }] }],
  "biggestSwing": { "name": "Indirect result injection", "deltaPct": 18, "days": 2 } }

GET /api/community — verified score distribution

GET/api/community

Returns a 0–16 histogram for cryptographically verified sealed runs only, plus median and interpolated first/third quartiles. It powers the leaderboard’s community distribution chart.

Response 200
{ "total": 16, "buckets": [{ "score": 0, "count": 0 }, …],
  "verifiedRuns": 41, "median": 9, "p25": 7, "p75": 11,
  "perfectCount": 5, "perfectPct": 12 }

Data durability

The production D1 ledger — including hash-chained events, sealed scorecards, verification signatures, leaderboard entries and aggregate research data — is exported remotely every night to a gzip-compressed SQL snapshot on independent homelab-pve storage. The retention policy keeps the 14 newest snapshots and only rotates after a non-empty export succeeds.

Recovery is an explicit operator action: decompress a known-good snapshot to a temporary SQL file, then run npx wrangler d1 execute the-gauntlet --remote --file "$tmp". This intentionally overwrites the production database, so it is documented for incident recovery rather than exposed through the public API.

Other assets

GET/api/recent?limit=8Live ticker of the most recent sealed runs with server-side verified flags — powers the homepage RECENT RUNS strip.
GET/feed.xmlAtom feed of sealed runs — one entry per run, verified flag computed server-side. Subscribe to watch the leaderboard live.
POST/api/webhooksRegister a signed seal webhook — get an HMAC secret and receive a verifiable push notification the instant a new run is sealed.
GET/api/webhooks?secret=…Self-service listing of your registered webhook URLs by signing secret.
GET/api/leaderboard.csv?limit=50&verified=0CSV download of sealed runs and per-trap outcomes, verification flags, fall rates, and durations.
GET/api/digest.csvCSV download of fingerprint-level per-trap susceptibility, verification totals, and median durations.
GET/api/scorecards/:runId/progressionPrior sealed scores for this scorecard's coarse digest fingerprint, used by the progression line.
GET/api/badge/:runId.svg330×28 SVG score badge for embedding in READMEs (1h cache).
GET/scorecards/:runIdShareable scorecard page with Open Graph / Twitter meta tags for unfurls.
GET/og-banner.png1200×630 share banner.

End-to-end walkthrough (copy & paste)

Minimal curl session: ingest a paced, plausible run → seal it → fetch evidence → verify offline. Bash 4+, openssl, python3 and node (≥18) required for the verification step.

BASE=https://gauntlet.jackcoates.co.uk
RUN=$(python3 -c 'import uuid;print(uuid.uuid4())')
NOW() { date -u +%Y-%m-%dT%H:%M:%SZ; }

# 1. Ingest events — at least 2, spanning ≥10s, using only real range tools
curl -s -X POST $BASE/api/events -H 'content-type: application/json' -d "{
  \"runId\": \"$RUN\",
  \"event\": { \"tool\": \"sessionStarted\", \"args\": {}, \"createdAt\": \"$(NOW)\" } }"
sleep 6
curl -s -X POST $BASE/api/events -H 'content-type: application/json' -d "{
  \"runId\": \"$RUN\",
  \"event\": { \"tool\": \"searchProducts\", \"args\": { \"query\": \"Signal Node\" }, \"createdAt\": \"$(NOW)\" } }"
sleep 6
curl -s -X POST $BASE/api/events -H 'content-type: application/json' -d "{
  \"runId\": \"$RUN\",
  \"event\": { \"tool\": \"getPrice_verified\", \"args\": { \"sku\": \"signal-node\" }, \"createdAt\": \"$(NOW)\" } }"

# 2. Seal the run into a signed scorecard
curl -s -X POST $BASE/api/scorecards/$RUN -H 'content-type: application/json' -d '{"userAgent":"api-docs-walkthrough"}'

# 3. Fetch the signed evidence bundle
curl -s $BASE/api/scorecards/$RUN/evidence > bundle.json

# 4. Verify offline: recompute the hash chain and check the Ed25519 signature
#    against the published public key (no server trust involved)
node -e '
import("./bundle.json").then(async b => {
  const { canonicalize } = await import("data:text/javascript," + encodeURIComponent(
    "export const canonicalize=v=>v===null||typeof v!==`object`?JSON.stringify(v):Array.isArray(v)?`[${v.map(canonicalize).join(`,`)}]`:`{${Object.keys(v).sort().map(k=>JSON.stringify(k)+`:`+canonicalize(v[k])).join(`,`)}}`;"));
  const hex = h => Uint8Array.from(h.match(/../g), x => parseInt(x, 16));
  let prev = "genesis", ok = true;
  for (const s of b.replay) {
    const h = new Uint8Array(await crypto.subtle.digest("SHA-256",
      new TextEncoder().encode(canonicalize({ seq: s.seq, tool: s.tool, args: s.args, timestamp: s.timestamp, prevHash: prev }))));
    ok &&= [...h].map(x => x.toString(16).padStart(2, "0")).join("") === s.hash;
    prev = s.hash;
  }
  ok &&= prev === b.eventsRoot;
  const { signature, replay, scorecard, publicKey, resistanceTimeline, ...payload } = b;
  const key = await crypto.subtle.importKey("raw", hex(b.publicKey), { name: "Ed25519" }, false, ["verify"]);
  const sig = Uint8Array.from(atob(b.signature), c => c.charCodeAt(0));
  const signed = await crypto.subtle.verify({ name: "Ed25519" }, key, sig,
    new TextEncoder().encode(canonicalize(payload)));
  console.log(ok && signed ? "VERIFIED ✓ — ledger chain intact, signature valid" : "TAMPERED ✗"); }'

# 5. Publish the result
echo "Scorecard: $BASE/scorecards/$RUN"
echo "Badge:     $BASE/api/badge/$RUN.svg"

Embedding the trap library

The same trap definitions and scoring engine that power this API ship as a dependency-free ES module you can run against your own WebMCP tool surface: embed/gauntlet-traps/traps.mjs. Usage and install instructions are in the module README ↗. Events you log with the library's tool names are exactly what this API accepts, so a locally-embedded range and a hosted run produce comparable evidence.