AI-Assisted Accessibility Skill
The βAccessible.org Auditβ skill is the user-facing distribution of an end-to-end accessibility audit pipeline that runs inside chat clients (Claude Desktop today, Claude Code / Claude.ai / ChatGPT next). A user pastes a URL, walks through verification with AI pre-grading, and gets a defensible, signed ACR (VPAT 2.5) β all without leaving the chat.
This document is the developer-facing reference. End-user docs live at apps/web/content/guides/claude-desktop-skill.md.
Architecture
ββββββββββββββββββββ JSON-RPC ββββββββββββββββββββββββββββββββββββ Claude Desktop ββββββ over HTTPS ββββΆβ api.theaccessible.org/api/mcp ββ (DXT extension) β Bearer <api-key> β (Hono on .4 + Lambda) βββββββββββββββββββββ ββββββββββββββββββ¬βββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ β β β βΌ βΌ βΌ ββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ β pdf.* tools β β url.* tools β β acr.* tools β β existing β β Phase 1 β β Phases 3-4 β ββββββββ¬ββββββββ ββββββββ¬βββββββββββββ ββββββββββ¬βββββββββ β β β β βββββ enqueue SQS ββββββββββ€ β β βΌ β β β ββββββββββββββββββββ β β β β url-fetch β β β β β executor β Puppeteer + β β β β on .4 / EC2 β axe-core + β β β ββββββββββ¬ββββββββββ cms-detector β β β β β β β βΌ β β β ββββββββββββββββββββ β β β β KV: url-fetch: βββββββββββββββββ€ β β β ${jobId} β poll/read β β β β R2: url-fetch/ β β β β β ${userId}/... β β β β ββββββββββββββββββββ β β β β β β ββββββββ lazy on first acr.queue call βββββββββββββββββββββββββββ€ β βΌ β β ββββββββββββββββββββ Anthropic Haiku 4.5 ββββββββββββββββββββββββ β β β verification- βββββββββ pre-grader ββββββΆβ Anthropic API β β β β queue + β ββββββββββββββββββββββββ β β β pre-grader β β β ββββββββββ¬ββββββββββ β β β β β βΌ β β ββββββββββββββββββββββββββββββββββββ β β β Supabase: human_verification ββββββββββββββββββββββββββββββββββββββββ€ β β (audit-trail rows) β acr.decide β β ββββββββββ¬ββββββββββββββββββββββββββ β β β β β βΌ β β ββββββββββββββββββββ weasyprint sidecar ββββββββββββββββββββββββββ β β β acr-composer βββββββββββββββββββββββββΆ β HTML + PDF in R2: β β β β (rendering) β β acr/${userId}/${jobId} β β β ββββββββββββββββββββ ββββββββββββββββββββββββββ β β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β βΌ Presigned download URLs (10-min TTL, returned to chat)Key components
| Component | Path | Purpose |
|---|---|---|
| MCP HTTP transport | workers/api/src/mcp/transports/mcp-http.ts | JSON-RPC 2.0 endpoint, bearer auth |
| Tool registry | workers/api/src/mcp/registry.ts | All pdf.*, url.*, acr.* tools registered here |
| URL tools | workers/api/src/mcp/tools/url-remediate.ts | Phase 1 β scan/status/result/conformance/getHtml/editHtml/rescan |
| ACR tools | workers/api/src/mcp/tools/acr.ts | Phases 3-4 β queue/decide/state/generate |
| URL HTTP route | workers/api/src/routes/remediate-v2.ts | POST/GET /api/v2/fetch-url[:jobId] |
| ACR HTTP route | workers/api/src/routes/acr.ts | /api/v2/acr/:jobId/{queue,decide,state,generate} |
| URL fetch executor | workers/batch/src/url-fetch-executor.ts | Puppeteer rendering + axe-core + CMS detection at scan completion |
| Verification queue | workers/api/src/services/verification-queue.ts | 11 per-criterion artifact extractors |
| AI pre-grader | workers/api/src/services/verification-prograder.ts | Anthropic Haiku 4.5, parallel Promise.all, 0.85 confidence threshold |
| ACR composer | workers/api/src/services/acr-composer.ts | Merges scan + decisions, renders HTML, calls weasyprint |
| CMS detector | workers/api/src/services/cms-detector.ts | WordPress / Squarespace / Webflow / Wix / Shopify / Instructure Canvas / Drupal / Ghost |
Tool surface (18 tools)
pdf.* (existing, pre-Phase 1)
| Tool | Purpose |
|---|---|
pdf.convert | Upload + start PDF β HTML conversion |
pdf.status | Poll job |
pdf.result | Get violations |
pdf.conformance | VPAT/conformance report |
pdf.getHtml | Fetch the converted HTML |
pdf.editHtml | Surgical find/replace or full rewrite |
url.* (Phase 1)
| Tool | Purpose |
|---|---|
url.scan(url, wcagLevel?, auth?) | Enqueue scan; auth is {cookies?, headers?, localStorage?} for protected sites |
url.status(jobId) | Poll |
url.result(jobId) | Full result + platform: {name, confidence, hints?} |
url.conformance(jobId) | VPAT criteria + summary + score + platform |
url.getHtml(jobId, variant?) | Desktop / dark / mobile HTML |
url.editHtml(jobId, edits|html) | Find/replace or full rewrite, stored under remediated/. Does NOT modify the live URL. |
url.rescan(jobId) | Re-enqueue, returns new jobId. Does not replay original auth. |
acr.* (Phases 3-4)
| Tool | Purpose |
|---|---|
acr.queue(jobId) | List items needing human verification, with AI pre-grades |
acr.decide(jobId, criterionId, verdict, note?) | Record decision; auto-classifies source as ai-suggested-human-confirmed / ai-suggested-human-overrode / human-only |
acr.state(jobId) | Overall progress |
acr.generate(jobId, signoff?) | Two-call protocol β first call returns {needsSignoff: true, prefilled}; second call (with signoff) returns {downloads: {html, pdf?}, summary, warnings?} with presigned URLs (10-min TTL) |
Auth model
- MCP transport:
Authorization: Bearer <api-key>where<api-key>is issued atpdf.theaccessible.org/settings β API Keys. Rate-limited per key. - Per-tool ownership: Every tool that touches a job calls
loadJob()which readsurl-fetch:${jobId}from KV and verifiesjob.userId === ctx.userId. 404 on mismatch (no existence leak). human_verificationtable: writes always setdecided_by_user_id = ctx.userIdfrom the verified JWT. Reads filter by bothjob_idANDdecided_by_user_id. RLS policies present but currently dead code (service-role bypass) β see migration header for the full disclaimer.- Auto-session forwarding for
*.theaccessible.org: the remediate web UI auto-injects the userβs Supabase session viaauth.localStorageso headless renders hit authenticated pages without the user pasting cookies. Implemented inapps/remediate/src/app/dashboard/page.tsx(buildSessionAuth).
Pre-grader behavior
- Model:
claude-haiku-4-5-20251001. SetANTHROPIC_API_KEYin.env(already present on .4; missing on Lambda β Lambda doesnβt currently serve user MCP traffic). - Lazy: runs on first
acr.queuecall for a given jobId. Subsequent calls read decisions from DB. - Parallel: all items dispatched via
Promise.all. Per-item.catch(() => null)ensures one bad call doesnβt poison the batch. Critical β sequential pre-grading would hit API Gatewayβs 30s timeout for full scans. - Auto-decide threshold:
confidence >= 0.85 && verdict !== 'partial'β write withsource: 'ai-auto', exclude from the queue surfaced to chat. - Tolerant parser: strips ``` fences, locates first JSON object, validates verdict enum + clamps confidence to [0,1]. Returns
nullon any failure β item stays for human.
Adding a new criterion to the pre-grader
- Add an artifact extractor to
workers/api/src/services/verification-queue.ts(look at the existing 11 βextract111,extract131, etc.). - Add the criterion ID to
SUPPORTED_CRITERIA. - Write a focused question template β keep it under ~150 words for token budget.
- Add a positive test in
workers/api/src/__tests__/services/verification-queue.test.tsshowing the artifact extracts cleanly from a sample DOM. - The pre-grader picks it up automatically on next deploy.
ACR document generation
- Composer:
workers/api/src/services/acr-composer.ts - Render path:
composeAcrmerges scanvpat.criteriawithhuman_verificationrows (human always wins over AI auto), normalizes verdicts to canonical capitalized labels (Supports/Partially Supports/Does Not Support/Not Applicable), computes the score =supports / verified-applicable, then renders to HTML with sign-off block + per-row audit-trail badges. - PDF: HTML β PDF via the existing
weasyprint-generator.tsservice (calls theaccessible-pdf-weasyprintsidecar on .4). WhenWEASYPRINT_URLis unset, falls back to HTML-only with awarningsentry. - Storage:
acr/{userId}/{jobId}.htmland.pdf. Presigned via@aws-sdk/s3-request-presigner(lazy-imported), TTL 600s. - Sign-off prefill: pulls
full_nameandemailfromprofiles. Organization is intentionally not prefilled today (no column onprofiles). Date defaults to scan-completion day.
URL escaping
User-controlled fields (URL, page title, criterion reasoning, human note, sign-off fields) all flow through the shared esc() helper before HTML output. URLs rendered into href attributes are additionally scheme-validated via safeHref() β non-http(s) schemes (e.g., javascript:) degrade to #. Important because ACRs are downloaded and shared with auditors.
CMS detection
workers/api/src/services/cms-detector.ts runs once at scan completion in the executor and persists platform: {name, confidence, hints?} on the KV record. Subsequent url.result / url.conformance calls read from KV β no per-poll R2 reads.
Currently detected: WordPress (with builder hints β Elementor, Divi, etc.), Squarespace, Webflow, Wix, Shopify, Instructure Canvas, Drupal, Ghost. Confidence β 'high' | 'medium' | 'low'.
Detection rules favor multiple weak signals over single strong ones to avoid false positives. The Instructure Canvas detector is a scoring rule (β₯3 of 7 weighted signals required) with an explicit guard against bare HTML <canvas> element collisions β see the in-file comments before extending.
Deployment
The MCP server runs on both Lambda (index-aws.ts) and the .4 Node servers (server.ts). User MCP traffic via api.theaccessible.org routes through the Cloudflare tunnel to .4 β Lambda is currently a hot standby for the API surface.
After changes to MCP tools or executor
# Lambda (optional but recommended for parity)cd workers/api && npm run build:lambdacd ../../infra/cdk && AWS_PROFILE=accessible npx cdk deploy AccessiblePdfProd-Api --require-approval never
# .4 Node servers (REQUIRED β this is what api.theaccessible.org hits)npm run rebuild # from repo rootAfter Supabase schema changes
Apply via the Supabase Dashboard SQL Editor (the project doesnβt have CLI migrations wired up). Migrations live in supabase/migrations/<timestamp>_<seq>_<name>.sql.
The current schema includes human_verification (Phase 3 migration 20260508_100_human_verification.sql).
Skill bundle
The DXT bundle lives at apps/skills/accessibility/dxt/. Rebuild after manifest or system-prompt changes:
cd apps/skills/accessibility/dxtnpx -y @anthropic-ai/dxt packmv dxt.dxt accessible-org-<version>.dxt(Note: @anthropic-ai/dxt was renamed to @anthropic-ai/mcpb β migrate when convenient.)
The Anthropic Skill at apps/skills/accessibility/skill/SKILL.md doesnβt need packaging; itβs loaded as-is by Claude.ai / Claude Code.
Costs to watch
| Operation | Cost | Notes |
|---|---|---|
| URL scan | ~$0.02β0.05 | Existing β Puppeteer + axe-core run, no incremental MCP cost |
acr.queue first call | ~$0.005β0.02 | Anthropic Haiku 4.5 Γ 11β18 criteria (parallel). Subsequent calls free (cached in DB). |
acr.generate | ~$0.001 | Composer + R2 writes; PDF generation free (sidecar) |
All AI calls log to the standard request logs. Cost tracking per the global standards is not yet wired into a cost_tracking table β open follow-up.
Troubleshooting
url.scan succeeded but acr.queue returns nothing
The scan didnβt produce any not-verified criteria β either everything was auto-decidable by axe-core, or the page is well-instrumented. Confirm with url.conformance β look at the criteria array and count not-verified entries.
acr.queue is slow (>20s) on first call
Pre-grader is running. Should be ~5β10s with parallel Promise.all. If itβs hitting 30s+, check:
ANTHROPIC_API_KEYis set on the host serving the request (verify.4env viassh -i ~/.ssh/nightly-audit larry@10.1.1.4 'grep -h ANTHROPIC_API_KEY ~/accessible/workers/api/.env*')- Anthropic API rate limits (look for 429s in the docker logs)
Claude Desktop shows βServer disconnectedβ
Almost always mcp-remoteβs npx cache is corrupted. Fix:
rm -rf ~/.npm/_npxThen quit + reopen Claude Desktop.
acr.generate returns warnings: ["pdf generation skipped β WEASYPRINT_URL not set"]
Lambda doesnβt have weasyprint reachable; .4 does. If the request landed on Lambda, only HTML download is available. User can re-run from a host that has weasyprint, or accept HTML-only.
URL scan succeeded against a protected page but rendered the login form
Auth payload didnβt take. Check:
- For
*.theaccessible.org: the auto-session-forwarding code inapps/remediate/src/app/dashboard/page.tsxshould have run. Verify the request body to/api/v2/fetch-urlincludesauth.localStorage. - For other sites:
auth.cookiesis subject to Chromiumβs 4096-byte per-cookie limit. Useauth.localStoragefor larger session blobs (Supabase, etc.).
Phase log
| Phase | Shipped | Description |
|---|---|---|
| 1 | 2026-05-08 | url.* MCP tools (PRs #596) |
| 2 | 2026-05-08 | CMS detection coverage + LLM-tuned tool descriptions (#598) |
| 3 | 2026-05-08 | acr.queue / acr.decide / acr.state + AI pre-grader + human_verification table (#599) |
| 4 | 2026-05-08 | acr.generate with sign-off + signed download URLs (#600) |
| 5 | 2026-05-09 | Skill bundle (DXT + Anthropic Skill formats) (#602) |
Open follow-ups
- Cost tracking β wire AI calls into a
cost_trackingtable per global standards - Lambda
ANTHROPIC_API_KEYβ set for parity with .4 in case routing changes - DXT package migration from
@anthropic-ai/dxtβ@anthropic-ai/mcpb - Manifest-tools/list lint β diff
dxt/manifest.jsonβs tool list against the live MCPtools/listto catch drift - ACR
organizationprefill β source from a tenant/team table once one exists - Apply-then-undo on
*.editHtmlβ currently apply-immediately with no undo stack - Two parallel ACR renderers (
acr-report-renderer.tsfor files,acr-composer.tsfor URLs) β extract a shared HTML shell when a third renderer is needed - Phase 6: PDF flow gets the same
acr.*treatment URLs got