← Rules Desk / API
Tokens

Drive Rules Desk from your own code

Everything the page does over the network is available over HTTP. The natural uses are a CI job that audits firestore.rules on every pull request and fails the build on a critical finding, a pre-deploy gate that refuses a ruleset whose verdict is open, and a migration script that runs the harden stage over a directory of projects and opens a pull request per repository with the rewritten file.

One thing to know before you start: the free part is not on this API. The parser, the coverage table and all twenty-four prescan checks run in the browser, in /ruleslint.js — a dependency-free module you can read, vendor and run under Node with global.window = {}. The API is for the three metered stages. Send the prescan output with the run, or the reply has nothing to be accountable to.

The task field comes first

This app has one endpoint and three stages. Every run input carries a task field, and it decides which contract comes back. Send it explicitly — if it is missing the model picks the closest stage and names its choice in notes, which is a fallback, not a feature.

taskWhat it doesRequired fieldsBody keys in the reply
auditJudges the rules as deployedrules, surface, data_sensitivity, prescanverdict, deciding_factor, paths, findings, checks
claimsWorks out the token the rules assumerules, prescan; providers optionalidentity_model, claims, gaps, steps, risks
hardenRewrites the whole filerules, posture, prescan; keep_public, audit_refs optionalrules_file, posture_applied, changes, kept, tests, residual_risk

The envelope, and the errors

Every response is {"data": ...} or {"error": {"code", "message", "details"}} with an HTTP status that matches. The model's own reply is a JSON string inside data.output.output — parse it, then expect the shared envelope (lane, title, headline, coverage, notes, warnings) with one stage body merged in.

Statuserror.codeWhat to do
400VALIDATION_ERRORThe input shape is wrong. details names the field.
401UNAUTHORIZEDNo token, or an expired one. Mint a new one at /tokens.html.
402INSUFFICIENT_CREDITSCall /estimate first and compare hold_credits with the balance from /me.
404NOT_FOUNDWrong slug in the host header, or a job id that never existed.
429RATE_LIMITEDBack off. Never tight-loop a retry.
5xxINTERNALRetry once with the SAME Idempotency-Key, so a completed run is not billed twice.

1. Get a token

A guest token is enough for /me and /estimate. The three stages are metered, so they need a personal token — sign in and copy it from /tokens.html, which shows the token this browser already holds without opening a developer console.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Authorization: Bearer none-needed" \
  -H "Content-Type: application/json" \
  -d '{}'

2. Check who you are and what you can spend

/me tells you whether the token is a guest or a person, and what the balance is. Compare it with hold_credits before you run anything.

curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer YOUR_TOKEN"

3. Price the run — free, no job created

Send the exact input you intend to run. The reply carries model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Re-estimate whenever the task changes.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "audit", "rules": "rules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n    match /notes/{id} {\n      allow read, write: if request.auth != null;\n    }\n  }\n}", "surface": "production", "data_sensitivity": "personal", "notes": "Anonymous sign-in is enabled.", "prescan": {"verdict": "leaky", "stats": {"lines": 8, "rules_version": "2", "match_blocks": 2, "granting_blocks": 1, "allow_statements": 1, "helpers": 0, "public_paths": 0, "recursive_paths": 0, "custom_claims": []}, "coverage": [{"path": "/databases/{database}/documents/notes/{id}", "ops": "get,list,create,update,delete", "gate": "signed-in", "line": 4}], "findings": [{"ref": "FR-06#1", "id": "FR-06", "severity": "high", "title": "Any signed-in user can write", "path": "/databases/{database}/documents/notes/{id}", "line": 5}, {"ref": "FR-11#2", "id": "FR-11", "severity": "medium", "title": "read and write used instead of the granular methods", "path": "/databases/{database}/documents/notes/{id}", "line": 5}]}}'

4. Run the audit stage, then poll

/run returns a job_id immediately. Poll /jobs/{id} until status is succeeded or failed; the model's text is data.output.output.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "audit", "rules": "rules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n    match /notes/{id} {\n      allow read, write: if request.auth != null;\n    }\n  }\n}", "surface": "production", "data_sensitivity": "personal", "notes": "Anonymous sign-in is enabled.", "prescan": {"verdict": "leaky", "stats": {"lines": 8, "rules_version": "2", "match_blocks": 2, "granting_blocks": 1, "allow_statements": 1, "helpers": 0, "public_paths": 0, "recursive_paths": 0, "custom_claims": []}, "coverage": [{"path": "/databases/{database}/documents/notes/{id}", "ops": "get,list,create,update,delete", "gate": "signed-in", "line": 4}], "findings": [{"ref": "FR-06#1", "id": "FR-06", "severity": "high", "title": "Any signed-in user can write", "path": "/databases/{database}/documents/notes/{id}", "line": 5}, {"ref": "FR-11#2", "id": "FR-11", "severity": "medium", "title": "read and write used instead of the granular methods", "path": "/databases/{database}/documents/notes/{id}", "line": 5}]}}'

