Drive A11y Desk from your own code
Everything the web page does is available over HTTP: post one component's markup — HTML, JSX or TSX, a Vue or Svelte template — and get back either a WCAG 2.2 AA audit of it (findings tied to success criteria, a keyboard map, the checks only a human can make, and a verdict) or an accessible rewrite of the same component (the whole thing again, fixed, with a change log and the things markup alone cannot carry). The natural use is a CI job that audits the components a pull request touched and fails the build when a blocker appears, or a batch that walks a component library overnight and produces the backlog nobody has had time to write down.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Send your token as Authorization: Bearer … on every call. The app slug travels in the
body of /guest as {"slug": "a11y-desk"}; after that the token
itself carries the app, so a run needs only Authorization,
Content-Type: application/json and the Idempotency-Key described in
step 5.
The request body for /estimate, /run and /run-stream is the
input object itself — not wrapped in anything. A body of {"input": {…}} returns 200
and quietly hides every field from the model, so a run that looks fine comes back describing
nothing. Guard it the way the app's own client does: refuse to send anything that is not a plain
JSON object.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits, or a guest token tried a metered run. Call /estimate first, then sign in and top up. |
validation_error | 400 | The body is not a plain JSON object, or a field is the wrong type — code must be a string. task, code and framework are required; a missing one comes back as a warning from /estimate. |
not_found | 404 | Unknown job id, or the app slug does not exist. Check the id you polled with. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop a poll. |
internal | 500 | A server-side failure. Retry with the same Idempotency-Key so you are not billed twice. |
Replaying an Idempotency-Key with a changed body is not a retry and is
rejected rather than billed. When you change the input — a different component, a
tightened constraints line — bump the attempt suffix on the key instead.
Structured fields travel as JSON strings. The platform checks every body against a
declared list of scalar fields, so prescan_facts and findings_to_fix are
declared as strings: the web page sends JSON.stringify(prescan_facts) and
JSON.stringify(findings_to_fix). The samples below show them as plain objects for
readability; that still works and the model reads both forms, but /estimate then
returns a should be string warning for each. Encode them to keep the warnings list empty,
so a real mistake such as an {"input": {…}} wrapper stands out.
The field to get right first: task
A11y Desk is one app with two lanes, and task is what chooses between them. It is the
first field of every request body:
task | what comes back | extra input fields |
|---|---|---|
"audit" | The component inspected against WCAG 2.2 AA: findings tied to a success criterion each, passes, manual_checks a person still has to run, a keyboard_map, a score of severity counts, and a verdict of blocked, needs_work or ready_for_manual_testing. | none |
"rewrite" | The same component again, fixed: rewritten_code, a changes log with verbatim before and after slices, unresolved items the markup cannot settle, behaviour_notes for what needs script rather than markup, and a test_script to check it by hand. | findings_to_fix, constraints |
The system prompt routes on task and never blends the two contracts in one reply. A
missing or unrecognised task is not an error: the model answers the closest lane,
names the lane it actually answered in the reply's lane field, and says so in
notes_on_input. So branch on lane in the reply, never on the
task you believe you sent.
The two lanes chain, and the handoff is the point of the app. Run
audit, take the findings you intend to fix, and send them back as the
findings_to_fix of a rewrite run over the same code.
Each entry is a small object — {"id": "F-001", "sc": "1.3.1", "summary": "…"} — and the
rewrite then reports, in changes[].fixes, which of those ids each edit closed. The web
page does this with one button, and a second button runs the rewritten markup back through the
audit lane, which is the only honest way to see whether the rewrite actually moved anything.
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
A guest token can call /me and /estimate. Auditing a
component and rewriting one are both metered, so either lane needs a personal
token from signing in.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://a11y-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="aut_YOUR_TOKEN"
#
# To mint a guest token from the command line instead. A guest token is enough for
# /me and /estimate; an audit or a rewrite run needs a personal token.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug": "a11y-desk"}'
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
# Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered lane.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "a11y-desk"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
print(TOKEN)
// Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "a11y-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest",
bytes.NewReader([]byte(`{"slug": "a11y-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"a11y-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
# Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered lane.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ "slug" => "a11y-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "a11y-desk"]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $payload["data"]["token"];
// Open https://a11y-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
var guestRes = await http.PostAsJsonAsync(
"https://api.skillsafe.ai/v1/app-api/guest",
new { slug = "a11y-desk" });
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
var token = guest.GetProperty("data").GetProperty("token").GetString();
2. A tiny client
One helper that adds the headers, unwraps data and raises on error.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="a11y-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://a11y-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "a11y-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "aut_YOUR_TOKEN") # from https://a11y-desk.skillsafe.ai/tokens.html
def call(path, body=None, headers=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
if body is not None and not isinstance(body, dict):
raise TypeError("the request body must be a JSON object, not a bare string")
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data,
method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "a11y-desk";
const TOKEN = "aut_YOUR_TOKEN"; // from https://a11y-desk.skillsafe.ai/tokens.html
async function call(path, body, extraHeaders) {
if (body !== undefined && (body === null || typeof body !== "object" || Array.isArray(body))) {
throw new TypeError("the request body must be a JSON object, not a bare string");
}
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(extraHeaders || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "a11y-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://a11y-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any, hdr map[string]string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class A11yDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "a11y-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "aut_YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody, Map<String, String> extra) throws Exception {
if (jsonBody != null && !jsonBody.trim().startsWith("{")) {
throw new IllegalArgumentException("the request body must be a JSON object");
}
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "a11y-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "aut_YOUR_TOKEN") # from https://a11y-desk.skillsafe.ai/tokens.html
def call(path, body = nil, extra = {})
raise TypeError, "the request body must be a JSON object" if body && !body.is_a?(Hash)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
extra.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "a11y-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "aut_YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null, array $extra = []) {
$ch = curl_init(BASE . "/" . $path);
$headers = array_merge(["Authorization: Bearer " . TOKEN], $extra);
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
// The body must encode as an object: an empty PHP array would encode
// as [] and be rejected as a validation_error.
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class A11yDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "a11y-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "aut_YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null,
(string, string)? extraHeader = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (extraHeader is { } h) req.Headers.Add(h.Item1, h.Item2);
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me tells you whether the token is a guest or a person, and what the balance is.
The object is small and carries exactly three things: subject_type —
guest or user — subject_id, and credits, the
wallet balance. There is no username in it, so "signed in" is
subject_type === "user" and nothing else; a guest can price a run but cannot
start one. Compare credits against min_credits from the next step before
you run, so a shortfall surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
# A guest token answers the same call with subject_type "guest" and cannot run
# either lane. Gate on it before you spend a poll loop finding out:
call me | grep -q '"subject_type":"user"' || {
echo "sign in at https://a11y-desk.skillsafe.ai/tokens.html first" >&2
exit 1
}
me = call("me")
signed_in = me["subject_type"] == "user"
print(me["subject_type"], me.get("credits"), "signed in" if signed_in else "guest")
if not signed_in:
raise SystemExit("a guest token cannot run the audit or the rewrite lane")
const me = await call("me");
const signedIn = me.subject_type === "user";
console.log(me.subject_type, me.credits, signedIn ? "signed in" : "guest");
if (!signedIn) throw new Error("a guest token cannot run the audit or the rewrite lane");
raw, err := call("me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
if me.SubjectType != "user" {
panic("a guest token cannot run the audit or the rewrite lane")
}
System.out.println(A11yDesk.call("me", null, null));
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
// "signed in" is subject_type.equals("user") - there is no username field, and a
// guest token is refused by both lanes.
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
abort "sign in first - a guest token cannot run a lane" unless me["subject_type"] == "user"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
$signedIn = $me["subject_type"] === "user";
if (!$signedIn) {
throw new RuntimeException("a guest token cannot run the audit or the rewrite lane");
}
var me = await A11yDesk.Call("me");
var signedIn = me.GetProperty("subject_type").GetString() == "user";
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")} {signedIn}");
if (!signedIn) throw new Exception("a guest token cannot run the audit or the rewrite lane");
4. Price the run — free
The input object is exactly what the app's own form submits. It is always a JSON
object — never a bare string, never wrapped in an input key:
| field | type | meaning |
|---|---|---|
task | string, required | "audit" or "rewrite". The lane. It routes the prompt, and a missing or unknown value degrades to the closest lane rather than failing — the reply names what it answered in lane and in notes_on_input. |
code | string, required | One component's markup, pasted as text: HTML, JSX or TSX, a Vue or Svelte template. This is the run's only evidence — every finding quotes a snippet out of it, and every rewrite is this text again. The browser clips to 40,000 characters from the middle, keeping the beginning and the end, and leaves a marker in place of the cut. Clip the same way if you send more and keep the marker: the prompt keys on it, refuses to claim anything about the missing middle, and mentions the cut in notes_on_input. Send one component, not a whole page — a component is what both lanes are scoped to. |
framework | enum | html, jsx, vue, svelte, angular or unknown. Detected client-side from the markup and overridable by the user. It matters more than it looks: it decides whether the fix is for= or htmlFor=, whether a handler is onclick or a framework binding, and what the rewrite is allowed to emit. |
context_hint | string, optional | Up to 600 characters about where this component lives and what it has to support — "Login form on the marketing site; Tailwind; evergreen browsers only". It is what lets the audit tell a real constraint from a preference. |
prescan_facts | object, always present | What the app's free local scanner found before the run. Shape and honesty note below. Always send the object, even when issues is empty. |
| rewrite lane only | ||
findings_to_fix | object[], optional | [{"id": "F-001", "sc": "1.3.1", "summary": "…"}] — the findings from an earlier audit run over the same code. This is the handoff: every id you send should come back in some changes[].fixes or be explained in unresolved. Omit it and the rewrite fixes what it finds itself, and every fixes array comes back empty. |
constraints | string, optional | Up to 600 characters of what the rewrite may not do — "keep the class names; no new dependencies". Use it for the house rules that would otherwise make a correct rewrite unmergeable. |
prescan_facts, and why a script should still send it
In the browser this object is computed for free, before the run and without spending a credit, by the app's own static scanner — it never leaves the page, and the model sees only the summary below:
{
"framework": "jsx",
"stats": { "lines": 41, "elements": 23, "interactive": 6, "images": 2,
"form_fields": 3, "headings": 2, "landmarks": 1, "components": 2 },
"headings": ["h1 Dashboard", "h4 Recent activity"],
"components": ["Button", "Icon"],
"issues": [
{ "id": "S-001", "rule": "img-alt", "sc": "1.1.1", "level": "A", "severity": "serious",
"line": 12, "snippet": "<img src=\"/icon.svg\">", "message": "img has no alt attribute" }
],
"manual_hints": ["forms", "dialog", "icons", "colour_in_css"],
"clipped": { "cut": 0 }
}
stats is a shape count of the component. headings is the heading outline
as the scanner read it, and components names the capitalised elements whose internals
are not visible in the paste — an audit cannot claim anything about what
<Button> renders, and saying so is what keeps it honest.
manual_hints lists what static review cannot settle in this particular markup, and
clipped.cut is how many characters the middle clip removed.
Each entry in issues is one mechanical hit, with severity in
blocker, serious, moderate or minor,
sc as the dotted success-criterion number and level as
A, AA or AAA. The scanner's rule ids are a fixed vocabulary,
one WCAG 2.2 criterion each:
img-alt img-alt-redundant input-image-alt svg-name control-name control-name-dynamic
link-no-href link-text-generic label-missing placeholder-as-label label-empty
div-click role-button-no-tabindex role-button-no-keys tabindex-positive
aria-hidden-focusable aria-label-no-role aria-role-invalid aria-attr-invalid
aria-ref-missing id-duplicate heading-skip heading-empty heading-multiple-h1
html-lang title-missing iframe-title table-headers fieldset-legend
radio-group-no-fieldset autoplay viewport-zoom contrast focus-outline-none
target-size dialog-name dialog-modal role-heading-level meta-refresh
nested-interactive li-outside-list motion-no-reduce marquee
An API caller does not have to reproduce any of that — there is no scanner to call
over HTTP, and the model reads code either way. What a script should do is
send the object in the shape shown, with the arrays empty:
{"framework": "html", "stats": {…}, "headings": [], "components": [], "issues": [], "manual_hints": [], "clipped": {"cut": 0}}.
The field is always present in a real request, the prompt is written against its presence, and an
empty issues array is a legitimate, meaningful value: it says the caller ran no scan,
not that the component is clean.
Be honest with yourself about what an empty scan gives up. The contract that makes the facts worth
sending is this: every id in prescan_facts.issues comes back exactly once in
coverage, with a status of confirmed, downgraded,
dismissed or merged. A mechanical scanner is allowed to be wrong, and
dismissed with a reason is the honest answer for a false positive — a different thing
from silence. Send no issues and coverage comes back empty, so you lose the
reconciliation, not the audit. If you have your own linter output, map it into this shape and send
it: it is the strongest check in the whole reply.
/estimate creates no job and charges nothing. It returns the model
binding — model is gpt-5.6-terra, model_alias is
gpt-terra, and markup_bps — plus sponsor_enabled and the
reservation: hold_credits is what gets held, and min_credits is the
balance you must clear to start at all. The hold is a reservation, not the price. It
prices the full output cap, so the charged_credits on the settled job is usually far
lower. Budget against hold_credits, report against charged_credits.
Price each lane separately. A rewrite body carries the whole component again and prices differently
from an audit of the same paste, and a prescan_facts carrying forty issues is forty
issues' worth of input tokens.
# One small, thoroughly inaccessible login form is the worked example throughout.
# Lane A - the audit. prescan_facts carries what a local scan found; both ids come
# back in `coverage`.
INPUT='{"task": "audit", "code": "<form>\n <div>Email</div>\n <input type=\"email\" placeholder=\"Email\">\n <div class=\"btn\">Log in</div>\n</form>", "framework": "html", "context_hint": "Login form on the marketing site; Tailwind; evergreen browsers only", "prescan_facts": {"framework": "html", "stats": {"lines": 5, "elements": 4, "interactive": 1, "images": 0, "form_fields": 1, "headings": 0, "landmarks": 0, "components": 0}, "headings": [], "components": [], "issues": [{"id": "S-001", "rule": "label-missing", "sc": "1.3.1", "level": "A", "severity": "serious", "line": 3, "snippet": "<input type=\"email\" placeholder=\"Email\">", "message": "input has no associated label"}, {"id": "S-002", "rule": "placeholder-as-label", "sc": "3.3.2", "level": "A", "severity": "moderate", "line": 3, "snippet": "<input type=\"email\" placeholder=\"Email\">", "message": "placeholder is used in place of a label"}], "manual_hints": ["forms", "colour_in_css"], "clipped": {"cut": 0}}}'
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":2652,"min_credits":310,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits on the settled job is normally much lower.
# Lane B - the rewrite of the SAME component, carrying two findings the audit
# produced. This is the handoff the web page's button performs.
FIX_INPUT='{"task": "rewrite", "code": "<form>\n <div>Email</div>\n <input type=\"email\" placeholder=\"Email\">\n <div class=\"btn\">Log in</div>\n</form>", "framework": "html", "context_hint": "Login form on the marketing site; Tailwind; evergreen browsers only", "constraints": "keep the class names; no new dependencies", "findings_to_fix": [{"id": "F-001", "sc": "1.3.1", "summary": "Email input has no associated label"}, {"id": "F-002", "sc": "2.1.1", "summary": "A div is the submit control: not focusable and not operable by keyboard"}], "prescan_facts": {"framework": "html", "stats": {"lines": 5, "elements": 4, "interactive": 1, "images": 0, "form_fields": 1, "headings": 0, "landmarks": 0, "components": 0}, "headings": [], "components": [], "issues": [{"id": "S-001", "rule": "label-missing", "sc": "1.3.1", "level": "A", "severity": "serious", "line": 3, "snippet": "<input type=\"email\" placeholder=\"Email\">", "message": "input has no associated label"}, {"id": "S-002", "rule": "placeholder-as-label", "sc": "3.3.2", "level": "A", "severity": "moderate", "line": 3, "snippet": "<input type=\"email\" placeholder=\"Email\">", "message": "placeholder is used in place of a label"}], "manual_hints": ["forms", "colour_in_css"], "clipped": {"cut": 0}}}'
call estimate "$FIX_INPUT" # price each lane separately
CODE = (
'<form>\n'
' <div>Email</div>\n'
' <input type="email" placeholder="Email">\n'
' <div class="btn">Log in</div>\n'
'</form>'
)
HINT = "Login form on the marketing site; Tailwind; evergreen browsers only"
# What a local scan found. A caller with no scanner sends the same object with
# empty arrays - the field is always present in a real request.
FACTS = {
"framework": "html",
"stats": {"lines": 5, "elements": 4, "interactive": 1, "images": 0,
"form_fields": 1, "headings": 0, "landmarks": 0, "components": 0},
"headings": [],
"components": [],
"issues": [
{"id": "S-001", "rule": "label-missing", "sc": "1.3.1", "level": "A",
"severity": "serious", "line": 3,
"snippet": '<input type="email" placeholder="Email">',
"message": "input has no associated label"},
{"id": "S-002", "rule": "placeholder-as-label", "sc": "3.3.2", "level": "A",
"severity": "moderate", "line": 3,
"snippet": '<input type="email" placeholder="Email">',
"message": "placeholder is used in place of a label"},
],
"manual_hints": ["forms", "colour_in_css"],
"clipped": {"cut": 0},
}
# Lane A - the audit.
INPUT = {
"task": "audit",
"code": CODE,
"framework": "html",
"context_hint": HINT,
"prescan_facts": FACTS,
}
# Lane B - the rewrite of the same component, carrying the audit's findings.
FIX_INPUT = {
"task": "rewrite",
"code": CODE,
"framework": "html",
"context_hint": HINT,
"constraints": "keep the class names; no new dependencies",
"findings_to_fix": [
{"id": "F-001", "sc": "1.3.1",
"summary": "Email input has no associated label"},
{"id": "F-002", "sc": "2.1.1",
"summary": "A div is the submit control: not focusable and not operable by keyboard"},
],
"prescan_facts": FACTS,
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"]) # gpt-5.6-terra gpt-terra 1000
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
print(call("estimate", FIX_INPUT)["hold_credits"]) # price each lane separately
const CODE = [
"<form>",
' <div>Email</div>',
' <input type="email" placeholder="Email">',
' <div class="btn">Log in</div>',
"</form>",
].join("\n");
const HINT = "Login form on the marketing site; Tailwind; evergreen browsers only";
// What a local scan found. With no scanner, send the same object with empty
// arrays - the field is always present in a real request.
const FACTS = {
framework: "html",
stats: { lines: 5, elements: 4, interactive: 1, images: 0,
form_fields: 1, headings: 0, landmarks: 0, components: 0 },
headings: [],
components: [],
issues: [
{ id: "S-001", rule: "label-missing", sc: "1.3.1", level: "A", severity: "serious",
line: 3, snippet: '<input type="email" placeholder="Email">',
message: "input has no associated label" },
{ id: "S-002", rule: "placeholder-as-label", sc: "3.3.2", level: "A", severity: "moderate",
line: 3, snippet: '<input type="email" placeholder="Email">',
message: "placeholder is used in place of a label" },
],
manual_hints: ["forms", "colour_in_css"],
clipped: { cut: 0 },
};
// Lane A - the audit.
const INPUT = {
task: "audit",
code: CODE,
framework: "html",
context_hint: HINT,
prescan_facts: FACTS,
};
// Lane B - the rewrite of the same component, carrying the audit's findings.
const FIX_INPUT = {
task: "rewrite",
code: CODE,
framework: "html",
context_hint: HINT,
constraints: "keep the class names; no new dependencies",
findings_to_fix: [
{ id: "F-001", sc: "1.3.1", summary: "Email input has no associated label" },
{ id: "F-002", sc: "2.1.1",
summary: "A div is the submit control: not focusable and not operable by keyboard" },
],
prescan_facts: FACTS,
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps); // gpt-5.6-terra gpt-terra 1000
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
console.log((await call("estimate", FIX_INPUT)).hold_credits); // price each lane separately
const componentCode = "<form>\n" +
" <div>Email</div>\n" +
" <input type=\"email\" placeholder=\"Email\">\n" +
" <div class=\"btn\">Log in</div>\n" +
"</form>"
const hint = "Login form on the marketing site; Tailwind; evergreen browsers only"
// What a local scan found. With no scanner, send the same object with empty
// slices - the field is always present in a real request.
var facts = map[string]any{
"framework": "html",
"stats": map[string]any{
"lines": 5, "elements": 4, "interactive": 1, "images": 0,
"form_fields": 1, "headings": 0, "landmarks": 0, "components": 0,
},
"headings": []any{},
"components": []any{},
"issues": []any{
map[string]any{
"id": "S-001", "rule": "label-missing", "sc": "1.3.1", "level": "A",
"severity": "serious", "line": 3,
"snippet": `<input type="email" placeholder="Email">`,
"message": "input has no associated label",
},
map[string]any{
"id": "S-002", "rule": "placeholder-as-label", "sc": "3.3.2", "level": "A",
"severity": "moderate", "line": 3,
"snippet": `<input type="email" placeholder="Email">`,
"message": "placeholder is used in place of a label",
},
},
"manual_hints": []string{"forms", "colour_in_css"},
"clipped": map[string]any{"cut": 0},
}
// Lane A - the audit.
var input = map[string]any{
"task": "audit",
"code": componentCode,
"framework": "html",
"context_hint": hint,
"prescan_facts": facts,
}
// Lane B - the rewrite of the same component, carrying the audit's findings.
var fixInput = map[string]any{
"task": "rewrite",
"code": componentCode,
"framework": "html",
"context_hint": hint,
"constraints": "keep the class names; no new dependencies",
"findings_to_fix": []any{
map[string]any{"id": "F-001", "sc": "1.3.1",
"summary": "Email input has no associated label"},
map[string]any{"id": "F-002", "sc": "2.1.1",
"summary": "A div is the submit control: not focusable and not operable by keyboard"},
},
"prescan_facts": facts,
}
raw, err := call("estimate", input, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
_ = json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.ModelAlias, est.HoldCredits, est.MinCredits)
// estimate is free: no job, no charge. The hold is a reservation against the
// full output cap, not the price of the run.
// The request bodies as JSON text. Inside a text block \\n is a literal
// backslash-n, which is exactly what a JSON string needs for the newlines in
// `code`, and \\" is a literal escaped quote.
static final String FACTS = """
"prescan_facts": {
"framework": "html",
"stats": {"lines": 5, "elements": 4, "interactive": 1, "images": 0,
"form_fields": 1, "headings": 0, "landmarks": 0, "components": 0},
"headings": [], "components": [],
"issues": [
{"id": "S-001", "rule": "label-missing", "sc": "1.3.1", "level": "A",
"severity": "serious", "line": 3,
"snippet": "<input type=\\"email\\" placeholder=\\"Email\\">",
"message": "input has no associated label"},
{"id": "S-002", "rule": "placeholder-as-label", "sc": "3.3.2", "level": "A",
"severity": "moderate", "line": 3,
"snippet": "<input type=\\"email\\" placeholder=\\"Email\\">",
"message": "placeholder is used in place of a label"}],
"manual_hints": ["forms", "colour_in_css"],
"clipped": {"cut": 0}}""";
static final String CODE = """
"code": "<form>\\n <div>Email</div>\\n <input type=\\"email\\" placeholder=\\"Email\\">\\n <div class=\\"btn\\">Log in</div>\\n</form>",
"framework": "html",
"context_hint": "Login form on the marketing site; Tailwind; evergreen browsers only",""";
// Lane A - the audit.
static final String INPUT = "{\"task\": \"audit\", " + CODE + " " + FACTS + "}";
// Lane B - the rewrite of the same component, carrying the audit's findings.
static final String FINDINGS = """
"constraints": "keep the class names; no new dependencies",
"findings_to_fix": [
{"id": "F-001", "sc": "1.3.1", "summary": "Email input has no associated label"},
{"id": "F-002", "sc": "2.1.1", "summary": "A div is the submit control: not focusable and not operable by keyboard"}],""";
static final String FIX_INPUT =
"{\"task\": \"rewrite\", " + CODE + " " + FINDINGS + " " + FACTS + "}";
System.out.println(A11yDesk.call("estimate", INPUT, null));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":2652,"min_credits":310,"sponsor_enabled":false}}
System.out.println(A11yDesk.call("estimate", FIX_INPUT, null)); // price each lane separately
CODE = <<~MARKUP.strip
<form>
<div>Email</div>
<input type="email" placeholder="Email">
<div class="btn">Log in</div>
</form>
MARKUP
HINT = "Login form on the marketing site; Tailwind; evergreen browsers only"
# What a local scan found. With no scanner, send the same object with empty
# arrays - the field is always present in a real request.
FACTS = {
"framework" => "html",
"stats" => { "lines" => 5, "elements" => 4, "interactive" => 1, "images" => 0,
"form_fields" => 1, "headings" => 0, "landmarks" => 0, "components" => 0 },
"headings" => [],
"components" => [],
"issues" => [
{ "id" => "S-001", "rule" => "label-missing", "sc" => "1.3.1", "level" => "A",
"severity" => "serious", "line" => 3,
"snippet" => '<input type="email" placeholder="Email">',
"message" => "input has no associated label" },
{ "id" => "S-002", "rule" => "placeholder-as-label", "sc" => "3.3.2", "level" => "A",
"severity" => "moderate", "line" => 3,
"snippet" => '<input type="email" placeholder="Email">',
"message" => "placeholder is used in place of a label" }
],
"manual_hints" => %w[forms colour_in_css],
"clipped" => { "cut" => 0 }
}.freeze
# Lane A - the audit.
INPUT = {
"task" => "audit", "code" => CODE, "framework" => "html",
"context_hint" => HINT, "prescan_facts" => FACTS
}.freeze
# Lane B - the rewrite of the same component, carrying the audit's findings.
FIX_INPUT = INPUT.merge(
"task" => "rewrite",
"constraints" => "keep the class names; no new dependencies",
"findings_to_fix" => [
{ "id" => "F-001", "sc" => "1.3.1",
"summary" => "Email input has no associated label" },
{ "id" => "F-002", "sc" => "2.1.1",
"summary" => "A div is the submit control: not focusable and not operable by keyboard" }
]
).freeze
est = call("estimate", INPUT)
puts "#{est['model']} #{est['model_alias']} #{est['hold_credits']} #{est['min_credits']}"
# estimate is free: no job is created and nothing is charged.
puts call("estimate", FIX_INPUT)["hold_credits"] # price each lane separately
<?php
$CODE = implode("\n", [
"<form>",
' <div>Email</div>',
' <input type="email" placeholder="Email">',
' <div class="btn">Log in</div>',
"</form>",
]);
$HINT = "Login form on the marketing site; Tailwind; evergreen browsers only";
// What a local scan found. With no scanner, send the same object with empty
// arrays - the field is always present in a real request.
$FACTS = [
"framework" => "html",
"stats" => ["lines" => 5, "elements" => 4, "interactive" => 1, "images" => 0,
"form_fields" => 1, "headings" => 0, "landmarks" => 0, "components" => 0],
"headings" => [],
"components" => [],
"issues" => [
["id" => "S-001", "rule" => "label-missing", "sc" => "1.3.1", "level" => "A",
"severity" => "serious", "line" => 3,
"snippet" => '<input type="email" placeholder="Email">',
"message" => "input has no associated label"],
["id" => "S-002", "rule" => "placeholder-as-label", "sc" => "3.3.2", "level" => "A",
"severity" => "moderate", "line" => 3,
"snippet" => '<input type="email" placeholder="Email">',
"message" => "placeholder is used in place of a label"],
],
"manual_hints" => ["forms", "colour_in_css"],
"clipped" => ["cut" => 0],
];
// Lane A - the audit.
$INPUT = [
"task" => "audit",
"code" => $CODE,
"framework" => "html",
"context_hint" => $HINT,
"prescan_facts" => $FACTS,
];
// Lane B - the rewrite of the same component, carrying the audit's findings.
$FIX_INPUT = $INPUT;
$FIX_INPUT["task"] = "rewrite";
$FIX_INPUT["constraints"] = "keep the class names; no new dependencies";
$FIX_INPUT["findings_to_fix"] = [
["id" => "F-001", "sc" => "1.3.1",
"summary" => "Email input has no associated label"],
["id" => "F-002", "sc" => "2.1.1",
"summary" => "A div is the submit control: not focusable and not operable by keyboard"],
];
$est = call("estimate", $INPUT);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
// estimate is free: no job is created and nothing is charged.
echo call("estimate", $FIX_INPUT)["hold_credits"], PHP_EOL; // price each lane separately
var componentCode = string.Join("\n", new[]
{
"<form>",
" <div>Email</div>",
" <input type=\"email\" placeholder=\"Email\">",
" <div class=\"btn\">Log in</div>",
"</form>",
});
var hint = "Login form on the marketing site; Tailwind; evergreen browsers only";
// What a local scan found. With no scanner, send the same object with empty
// arrays - the field is always present in a real request.
var facts = new Dictionary<string, object?>
{
["framework"] = "html",
["stats"] = new Dictionary<string, int>
{
["lines"] = 5, ["elements"] = 4, ["interactive"] = 1, ["images"] = 0,
["form_fields"] = 1, ["headings"] = 0, ["landmarks"] = 0, ["components"] = 0,
},
["headings"] = Array.Empty<string>(),
["components"] = Array.Empty<string>(),
["issues"] = new object[]
{
new { id = "S-001", rule = "label-missing", sc = "1.3.1", level = "A",
severity = "serious", line = 3,
snippet = "<input type=\"email\" placeholder=\"Email\">",
message = "input has no associated label" },
new { id = "S-002", rule = "placeholder-as-label", sc = "3.3.2", level = "A",
severity = "moderate", line = 3,
snippet = "<input type=\"email\" placeholder=\"Email\">",
message = "placeholder is used in place of a label" },
},
["manual_hints"] = new[] { "forms", "colour_in_css" },
["clipped"] = new { cut = 0 },
};
// Lane A - the audit.
var input = new Dictionary<string, object?>
{
["task"] = "audit",
["code"] = componentCode,
["framework"] = "html",
["context_hint"] = hint,
["prescan_facts"] = facts,
};
// Lane B - the rewrite of the same component, carrying the audit's findings.
var fixInput = new Dictionary<string, object?>(input)
{
["task"] = "rewrite",
["constraints"] = "keep the class names; no new dependencies",
["findings_to_fix"] = new object[]
{
new { id = "F-001", sc = "1.3.1",
summary = "Email input has no associated label" },
new { id = "F-002", sc = "2.1.1",
summary = "A div is the submit control: not focusable and not operable by keyboard" },
},
};
var est = await A11yDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")} {est.GetProperty("min_credits")}");
// estimate is free: no job is created and nothing is charged.
var fixEst = await A11yDesk.Call("estimate", fixInput); // price each lane separately
Console.WriteLine(fixEst.GetProperty("hold_credits"));
5. Run it, then poll
POST /run returns a job_id; poll GET jobs/{job_id} until
status is succeeded or failed. The result JSON is the string
at data.output.output. The terminal job also carries charged_credits —
the real price — and the truncated flag.
Always send an Idempotency-Key. The web app builds it as
a11y-desk:<task>:<input hash>:a<attempt> and so should you. Three
parts, three reasons:
- the task, because an audit and a rewrite of one component are two different runs and must never collide on one key;
- a hash of the input — of what a person actually chose:
task,code,framework,context_hint,constraintsand thefindings_to_fixids. The hash deliberately excludesprescan_facts, so re-running the same paste after improving your local scan is still the same run; - an attempt counter, because a retry with a changed body — a smaller component sent after a reply came back truncated — must not replay the old key, and a replay with a different body is rejected rather than billed.
A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a CI retry safe after a network blip. It also means the audit-then-rewrite handoff is cheap to re-run: the audit half replays, and only the rewrite is new work.
# The key is slug:task:hash:attempt. A retried request with the same key returns
# the SAME job instead of billing a second run.
KEY="a11y-desk:audit:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until the job reaches a terminal status.
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"audit\",\"title\":\"Login form - WCAG 2.2 audit\", ...}"},
# "charged_credits":588,"truncated":false}}
# The rewrite half of the handoff is the same call with the other body and its
# own key - never reuse the audit's key for it.
FIX_KEY="a11y-desk:rewrite:$(printf '%s' "$FIX_INPUT" | shasum -a 256 | cut -c1-16):a1"
import hashlib, time
def idempotency_key(body, attempt=1):
"""slug:task:hash:attempt - the hash covers what a person chose, not the scan."""
signal = json.dumps({
"task": body.get("task"),
"code": body.get("code"),
"framework": body.get("framework"),
"context_hint": body.get("context_hint", ""),
"constraints": body.get("constraints", ""),
"fix": [f["id"] for f in body.get("findings_to_fix", [])],
}, sort_keys=True)
digest = hashlib.sha256(signal.encode()).hexdigest()[:16]
return f"{SLUG}:{body.get('task')}:{digest}:a{attempt}"
def run(body, attempt=1, timeout=300):
job = call("run", body, {"Idempotency-Key": idempotency_key(body, attempt)})
jobId = job["job_id"]
deadline = time.time() + timeout
while time.time() < deadline:
state = call(f"jobs/{jobId}")
if state["status"] == "succeeded":
return state
if state["status"] == "failed":
raise RuntimeError(state.get("error") or "the run failed")
time.sleep(2)
raise TimeoutError("the run did not settle in time")
settled = run(INPUT)
print(settled["charged_credits"], settled.get("truncated")) # the real price, and the flag
raw = settled["output"]["output"] # a STRING holding one JSON object
fixed = run(FIX_INPUT) # the rewrite half of the handoff
import { createHash } from "node:crypto";
function idempotencyKey(body, attempt = 1) {
// slug:task:hash:attempt - the hash covers what a person chose, not the scan.
const signal = JSON.stringify({
task: body.task,
code: body.code,
framework: body.framework,
context_hint: body.context_hint || "",
constraints: body.constraints || "",
fix: (body.findings_to_fix || []).map((f) => f.id),
});
const digest = createHash("sha256").update(signal).digest("hex").slice(0, 16);
return `${SLUG}:${body.task}:${digest}:a${attempt}`;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function run(body, attempt = 1, timeoutMs = 300000) {
const job = await call("run", body, { "Idempotency-Key": idempotencyKey(body, attempt) });
const jobId = job.job_id;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const state = await call(`jobs/${jobId}`);
if (state.status === "succeeded") return state;
if (state.status === "failed") throw new Error(state.error || "the run failed");
await sleep(2000);
}
throw new Error("the run did not settle in time");
}
const settled = await run(INPUT);
console.log(settled.charged_credits, settled.truncated); // the real price, and the flag
const raw = settled.output.output; // a STRING holding one JSON object
const fixed = await run(FIX_INPUT); // the rewrite half of the handoff
// slug:task:hash:attempt - the hash covers what a person chose, not the scan.
func idempotencyKey(body map[string]any, attempt int) string {
signal := map[string]any{
"task": body["task"],
"code": body["code"],
"framework": body["framework"],
"context_hint": body["context_hint"],
"constraints": body["constraints"],
}
b, _ := json.Marshal(signal)
sum := sha256.Sum256(b)
return fmt.Sprintf("%s:%v:%s:a%d", slug, body["task"], hex.EncodeToString(sum[:])[:16], attempt)
}
func run(body map[string]any, attempt int) (map[string]any, error) {
raw, err := call("run", body, map[string]string{"Idempotency-Key": idempotencyKey(body, attempt)})
if err != nil {
return nil, err
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
stateRaw, err := call("jobs/"+started.JobID, nil, nil)
if err != nil {
return nil, err
}
var state map[string]any
_ = json.Unmarshal(stateRaw, &state)
switch state["status"] {
case "succeeded":
return state, nil
case "failed":
return nil, fmt.Errorf("the run failed: %v", state["error"])
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("the run did not settle in time")
}
settled, err := run(input, 1)
if err != nil {
panic(err)
}
fmt.Println(settled["charged_credits"], settled["truncated"])
output := settled["output"].(map[string]any)["output"].(string) // a STRING holding one JSON object
_ = output
fixed, _ := run(fixInput, 1) // the rewrite half of the handoff
_ = fixed
import java.security.MessageDigest;
import java.util.HexFormat;
// slug:task:hash:attempt. Hash whatever a person chose - the task, the markup,
// the framework, the hint, the constraints - but not prescan_facts.
static String idempotencyKey(String taskName, String body, int attempt) throws Exception {
var digest = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"));
var hex = HexFormat.of().formatHex(digest).substring(0, 16);
return SLUG + ":" + taskName + ":" + hex + ":a" + attempt;
}
static String run(String taskName, String body, int attempt) throws Exception {
var started = A11yDesk.call("run", body,
java.util.Map.of("Idempotency-Key", idempotencyKey(taskName, body, attempt)));
// {"ok":true,"data":{"job_id":"job_..."}}
var jobId = started.split("\"job_id\":\"")[1].split("\"")[0];
for (int i = 0; i < 150; i++) {
String state = A11yDesk.call("jobs/" + jobId, null, null);
if (state.contains("\"status\":\"succeeded\"")) return state;
if (state.contains("\"status\":\"failed\"")) throw new RuntimeException(state);
Thread.sleep(2000);
}
throw new RuntimeException("the run did not settle in time");
}
String settled = run("audit", INPUT, 1); // carries charged_credits and truncated
String fixed = run("rewrite", FIX_INPUT, 1);
require "digest"
# slug:task:hash:attempt - the hash covers what a person chose, not the scan.
def idempotency_key(body, attempt = 1)
signal = JSON.generate({
"task" => body["task"],
"code" => body["code"],
"framework" => body["framework"],
"context_hint" => body["context_hint"].to_s,
"constraints" => body["constraints"].to_s,
"fix" => (body["findings_to_fix"] || []).map { |f| f["id"] }
})
"#{SLUG}:#{body['task']}:#{Digest::SHA256.hexdigest(signal)[0, 16]}:a#{attempt}"
end
def run(body, attempt = 1, timeout = 300)
job = call("run", body, { "Idempotency-Key" => idempotency_key(body, attempt) })
jobId = job["job_id"]
giveUp = Time.now + timeout
while Time.now < giveUp
state = call("jobs/#{jobId}")
return state if state["status"] == "succeeded"
raise "the run failed: #{state['error']}" if state["status"] == "failed"
sleep 2
end
raise "the run did not settle in time"
end
settled = run(INPUT)
puts "#{settled['charged_credits']} #{settled['truncated']}"
raw = settled["output"]["output"] # a STRING holding one JSON object
fixed = run(FIX_INPUT) # the rewrite half of the handoff
<?php
// slug:task:hash:attempt - the hash covers what a person chose, not the scan.
function idempotency_key(array $body, int $attempt = 1): string {
$signal = json_encode([
"task" => $body["task"],
"code" => $body["code"],
"framework" => $body["framework"],
"context_hint" => $body["context_hint"] ?? "",
"constraints" => $body["constraints"] ?? "",
"fix" => array_map(fn($f) => $f["id"], $body["findings_to_fix"] ?? []),
]);
return SLUG . ":" . $body["task"] . ":" . substr(hash("sha256", $signal), 0, 16) . ":a" . $attempt;
}
function run(array $body, int $attempt = 1, int $timeout = 300): array {
$job = call("run", $body, ["Idempotency-Key: " . idempotency_key($body, $attempt)]);
$jobId = $job["job_id"];
$giveUp = time() + $timeout;
while (time() < $giveUp) {
$state = call("jobs/" . $jobId);
if ($state["status"] === "succeeded") return $state;
if ($state["status"] === "failed") {
throw new RuntimeException("the run failed");
}
sleep(2);
}
throw new RuntimeException("the run did not settle in time");
}
$settled = run($INPUT);
echo $settled["charged_credits"], " ", var_export($settled["truncated"], true), PHP_EOL;
$raw = $settled["output"]["output"]; // a STRING holding one JSON object
$fixed = run($FIX_INPUT); // the rewrite half of the handoff
using System.Security.Cryptography;
using System.Text;
// slug:task:hash:attempt - the hash covers what a person chose, not the scan.
static string IdempotencyKey(Dictionary<string, object?> body, int attempt = 1)
{
var signal = JsonSerializer.Serialize(new
{
task = body["task"],
code = body["code"],
framework = body["framework"],
context_hint = body.GetValueOrDefault("context_hint"),
constraints = body.GetValueOrDefault("constraints"),
});
var hex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(signal)))
.ToLowerInvariant()[..16];
return $"a11y-desk:{body["task"]}:{hex}:a{attempt}";
}
static async Task<JsonElement> Run(Dictionary<string, object?> body, int attempt = 1)
{
var started = await A11yDesk.Call("run", body,
("Idempotency-Key", IdempotencyKey(body, attempt)));
var jobId = started.GetProperty("job_id").GetString();
var deadline = DateTime.UtcNow.AddMinutes(5);
while (DateTime.UtcNow < deadline)
{
var state = await A11yDesk.Call($"jobs/{jobId}");
var status = state.GetProperty("status").GetString();
if (status == "succeeded") return state;
if (status == "failed") throw new Exception("the run failed");
await Task.Delay(2000);
}
throw new TimeoutException("the run did not settle in time");
}
var settled = await Run(input);
Console.WriteLine($"{settled.GetProperty("charged_credits")} {settled.GetProperty("truncated")}");
var raw = settled.GetProperty("output").GetProperty("output").GetString();
var fixedRun = await Run(fixInput); // the rewrite half of the handoff
6. Or stream it
POST /run-stream is the same call over server-sent events, and it takes the same
Idempotency-Key. Each delta event carries {"text": "..."}, a
chunk of the result JSON, and the final done event carries status,
charged_credits — the real price, normally a fraction of the hold — and the
truncated flag.
Read the SSE yourself. What a client receives depends on where it is: a
command-line reader like the ones below gets real delta events, while the same
endpoint sends a page in a browser tick heartbeats instead — so a JavaScript callback
wired to deltas never fires there, and any progress display, streaming preview or partial-recovery
path built on it is dead code in a browser. Parse the event stream in your own reader, as the
samples here do, and treat a run with no deltas at all as normal rather than as a stall: wait for
done, or fall back to /run and polling.
The practical tip: do not try to parse the partial JSON to drive a progress display — watch for key
names arriving in the accumulating text instead. In the audit lane the appearance of
"findings", then "passes", then "manual_checks", then
"keyboard_map", then "score" is what advances the stage from reading the
markup to naming what a person still has to test. In the rewrite lane the sequence is
"rewritten_code", "changes", "unresolved",
"behaviour_notes", "test_script" — and because
rewritten_code arrives first and is by far the largest string, a rewrite looks stalled
for most of its run unless you say so. Substring matching on the quoted key name is enough, and it
costs nothing.
# Server-sent events. From the command line each `delta` carries a chunk of the
# JSON; the final `done` event carries the status, charged_credits and truncated.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: delta
# data: {"text":"{\"lane\":\"audit\",\"title\":\"Login form"}
# event: delta
# data: {"text":" - WCAG 2.2 audit\",\"findings\":[{"}
# event: done
# data: {"status":"succeeded","charged_credits":588,"truncated":false}
#
# A browser is sent `tick` heartbeats instead of deltas. Read the stream in your
# own reader, or fall back to /run and polling.
import urllib.request
body = json.dumps(INPUT).encode()
req = urllib.request.Request(f"{BASE}/run-stream", data=body, method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Idempotency-Key", idempotency_key(INPUT))
text, event, stage = "", "", ""
STAGES = ['"findings"', '"passes"', '"manual_checks"', '"keyboard_map"', '"score"']
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode("utf-8").rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
text += payload.get("text", "")
for name in STAGES: # progress without parsing partial JSON
if name in text and stage != name:
stage = name
print("reached", name)
elif event == "done":
print(payload["status"], payload["charged_credits"], payload["truncated"])
result = json.loads(text[text.index("{"):text.rindex("}") + 1])
const stream = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"Idempotency-Key": idempotencyKey(INPUT),
},
body: JSON.stringify(INPUT),
});
const STAGES = ['"findings"', '"passes"', '"manual_checks"', '"keyboard_map"', '"score"'];
const decoder = new TextDecoder();
let buffer = "", text = "", event = "", stage = "";
for await (const chunk of stream.body) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const payload = JSON.parse(line.slice(6));
if (event === "delta") {
text += payload.text || "";
for (const name of STAGES) {
if (text.includes(name) && stage !== name) { stage = name; console.log("reached", name); }
}
} else if (event === "done") {
console.log(payload.status, payload.charged_credits, payload.truncated);
}
}
}
}
const result = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
b, _ := json.Marshal(input)
sreq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(b))
sreq.Header.Set("Authorization", "Bearer "+token)
sreq.Header.Set("Content-Type", "application/json")
sreq.Header.Set("Accept", "text/event-stream")
sreq.Header.Set("Idempotency-Key", idempotencyKey(input, 1))
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
stages := []string{`"findings"`, `"passes"`, `"manual_checks"`, `"keyboard_map"`, `"score"`}
var text strings.Builder
event := ""
scanner := bufio.NewScanner(sres.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var payload map[string]any
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload)
if event == "delta" {
if chunk, ok := payload["text"].(string); ok {
text.WriteString(chunk)
}
for _, name := range stages { // progress without parsing partial JSON
if strings.Contains(text.String(), name) {
_ = name
}
}
} else if event == "done" {
fmt.Println(payload["status"], payload["charged_credits"], payload["truncated"])
}
}
}
whole := text.String()
result := whole[strings.Index(whole, "{") : strings.LastIndex(whole, "}")+1]
fmt.Println(len(result))
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
var url = new URL(BASE + "/run-stream");
var conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Authorization", "Bearer " + TOKEN);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Idempotency-Key", idempotencyKey("audit", INPUT, 1));
conn.getOutputStream().write(INPUT.getBytes("UTF-8"));
var text = new StringBuilder();
String event = "";
try (var in = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"))) {
String line;
while ((line = in.readLine()) != null) {
if (line.startsWith("event: ")) {
event = line.substring(7);
} else if (line.startsWith("data: ")) {
String data = line.substring(6);
if (event.equals("delta")) {
// {"text":"..."} - the chunk is a JSON string, so unescape it properly
// in real code; the key names arriving in `text` are your progress bar.
text.append(data);
} else if (event.equals("done")) {
System.out.println(data); // status, charged_credits, truncated
}
}
}
}
require "net/http"
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Idempotency-Key"] = idempotency_key(INPUT)
req.body = JSON.generate(INPUT)
STAGES = ['"findings"', '"passes"', '"manual_checks"', '"keyboard_map"', '"score"'].freeze
text = +""
event = ""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ")
payload = JSON.parse(line[6..])
if event == "delta"
text << payload.fetch("text", "")
STAGES.each { |name| puts "reached #{name}" if text.end_with?(name) }
elsif event == "done"
puts "#{payload['status']} #{payload['charged_credits']} #{payload['truncated']}"
end
end
end
end
end
end
result = JSON.parse(text[text.index("{")..text.rindex("}")])
<?php
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: " . idempotency_key($INPUT),
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($INPUT));
$text = "";
$event = "";
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$text, &$event) {
foreach (preg_split("/\r?\n/", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$payload = json_decode(substr($line, 6), true);
if ($event === "delta") {
$text .= $payload["text"] ?? "";
} elseif ($event === "done") {
echo $payload["status"], " ", $payload["charged_credits"], PHP_EOL;
}
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$result = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
var streamReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")}");
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", IdempotencyKey(input));
streamReq.Content = JsonContent.Create(input);
using var http = new HttpClient();
using var response = await http.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync());
var text = new StringBuilder();
var evt = "";
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: "))
{
var payload = JsonDocument.Parse(line[6..]).RootElement;
if (evt == "delta" && payload.TryGetProperty("text", out var t))
text.Append(t.GetString());
else if (evt == "done")
Console.WriteLine($"{payload.GetProperty("status")} {payload.GetProperty("charged_credits")}");
}
}
var whole = text.ToString();
var result = whole[whole.IndexOf('{')..(whole.LastIndexOf('}') + 1)];
7. Parse the result
data.output.output is a string holding one JSON object — unwrap
twice. The web app strips an optional code fence, takes everything from the first { to
the last }, parses that, and only then reads fields. Doing the same two things — the
fence strip and the outer-brace slice — is what makes a caller robust against the small variations
a model produces around an otherwise clean object.
Here is an abbreviated audit reply for the login form above, structurally complete:
{
"lane": "audit",
"title": "Login form - WCAG 2.2 AA audit",
"summary": "The form has two controls and neither is usable as written. The email field has no programmatic label, so a screen reader announces an unnamed edit box, and the submit control is a div, which no keyboard user can reach or activate. Fixing the submit control is the difference between a form that cannot be completed and one that merely reads badly.",
"component": "login form",
"verdict": "blocked",
"verdict_reason": "The submit control is a plain div: it is not focusable and not operable by keyboard, so the form cannot be submitted at all without a mouse.",
"findings": [
{ "id": "F-001", "sc": "1.3.1", "sc_name": "Info and Relationships", "level": "A",
"severity": "serious", "principle": "perceivable", "affects": ["screen_reader"],
"line": 3, "snippet": "<input type=\"email\" placeholder=\"Email\">",
"problem": "The text 'Email' sits in a sibling div and in the placeholder, neither of which is a programmatic label. A screen reader user hears 'edit, blank' and has no way to know what the field wants; the placeholder also disappears the moment they start typing.",
"fix": "Give the input an id and associate a real label with it, and drop the placeholder or make it an example value rather than the field's name.",
"fix_code": "<label for=\"email\">Email</label>\n<input id=\"email\" type=\"email\" name=\"email\" autocomplete=\"email\" required>" },
{ "id": "F-002", "sc": "2.1.1", "sc_name": "Keyboard", "level": "A",
"severity": "blocker", "principle": "operable",
"affects": ["keyboard", "screen_reader", "motor"],
"line": 4, "snippet": "<div class=\"btn\">Log in</div>",
"problem": "The submit control is a div. It is not in the tab order, it exposes no button role, and Enter and Space do nothing on it, so a keyboard user, a screen reader user and anyone using switch access cannot log in at all.",
"fix": "Use a real button element and keep the class so the styling is unchanged.",
"fix_code": "<button type=\"submit\" class=\"btn\">Log in</button>" }
],
"passes": [
{ "sc": "1.1.1", "sc_name": "Non-text Content", "note": "the component carries no images or icons, so nothing here needs a text alternative" }
],
"manual_checks": [
{ "id": "M-001", "sc": "2.4.7", "what": "focus is visible on every control",
"how": "Tab through the form; each control must show a focus indicator with at least 3:1 contrast against its background. The Tailwind class on the button may remove the default outline." },
{ "id": "M-002", "sc": "1.4.3", "what": "the label and the button text meet contrast",
"how": "Sample the rendered colours with a contrast checker: 4.5:1 for the label text, 3:1 for the button's border against the page." }
],
"keyboard_map": [
{ "element": "Email field", "keys": "Tab", "expected": "focus lands in the field and its name is announced", "status": "missing" },
{ "element": "Log in control", "keys": "Tab, Enter, Space", "expected": "submits the form", "status": "missing" }
],
"score": { "blocker": 1, "serious": 1, "moderate": 0, "minor": 0 },
"coverage": [
{ "id": "S-001", "status": "confirmed", "ref": "F-001", "note": "" },
{ "id": "S-002", "status": "merged", "ref": "F-001", "note": "The placeholder-as-label hit is the same defect as the missing label, so it is one finding, not two." }
],
"credential_seen": false,
"notes_on_input": ""
}
And the rewrite reply for the same component, carrying the two findings the audit produced:
{
"lane": "rewrite",
"title": "Login form - accessible rewrite",
"summary": "Both findings are closed in markup alone: the email field gets a real label and an autocomplete token, and the div becomes a submit button with its class kept. One thing is left open, because the component has nowhere to put a sign-in error.",
"framework": "html",
"rewritten_code": "<form>\n <label for=\"email\">Email</label>\n <input id=\"email\" type=\"email\" name=\"email\" autocomplete=\"email\" required>\n <!-- TODO: render the sign-in error here and move focus to it -->\n <button type=\"submit\" class=\"btn\">Log in</button>\n</form>",
"changes": [
{ "id": "C-001", "sc": "1.3.1",
"what": "Replaced the text div with a real label bound to the input by id, and moved the field name out of the placeholder.",
"before": "<div>Email</div>",
"after": "<label for=\"email\">Email</label>",
"fixes": ["F-001"] },
{ "id": "C-002", "sc": "2.1.1",
"what": "Turned the styled div into a submit button, keeping the btn class so nothing changes visually.",
"before": "<div class=\"btn\">Log in</div>",
"after": "<button type=\"submit\" class=\"btn\">Log in</button>",
"fixes": ["F-002"] }
],
"unresolved": [
{ "id": "U-001", "sc": "3.3.1",
"why": "The component has no error region, and the markup alone does not say where a failed sign-in message should appear or what it says.",
"placeholder": "<!-- TODO: render the sign-in error here and move focus to it -->" }
],
"behaviour_notes": [
"A submit button posts the form; if the sign-in is handled in script, the handler must listen for submit on the form rather than for click on the button, or keyboard submission is lost again.",
"The error region added at the TODO needs aria-live=\"polite\" or focus moved into it, otherwise a screen reader user never learns the attempt failed."
],
"test_script": [
{ "step": "Tab once from the top of the form", "expect": "focus lands in the email field and 'Email, edit' is announced" },
{ "step": "Tab again, then press Enter", "expect": "focus is on the Log in button and the form submits" },
{ "step": "Submit with an empty field", "expect": "the browser's own required-field message names the Email field" }
],
"coverage": [
{ "id": "S-001", "status": "confirmed", "ref": "C-001", "note": "" },
{ "id": "S-002", "status": "merged", "ref": "C-001", "note": "The placeholder went away with the same change." }
],
"credential_seen": false,
"notes_on_input": ""
}
The verdict rule
The audit verdict is not free-form, and the page re-derives it rather than trusting it — treating
the findings list as authoritative and warning when the returned
verdict disagrees:
blocked if any finding has severity "blocker"
needs_work else if any finding is "serious" or "moderate"
ready_for_manual_testing otherwise - minor findings only, or none at all
Do the same. A gate that reads verdict alone can be talked out of failing by a reply
that lists a blocker and then calls itself needs_work. And note what the top verdict
means: blocked is not "bad", it is "a user of one assistive technology cannot complete
this component's purpose at all" — an unlabeled field they cannot identify, a control unreachable
by keyboard, a modal with no name and no way out. ready_for_manual_testing is the
ceiling, not a pass: it says static review found nothing more, and the
manual_checks list is now the work.
While you are there, check score against findings. It is four counts by
severity over the same list, so it is the cheapest possible test that the reply is internally
consistent, and a reply whose score disagrees with its own findings is one whose verdict you should
not trust either.
The checks the page runs on a rewrite
A rewrite is easier to get subtly wrong than an audit, because it produces one long string that looks plausible whatever is in it. These are the five checks the web app performs on every rewrite reply, all of them cheap and all of them worth copying:
- every
unresolved[].placeholderstring appears verbatim inrewritten_code— an unresolved item that is not actually marked in the code is a TODO nobody will ever find; - every
changes[].afterappears verbatim inrewritten_code— this is what stops a change log describing edits the code does not contain; - every id in
changes[].fixesis an id you actually sent infindings_to_fix, when you sent that list at all; frameworkin the reply equals the framework you sent, unlessnotes_on_inputexplains why not — a JSX component that comes back as HTML is not a rewrite you can merge;- the free scanner, re-run on
rewritten_code, reports fewer issues than it reported on the input. This is the only check that tests the rewrite against something other than its own description of itself, and it is the one to port if you port just one.
The last one needs a scanner you do not have over HTTP — so the API equivalent is to
re-audit: post rewritten_code back into the audit lane
and compare score with the first run's. That costs a second run and is worth it in CI,
where the alternative is merging a rewrite nobody checked.
# The result JSON is a string inside the envelope, so unwrap it twice.
RESULT=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')
# Strip an optional code fence and keep the outer {...}, then re-derive the verdict.
printf '%s' "$RESULT" | python3 - <<'PY'
import json, sys
raw = sys.stdin.read().strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
obj = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
sev = [f.get("severity") for f in obj.get("findings", [])]
want = ("blocked" if "blocker" in sev
else "needs_work" if ("serious" in sev or "moderate" in sev)
else "ready_for_manual_testing")
print(obj["lane"], obj.get("verdict"), "re-derived:", want)
if obj.get("verdict") != want:
print("WARNING: the reply's verdict disagrees with its own findings", file=sys.stderr)
PY
def parse(settled):
"""Envelope -> string -> object, the way the web page does it."""
raw = settled["output"]["output"].strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
def verdict_from(findings):
sev = {f.get("severity") for f in findings}
if "blocker" in sev:
return "blocked"
if sev & {"serious", "moderate"}:
return "needs_work"
return "ready_for_manual_testing"
obj = parse(settled)
assert obj["lane"] in ("audit", "rewrite"), obj.get("lane")
if obj["lane"] == "audit":
findings = obj.get("findings", [])
want = verdict_from(findings)
if obj.get("verdict") != want:
print("WARNING: verdict", obj.get("verdict"), "but findings say", want)
score = obj.get("score", {})
for level in ("blocker", "serious", "moderate", "minor"):
counted = sum(1 for f in findings if f.get("severity") == level)
assert score.get(level, 0) == counted, f"score.{level} disagrees with findings"
sent = {i["id"] for i in INPUT["prescan_facts"]["issues"]}
seen = [c["id"] for c in obj.get("coverage", [])]
assert sorted(seen) == sorted(sent), "coverage must answer every scanner id exactly once"
else:
code = obj["rewritten_code"]
for change in obj.get("changes", []):
assert change["after"] in code, f"{change['id']}: after is not in rewritten_code"
for fid in change.get("fixes", []):
assert fid in {f["id"] for f in FIX_INPUT["findings_to_fix"]}, fid
for item in obj.get("unresolved", []):
assert item["placeholder"] in code, f"{item['id']}: placeholder is not in the code"
assert obj["framework"] == FIX_INPUT["framework"] or obj.get("notes_on_input")
function parse(settled) {
let raw = settled.output.output.trim();
if (raw.startsWith("```")) raw = raw.slice(raw.indexOf("\n") + 1, raw.lastIndexOf("```"));
return JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
}
function verdictFrom(findings) {
const sev = new Set(findings.map((f) => f.severity));
if (sev.has("blocker")) return "blocked";
if (sev.has("serious") || sev.has("moderate")) return "needs_work";
return "ready_for_manual_testing";
}
const obj = parse(settled);
if (obj.lane === "audit") {
const want = verdictFrom(obj.findings || []);
if (obj.verdict !== want) console.warn("verdict", obj.verdict, "but findings say", want);
for (const level of ["blocker", "serious", "moderate", "minor"]) {
const counted = (obj.findings || []).filter((f) => f.severity === level).length;
if ((obj.score || {})[level] !== counted) throw new Error(`score.${level} disagrees`);
}
const sent = INPUT.prescan_facts.issues.map((i) => i.id).sort();
const seen = (obj.coverage || []).map((c) => c.id).sort();
if (JSON.stringify(sent) !== JSON.stringify(seen)) {
throw new Error("coverage must answer every scanner id exactly once");
}
} else {
const code = obj.rewritten_code;
const sent = new Set((FIX_INPUT.findings_to_fix || []).map((f) => f.id));
for (const change of obj.changes || []) {
if (!code.includes(change.after)) throw new Error(`${change.id}: after is not in rewritten_code`);
for (const fid of change.fixes || []) if (!sent.has(fid)) throw new Error(`unknown id ${fid}`);
}
for (const item of obj.unresolved || []) {
if (!code.includes(item.placeholder)) throw new Error(`${item.id}: placeholder is not in the code`);
}
if (obj.framework !== FIX_INPUT.framework && !obj.notes_on_input) {
throw new Error("the rewrite changed framework without saying why");
}
}
type finding struct {
ID string `json:"id"`
Severity string `json:"severity"`
}
type auditReply struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Findings []finding `json:"findings"`
Score map[string]int `json:"score"`
Coverage []struct {
ID string `json:"id"`
Ref string `json:"ref"`
} `json:"coverage"`
}
func sliceObject(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "```") {
raw = raw[strings.Index(raw, "\n")+1 : strings.LastIndex(raw, "```")]
}
return raw[strings.Index(raw, "{") : strings.LastIndex(raw, "}")+1]
}
func verdictFrom(fs []finding) string {
sev := map[string]bool{}
for _, f := range fs {
sev[f.Severity] = true
}
if sev["blocker"] {
return "blocked"
}
if sev["serious"] || sev["moderate"] {
return "needs_work"
}
return "ready_for_manual_testing"
}
var reply auditReply
_ = json.Unmarshal([]byte(sliceObject(output)), &reply)
if want := verdictFrom(reply.Findings); reply.Verdict != want {
fmt.Println("WARNING: verdict", reply.Verdict, "but findings say", want)
}
for _, level := range []string{"blocker", "serious", "moderate", "minor"} {
counted := 0
for _, f := range reply.Findings {
if f.Severity == level {
counted++
}
}
if reply.Score[level] != counted {
panic("score." + level + " disagrees with findings")
}
}
// The reply is a string holding one JSON object. Strip an optional fence, keep
// everything between the first { and the last }, then parse with whatever JSON
// library the project already uses.
static String sliceObject(String raw) {
raw = raw.strip();
if (raw.startsWith("```")) {
raw = raw.substring(raw.indexOf('\n') + 1, raw.lastIndexOf("```"));
}
return raw.substring(raw.indexOf('{'), raw.lastIndexOf('}') + 1);
}
// Re-derive the verdict from the severities rather than trusting the field.
static String verdictFrom(java.util.List<String> severities) {
if (severities.contains("blocker")) return "blocked";
if (severities.contains("serious") || severities.contains("moderate")) return "needs_work";
return "ready_for_manual_testing";
}
String body = sliceObject(settled); // settled is data.output.output
System.out.println(body.substring(0, Math.min(200, body.length())));
// Then: assert score matches the findings, assert every prescan issue id appears
// once in coverage, and for a rewrite assert every changes[].after occurs
// verbatim inside rewritten_code.
def parse_reply(settled)
raw = settled["output"]["output"].strip
raw = raw.split("\n", 2)[1].rpartition("```").first if raw.start_with?("```")
JSON.parse(raw[raw.index("{")..raw.rindex("}")])
end
def verdict_from(findings)
sev = findings.map { |f| f["severity"] }
return "blocked" if sev.include?("blocker")
return "needs_work" if sev.include?("serious") || sev.include?("moderate")
"ready_for_manual_testing"
end
obj = parse_reply(settled)
if obj["lane"] == "audit"
want = verdict_from(obj["findings"])
warn "verdict #{obj['verdict']} but findings say #{want}" if obj["verdict"] != want
%w[blocker serious moderate minor].each do |level|
counted = obj["findings"].count { |f| f["severity"] == level }
raise "score.#{level} disagrees" unless obj["score"][level].to_i == counted
end
else
code = obj["rewritten_code"]
obj["changes"].each { |c| raise "#{c['id']} after missing" unless code.include?(c["after"]) }
obj["unresolved"].each { |u| raise "#{u['id']} placeholder missing" unless code.include?(u["placeholder"]) }
end
<?php
function parse_reply(array $settled): array {
$raw = trim($settled["output"]["output"]);
if (str_starts_with($raw, "```")) {
$raw = substr($raw, strpos($raw, "\n") + 1, strrpos($raw, "```") - strpos($raw, "\n") - 1);
}
$start = strpos($raw, "{");
return json_decode(substr($raw, $start, strrpos($raw, "}") - $start + 1), true);
}
function verdict_from(array $findings): string {
$sev = array_column($findings, "severity");
if (in_array("blocker", $sev, true)) return "blocked";
if (array_intersect(["serious", "moderate"], $sev)) return "needs_work";
return "ready_for_manual_testing";
}
$obj = parse_reply($settled);
if ($obj["lane"] === "audit") {
$want = verdict_from($obj["findings"]);
if ($obj["verdict"] !== $want) {
fwrite(STDERR, "WARNING: verdict {$obj['verdict']} but findings say {$want}\n");
}
foreach (["blocker", "serious", "moderate", "minor"] as $level) {
$counted = count(array_filter($obj["findings"], fn($f) => $f["severity"] === $level));
if (($obj["score"][$level] ?? 0) !== $counted) {
throw new RuntimeException("score.$level disagrees with findings");
}
}
} else {
$code = $obj["rewritten_code"];
foreach ($obj["changes"] as $c) {
if (!str_contains($code, $c["after"])) throw new RuntimeException($c["id"] . ": after missing");
}
foreach ($obj["unresolved"] as $u) {
if (!str_contains($code, $u["placeholder"])) throw new RuntimeException($u["id"] . ": placeholder missing");
}
}
static JsonElement ParseReply(JsonElement settled)
{
var raw = settled.GetProperty("output").GetProperty("output").GetString()!.Trim();
if (raw.StartsWith("```"))
raw = raw[(raw.IndexOf('\n') + 1)..raw.LastIndexOf("```", StringComparison.Ordinal)];
var body = raw[raw.IndexOf('{')..(raw.LastIndexOf('}') + 1)];
return JsonDocument.Parse(body).RootElement;
}
static string VerdictFrom(IEnumerable<string> severities)
{
var sev = severities.ToHashSet();
if (sev.Contains("blocker")) return "blocked";
if (sev.Contains("serious") || sev.Contains("moderate")) return "needs_work";
return "ready_for_manual_testing";
}
var obj = ParseReply(settled);
if (obj.GetProperty("lane").GetString() == "audit")
{
var findings = obj.GetProperty("findings").EnumerateArray().ToList();
var want = VerdictFrom(findings.Select(f => f.GetProperty("severity").GetString()!));
if (obj.GetProperty("verdict").GetString() != want)
Console.Error.WriteLine($"WARNING: verdict disagrees with the findings, expected {want}");
foreach (var level in new[] { "blocker", "serious", "moderate", "minor" })
{
var counted = findings.Count(f => f.GetProperty("severity").GetString() == level);
if (obj.GetProperty("score").GetProperty(level).GetInt32() != counted)
throw new Exception($"score.{level} disagrees with findings");
}
}
else
{
var code = obj.GetProperty("rewritten_code").GetString()!;
foreach (var change in obj.GetProperty("changes").EnumerateArray())
if (!code.Contains(change.GetProperty("after").GetString()!))
throw new Exception($"{change.GetProperty("id")}: after is not in rewritten_code");
foreach (var item in obj.GetProperty("unresolved").EnumerateArray())
if (!code.Contains(item.GetProperty("placeholder").GetString()!))
throw new Exception($"{item.GetProperty("id")}: placeholder is not in the code");
}
The output contract
One JSON object, no prose and no code fence around it. First the envelope both lanes share:
| key | type | meaning |
|---|---|---|
lane | enum | audit or rewrite — the lane the model actually answered, and therefore which contract the rest of the object follows. Branch on this, not on the task you sent. |
title | string | A short name for this run, taken from the component's own subject — "Login form - WCAG 2.2 AA audit". |
summary | string | Two to four sentences: what the component is and the one thing the reader must know about it. |
coverage | object[] | {id, status, ref, note} — the reconciliation table, one entry per prescan_facts.issues[] id, exactly once. status is confirmed (it became a finding or a change, and ref is that F- or C- id), downgraded (real, but less severe than the scanner claimed — ref set, note says why), dismissed (a scanner false positive — ref is "" and note says why) or merged (folded into another finding, whose id is ref). |
credential_seen | boolean | true when the pasted markup looked like it carried a password, API key or token — a hard-coded bearer in a fetch call, a key in a data attribute. The reply then repeats no part of the value anywhere. Treat it as a signal to rotate, and keep it out of your logs. |
notes_on_input | string | "" when there is nothing to say. Carries: that the middle of the markup was clipped, that the task was missing or unrecognised and which lane was answered instead, and that the detected framework disagrees with the one you sent. |
The audit body
| key | type | meaning |
|---|---|---|
component | string | What the markup is, in the component's own words — "login form", "pricing table", "nav drawer". |
verdict | enum | blocked, needs_work or ready_for_manual_testing. Derived, not chosen — see the verdict rule above, and re-derive it rather than trusting it. |
verdict_reason | string | One sentence naming the finding or the fact that decided the verdict. |
findings | object[] | {id, sc, sc_name, level, severity, principle, affects, line, snippet, problem, fix, fix_code}. Ids run F-001 upward. sc is the dotted success-criterion number and sc_name its title; level is A, AA or AAA; affects names who is blocked; line is an integer or null; snippet is verbatim from your code, up to 200 characters; problem and fix are one to three sentences each; fix_code is the corrected markup and may be "" when the fix is not a markup change. |
passes | object[] | {sc, sc_name, note} — criteria this component actually meets. Shipping what passed is what makes the findings list auditable rather than a wall of complaints. |
manual_checks | object[] | {id, sc, what, how}, ids M-001 upward. The work static review cannot do: contrast against rendered colours, a visible focus ring, a screen-reader pass. how is the actual procedure, not a restatement of the criterion. |
keyboard_map | object[] | {element, keys, expected, status} with status in ok, missing, unknown. One row per interactive element: what keys should do, and whether the markup as written delivers it. unknown is the honest answer when the behaviour lives in script you did not paste. |
score | object | {blocker, serious, moderate, minor} — counts over findings, and they must match it. The cheapest internal-consistency test in the reply. |
The rewrite body
| key | type | meaning |
|---|---|---|
framework | enum | Same enum as the input. The rewrite keeps the framework you pasted; anything else has to be explained in notes_on_input. |
rewritten_code | string | The whole component again, fixed, as one JSON string. Not a diff and not an excerpt — it is meant to replace the file's component wholesale. |
changes | object[] | {id, sc, what, before, after, fixes}, ids C-001 upward. before is verbatim from your input and after verbatim from rewritten_code, each up to 200 characters, so a reviewer can find both ends of every edit. fixes lists the findings_to_fix ids the change closes, and is [] when you sent no findings. |
unresolved | object[] | {id, sc, why, placeholder}, ids U-001 upward. What the markup alone cannot settle — the meaning of a chart, the text of an error nobody wrote. placeholder is the marker left in rewritten_code, and it appears there verbatim. |
behaviour_notes | string[] | What needs script rather than markup: a focus trap, an Escape handler, a live region, focus returned to the control that opened a dialog. These are the things a rewrite cannot do for you and must not pretend to have done. |
test_script | object[] | {step, expect} — a short by-hand pass over the rewritten component, in the order a person would actually perform it. |
The enums
taskandlane:audit,rewriteframework:html,jsx,vue,svelte,angular,unknownverdict:blocked,needs_work,ready_for_manual_testingseverity:blocker,serious,moderate,minorprinciple:perceivable,operable,understandable,robustaffects:screen_reader,keyboard,low_vision,motor,cognitive,deaf_hoh,vestibular— an array, and a finding may name severalcoverage[].status:confirmed,downgraded,dismissed,mergedkeyboard_map[].status:ok,missing,unknownlevel:A,AA,AAA
Ids are zero-padded to three digits and sequential from 001, with the letter naming
what they are: S- a scanner issue you sent, F- an audit finding,
M- a manual check, C- a rewrite change, U- something the
rewrite could not resolve. That is what makes the handoff mechanical — a findings_to_fix
entry is an F- id, and the changes[].fixes that comes back names it.
8. Use it in CI
The worked example: a job reads a component out of the repository, audits it, prints every finding
with its success criterion, and exits non-zero when the component is unusable — when the
verdict is blocked, or when any finding carries a severity of
blocker or serious. Both halves of that test matter. The verdict alone
misses a reply that lists three serious findings and calls itself needs_work; the
severities alone miss nothing, but re-deriving the verdict from them and comparing is what catches
a reply that is internally inconsistent.
Let moderate and minor through and report them. A gate that fails on every
minor finding in a living component library becomes noise people learn to skip, and the findings
that actually block someone are then invisible. Derive the Idempotency-Key from the
component's contents so a re-run of the same commit replays the same job instead of re-billing, and
bump the attempt suffix only when the markup really changed.
The script below sends prescan_facts with empty arrays, which is the honest thing for
a caller with no local scanner — and it means coverage comes back empty, so the gate
leans on the findings and the verdict rather than on reconciliation. If your project already runs
an accessibility linter, map its output into the issues shape and send it: the
reconciliation check is the strongest signal in the reply, and it is how you find out which of your
linter's hits are false positives.
The workflow that runs it, as GitHub Actions YAML:
name: accessibility
on:
pull_request:
paths:
- "src/components/**"
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Audit the login form
env:
SKILLSAFE_TOKEN: ${{ secrets.SKILLSAFE_TOKEN }}
run: python3 ci/a11y-gate.py src/components/LoginForm.html
#!/bin/sh
# a11y-gate.sh - fail the build when a component cannot be used.
# Usage: a11y-gate.sh src/components/LoginForm.html
set -eu
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"
FILE="$1"
# Build the body with python3 so the markup is JSON-escaped correctly.
BODY=$(python3 - "$FILE" <<'PY'
import json, sys
markup = open(sys.argv[1], encoding="utf-8").read()
print(json.dumps({
"task": "audit",
"code": markup,
"framework": "html",
"context_hint": "Component from the application repository, audited in CI",
"prescan_facts": {
"framework": "html",
"stats": {"lines": markup.count("\n") + 1, "elements": 0, "interactive": 0,
"images": 0, "form_fields": 0, "headings": 0, "landmarks": 0,
"components": 0},
"headings": [], "components": [], "issues": [],
"manual_hints": [], "clipped": {"cut": 0},
},
}))
PY
)
KEY="a11y-desk:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
STATE=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
S=$(printf '%s' "$STATE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = "succeeded" ] && break
[ "$S" = "failed" ] && echo "$STATE" && exit 2
sleep 2
done
printf '%s' "$STATE" | python3 - <<'PY'
import json, sys
job = json.load(sys.stdin)["data"]
raw = job["output"]["output"].strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
obj = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
for f in obj.get("findings", []):
print(f"{f['severity']:8} {f['sc']:6} {f['id']} {f['problem'][:90]}")
sev = [f.get("severity") for f in obj.get("findings", [])]
hard = [s for s in sev if s in ("blocker", "serious")]
print(f"verdict={obj.get('verdict')} charged={job.get('charged_credits')} truncated={job.get('truncated')}")
if job.get("truncated"):
sys.exit("the reply was truncated - top up and re-run, do not gate on a prefix")
sys.exit(1 if obj.get("verdict") == "blocked" or hard else 0)
PY
#!/usr/bin/env python3
"""a11y-gate.py - fail the build when a component cannot be used.
Usage: a11y-gate.py src/components/LoginForm.html
"""
import sys
markup = open(sys.argv[1], encoding="utf-8").read()
BODY = {
"task": "audit",
"code": markup,
"framework": "html",
"context_hint": "Component from the application repository, audited in CI",
# No local scanner in CI: send the object with empty arrays. coverage then
# comes back empty, so the gate leans on the findings instead.
"prescan_facts": {
"framework": "html",
"stats": {"lines": markup.count("\n") + 1, "elements": 0, "interactive": 0,
"images": 0, "form_fields": 0, "headings": 0, "landmarks": 0,
"components": 0},
"headings": [], "components": [], "issues": [],
"manual_hints": [], "clipped": {"cut": 0},
},
}
settled = run(BODY) # from step 5
obj = parse(settled) # from step 7
for f in obj.get("findings", []):
print(f"{f['severity']:8} {f['sc']:6} {f['id']} {f['problem'][:90]}")
for m in obj.get("manual_checks", []):
print(f"manual {m['sc']:6} {m['id']} {m['what']}")
derived = verdict_from(obj.get("findings", []))
if derived != obj.get("verdict"):
print(f"note: the reply said {obj.get('verdict')}, its findings say {derived}")
print(f"verdict={obj.get('verdict')} charged={settled.get('charged_credits')}")
if settled.get("truncated"):
raise SystemExit("the reply was truncated - top up and re-run, do not gate on a prefix")
hard = [f for f in obj.get("findings", []) if f.get("severity") in ("blocker", "serious")]
if obj.get("verdict") == "blocked" or derived == "blocked" or hard:
raise SystemExit(f"{len(hard)} blocking or serious finding(s) - failing the build")
#!/usr/bin/env node
// a11y-gate.mjs - fail the build when a component cannot be used.
// Usage: node a11y-gate.mjs src/components/LoginForm.html
import { readFileSync } from "node:fs";
const markup = readFileSync(process.argv[2], "utf8");
const BODY = {
task: "audit",
code: markup,
framework: "html",
context_hint: "Component from the application repository, audited in CI",
// No local scanner in CI: send the object with empty arrays.
prescan_facts: {
framework: "html",
stats: { lines: markup.split("\n").length, elements: 0, interactive: 0, images: 0,
form_fields: 0, headings: 0, landmarks: 0, components: 0 },
headings: [], components: [], issues: [], manual_hints: [], clipped: { cut: 0 },
},
};
const settled = await run(BODY); // from step 5
const obj = parse(settled); // from step 7
for (const f of obj.findings || []) {
console.log(`${f.severity.padEnd(8)} ${f.sc.padEnd(6)} ${f.id} ${f.problem.slice(0, 90)}`);
}
for (const m of obj.manual_checks || []) {
console.log(`manual ${m.sc.padEnd(6)} ${m.id} ${m.what}`);
}
const derived = verdictFrom(obj.findings || []);
if (derived !== obj.verdict) console.log(`note: reply said ${obj.verdict}, findings say ${derived}`);
console.log(`verdict=${obj.verdict} charged=${settled.charged_credits}`);
if (settled.truncated) {
console.error("the reply was truncated - top up and re-run, do not gate on a prefix");
process.exit(2);
}
const hard = (obj.findings || []).filter((f) => ["blocker", "serious"].includes(f.severity));
if (obj.verdict === "blocked" || derived === "blocked" || hard.length) {
console.error(`${hard.length} blocking or serious finding(s) - failing the build`);
process.exit(1);
}
// a11y-gate.go - fail the build when a component cannot be used.
// Usage: go run a11y-gate.go src/components/LoginForm.html
func gate(path string) int {
markup, err := os.ReadFile(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
body := map[string]any{
"task": "audit",
"code": string(markup),
"framework": "html",
"context_hint": "Component from the application repository, audited in CI",
// No local scanner in CI: send the object with empty slices.
"prescan_facts": map[string]any{
"framework": "html",
"stats": map[string]any{
"lines": strings.Count(string(markup), "\n") + 1, "elements": 0,
"interactive": 0, "images": 0, "form_fields": 0, "headings": 0,
"landmarks": 0, "components": 0,
},
"headings": []any{}, "components": []any{}, "issues": []any{},
"manual_hints": []string{}, "clipped": map[string]any{"cut": 0},
},
}
settled, err := run(body, 1)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
out := settled["output"].(map[string]any)["output"].(string)
var reply auditReply
if err := json.Unmarshal([]byte(sliceObject(out)), &reply); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
hard := 0
for _, f := range reply.Findings {
if f.Severity == "blocker" || f.Severity == "serious" {
hard++
}
fmt.Printf("%-8s %-6s %s\n", f.Severity, f.ID, reply.Verdict)
}
if truncated, _ := settled["truncated"].(bool); truncated {
fmt.Fprintln(os.Stderr, "the reply was truncated - top up and re-run")
return 2
}
if reply.Verdict == "blocked" || verdictFrom(reply.Findings) == "blocked" || hard > 0 {
fmt.Fprintf(os.Stderr, "%d blocking or serious finding(s) - failing the build\n", hard)
return 1
}
return 0
}
func main() { os.Exit(gate(os.Args[1])) }
// A11yGate.java - fail the build when a component cannot be used.
// Usage: java A11yGate.java src/components/LoginForm.html
public static void main(String[] args) throws Exception {
String markup = java.nio.file.Files.readString(java.nio.file.Path.of(args[0]));
// Escape the markup into a JSON string literal, then build the body. No local
// scanner in CI, so prescan_facts goes out with empty arrays.
String codeJson = com.example.Json.quote(markup); // any JSON library will do
String body = "{\"task\": \"audit\", \"code\": " + codeJson + ", "
+ "\"framework\": \"html\", "
+ "\"context_hint\": \"Component from the application repository, audited in CI\", "
+ "\"prescan_facts\": {\"framework\": \"html\", "
+ "\"stats\": {\"lines\": " + (markup.lines().count()) + ", \"elements\": 0, "
+ "\"interactive\": 0, \"images\": 0, \"form_fields\": 0, \"headings\": 0, "
+ "\"landmarks\": 0, \"components\": 0}, "
+ "\"headings\": [], \"components\": [], \"issues\": [], "
+ "\"manual_hints\": [], \"clipped\": {\"cut\": 0}}}";
String settled = run("audit", body, 1);
String reply = sliceObject(settled);
boolean truncated = settled.contains("\"truncated\":true");
boolean blocked = reply.contains("\"verdict\": \"blocked\"")
|| reply.contains("\"verdict\":\"blocked\"");
boolean hard = reply.contains("\"severity\": \"blocker\"")
|| reply.contains("\"severity\":\"blocker\"")
|| reply.contains("\"severity\": \"serious\"")
|| reply.contains("\"severity\":\"serious\"");
System.out.println(reply);
if (truncated) System.exit(2);
System.exit(blocked || hard ? 1 : 0);
}
#!/usr/bin/env ruby
# a11y-gate.rb - fail the build when a component cannot be used.
# Usage: ruby a11y-gate.rb src/components/LoginForm.html
markup = File.read(ARGV[0])
BODY = {
"task" => "audit",
"code" => markup,
"framework" => "html",
"context_hint" => "Component from the application repository, audited in CI",
# No local scanner in CI: send the object with empty arrays.
"prescan_facts" => {
"framework" => "html",
"stats" => { "lines" => markup.lines.size, "elements" => 0, "interactive" => 0,
"images" => 0, "form_fields" => 0, "headings" => 0,
"landmarks" => 0, "components" => 0 },
"headings" => [], "components" => [], "issues" => [],
"manual_hints" => [], "clipped" => { "cut" => 0 }
}
}.freeze
settled = run(BODY) # from step 5
obj = parse_reply(settled) # from step 7
obj["findings"].each do |f|
puts format("%-8s %-6s %s %s", f["severity"], f["sc"], f["id"], f["problem"][0, 90])
end
obj["manual_checks"].each { |m| puts "manual #{m['sc']} #{m['id']} #{m['what']}" }
derived = verdict_from(obj["findings"])
puts "verdict=#{obj['verdict']} derived=#{derived} charged=#{settled['charged_credits']}"
abort "the reply was truncated - top up and re-run" if settled["truncated"]
hard = obj["findings"].count { |f| %w[blocker serious].include?(f["severity"]) }
exit 1 if obj["verdict"] == "blocked" || derived == "blocked" || hard.positive?
exit 0
<?php
// a11y-gate.php - fail the build when a component cannot be used.
// Usage: php a11y-gate.php src/components/LoginForm.html
$markup = file_get_contents($argv[1]);
$BODY = [
"task" => "audit",
"code" => $markup,
"framework" => "html",
"context_hint" => "Component from the application repository, audited in CI",
// No local scanner in CI: send the object with empty arrays.
"prescan_facts" => [
"framework" => "html",
"stats" => ["lines" => substr_count($markup, "\n") + 1, "elements" => 0,
"interactive" => 0, "images" => 0, "form_fields" => 0,
"headings" => 0, "landmarks" => 0, "components" => 0],
"headings" => [], "components" => [], "issues" => [],
"manual_hints" => [], "clipped" => ["cut" => 0],
],
];
$settled = run($BODY); // from step 5
$obj = parse_reply($settled); // from step 7
foreach ($obj["findings"] as $f) {
printf("%-8s %-6s %s %s\n", $f["severity"], $f["sc"], $f["id"], substr($f["problem"], 0, 90));
}
$derived = verdict_from($obj["findings"]);
echo "verdict={$obj['verdict']} derived={$derived} charged={$settled['charged_credits']}\n";
if (!empty($settled["truncated"])) {
fwrite(STDERR, "the reply was truncated - top up and re-run\n");
exit(2);
}
$hard = count(array_filter($obj["findings"],
fn($f) => in_array($f["severity"], ["blocker", "serious"], true)));
exit(($obj["verdict"] === "blocked" || $derived === "blocked" || $hard > 0) ? 1 : 0);
// A11yGate.cs - fail the build when a component cannot be used.
// Usage: dotnet run -- src/components/LoginForm.html
var markup = await File.ReadAllTextAsync(args[0]);
var body = new Dictionary<string, object?>
{
["task"] = "audit",
["code"] = markup,
["framework"] = "html",
["context_hint"] = "Component from the application repository, audited in CI",
// No local scanner in CI: send the object with empty arrays.
["prescan_facts"] = new Dictionary<string, object?>
{
["framework"] = "html",
["stats"] = new Dictionary<string, int>
{
["lines"] = markup.Split('\n').Length, ["elements"] = 0, ["interactive"] = 0,
["images"] = 0, ["form_fields"] = 0, ["headings"] = 0,
["landmarks"] = 0, ["components"] = 0,
},
["headings"] = Array.Empty<string>(),
["components"] = Array.Empty<string>(),
["issues"] = Array.Empty<object>(),
["manual_hints"] = Array.Empty<string>(),
["clipped"] = new { cut = 0 },
},
};
var settled = await Run(body); // from step 5
var obj = ParseReply(settled); // from step 7
var findings = obj.GetProperty("findings").EnumerateArray().ToList();
foreach (var f in findings)
{
Console.WriteLine($"{f.GetProperty("severity").GetString(),-8} " +
$"{f.GetProperty("sc").GetString(),-6} {f.GetProperty("id").GetString()}");
}
var derived = VerdictFrom(findings.Select(f => f.GetProperty("severity").GetString()!));
var verdict = obj.GetProperty("verdict").GetString();
Console.WriteLine($"verdict={verdict} derived={derived}");
if (settled.TryGetProperty("truncated", out var tr) && tr.GetBoolean())
{
Console.Error.WriteLine("the reply was truncated - top up and re-run");
return 2;
}
var hard = findings.Count(f => f.GetProperty("severity").GetString() is "blocker" or "serious");
return (verdict == "blocked" || derived == "blocked" || hard > 0) ? 1 : 0;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused: it executes with a reduced output cap and comes back with truncated: true on
the finished job and on the streaming done event. What you hold then is a prefix of the
reply, not the reply. In the audit lane the first findings may be complete while
manual_checks, keyboard_map, score and coverage
are missing or cut mid-string; in the rewrite lane rewritten_code is written first and
is the longest string in the object, so what gets lost is the change log, the unresolved list and
the behaviour notes — everything that tells you what the rewrite did.
Check the flag before you treat a reply as complete. The shape of the object will
not tell you: a truncated audit that still carries two findings parses cleanly and looks like a
small audit, and its score and verdict — the two fields a gate reads — are
exactly the fields most likely to be missing. A truncated rewrite is worse, because
rewritten_code cut off mid-element is still a string, and a person skimming a diff can
merge it.
The right response is a retry, not a repair: top up, or resubmit a smaller component — one control
group rather than a whole form — with the attempt suffix on the Idempotency-Key
incremented so the new body is not a replay of the old key. Repairing truncated JSON by appending
closing braces produces something that parses and is not what the model meant; in this app it
produces an audit whose score disagrees with the findings above it, or a rewrite whose
change log describes edits its code does not contain.
One more honest limit: code is clipped from the middle at 40,000 characters,
and the reply says so in notes_on_input. An audit of a clipped component is an audit of
its beginning and its end, and a rewrite of one is not safe to merge at all — the middle it never
saw would come back missing. Both lanes are scoped to one component for this reason. For a whole
page, split it at its own component boundaries, run each part, and let the
manual_checks lists tell you what still has to be tested across the assembled page:
focus order, landmark structure and the reading order between components are exactly the things no
per-component run can settle.