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.
Rate limits & fair use
| Scope | Limit | Window | Over limit |
|---|---|---|---|
Event ingest (POST /api/events) | 30 per IP | 1 minute (sliding) | 429 + Retry-After (seconds) |
Scorecard seals (POST /api/scorecards/:id) | 5 per IP | 1 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, purgeCartHistory, 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:
- contains at least 2 events;
- spans at least 10 seconds from first to last timestamp;
- references only tool names that exist in the range (list above).
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/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 } ] }
Other assets
| GET/api/badge/:runId.svg | 330×28 SVG score badge for embedding in READMEs (1h cache). |
| GET/scorecards/:runId | Shareable scorecard page with Open Graph / Twitter meta tags for unfurls. |
| GET/og-banner.png | 1200×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.