Then poll the job:

curl -s -X GET "https://api.skillsafe.ai/v1/app-api/jobs/job_REPLACE_WITH_ID" \
  -H "Authorization: Bearer YOUR_TOKEN"

5. Stream it instead

/run-stream is server-sent events. The page uses it so the progress card can advance on section keys arriving in the delta stream rather than on a timer. Same body, same idempotency rules.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "audit", "rules": "rules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n    match /notes/{id} {\n      allow read, write: if request.auth != null;\n    }\n  }\n}", "surface": "production", "data_sensitivity": "personal", "notes": "Anonymous sign-in is enabled.", "prescan": {"verdict": "leaky", "stats": {"lines": 8, "rules_version": "2", "match_blocks": 2, "granting_blocks": 1, "allow_statements": 1, "helpers": 0, "public_paths": 0, "recursive_paths": 0, "custom_claims": []}, "coverage": [{"path": "/databases/{database}/documents/notes/{id}", "ops": "get,list,create,update,delete", "gate": "signed-in", "line": 4}], "findings": [{"ref": "FR-06#1", "id": "FR-06", "severity": "high", "title": "Any signed-in user can write", "path": "/databases/{database}/documents/notes/{id}", "line": 5}, {"ref": "FR-11#2", "id": "FR-11", "severity": "medium", "title": "read and write used instead of the granular methods", "path": "/databases/{database}/documents/notes/{id}", "line": 5}]}}'

6. The claims stage

Same rules file, different contract. providers is a free-text list of the sign-in methods the project allows; leaving it out makes the model infer and say that it inferred.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "claims", "rules": "rules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n    match /notes/{id} {\n      allow read, write: if request.auth != null;\n    }\n  }\n}", "notes": "There is no admin yet.", "prescan": {"verdict": "leaky", "stats": {"lines": 8, "rules_version": "2", "match_blocks": 2, "granting_blocks": 1, "allow_statements": 1, "helpers": 0, "public_paths": 0, "recursive_paths": 0, "custom_claims": []}, "coverage": [{"path": "/databases/{database}/documents/notes/{id}", "ops": "get,list,create,update,delete", "gate": "signed-in", "line": 4}], "findings": [{"ref": "FR-06#1", "id": "FR-06", "severity": "high", "title": "Any signed-in user can write", "path": "/databases/{database}/documents/notes/{id}", "line": 5}, {"ref": "FR-11#2", "id": "FR-11", "severity": "medium", "title": "read and write used instead of the granular methods", "path": "/databases/{database}/documents/notes/{id}", "line": 5}]}, "providers": "password, anonymous"}'

7. The harden stage

Returns the complete rewritten file in rules_file — never a diff, never a fragment. audit_refs carries the finding ids from an earlier audit so the rewrite has to answer them; keep_public names paths that must stay readable.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "harden", "rules": "rules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n    match /notes/{id} {\n      allow read, write: if request.auth != null;\n    }\n  }\n}", "notes": "Notes belong to whoever created them; the field is ownerId.", "prescan": {"verdict": "leaky", "stats": {"lines": 8, "rules_version": "2", "match_blocks": 2, "granting_blocks": 1, "allow_statements": 1, "helpers": 0, "public_paths": 0, "recursive_paths": 0, "custom_claims": []}, "coverage": [{"path": "/databases/{database}/documents/notes/{id}", "ops": "get,list,create,update,delete", "gate": "signed-in", "line": 4}], "findings": [{"ref": "FR-06#1", "id": "FR-06", "severity": "high", "title": "Any signed-in user can write", "path": "/databases/{database}/documents/notes/{id}", "line": 5}, {"ref": "FR-11#2", "id": "FR-11", "severity": "medium", "title": "read and write used instead of the granular methods", "path": "/databases/{database}/documents/notes/{id}", "line": 5}]}, "posture": "strict", "keep_public": "", "audit_refs": ["FR-06#1", "RD-01"]}'

8. Parse the reply

Strip an accidental code fence, take the first { to the last }, and JSON.parse it — exactly what /app.js does in parseResult. Then normalise: every array key is guaranteed present by the prompt, but a defensive reader treats a missing key as an empty array rather than throwing.

The coverage array is the part worth checking in CI. It carries one entry per prescan.findings[].ref you sent, with status of confirmed or set-aside. A ref that comes back with neither was never answered, and that is the signal that the review skipped something.

# Pull the verdict and the unanswered refs out of a finished job with jq.
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB_ID" \
  -H "Authorization: Bearer YOUR_TOKEN")

echo "$JOB" | jq -r '.data.output.output' | jq -r '.verdict, .deciding_factor'
echo "$JOB" | jq -r '.data.output.output' | jq -r '.coverage[] | "\(.ref) \(.status)"'

Rate limits, idempotency and cost

Credits

Rules Desk is a derived work built from three Firebase agent skills: @firebase/firebase-security-rules-auditor, @firebase/firebase-firestore and @firebase/firebase-auth-basics.