Skip to content

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

ComponentPathPurpose
MCP HTTP transportworkers/api/src/mcp/transports/mcp-http.tsJSON-RPC 2.0 endpoint, bearer auth
Tool registryworkers/api/src/mcp/registry.tsAll pdf.*, url.*, acr.* tools registered here
URL toolsworkers/api/src/mcp/tools/url-remediate.tsPhase 1 β€” scan/status/result/conformance/getHtml/editHtml/rescan
ACR toolsworkers/api/src/mcp/tools/acr.tsPhases 3-4 β€” queue/decide/state/generate
URL HTTP routeworkers/api/src/routes/remediate-v2.tsPOST/GET /api/v2/fetch-url[:jobId]
ACR HTTP routeworkers/api/src/routes/acr.ts/api/v2/acr/:jobId/{queue,decide,state,generate}
URL fetch executorworkers/batch/src/url-fetch-executor.tsPuppeteer rendering + axe-core + CMS detection at scan completion
Verification queueworkers/api/src/services/verification-queue.ts11 per-criterion artifact extractors
AI pre-graderworkers/api/src/services/verification-prograder.tsAnthropic Haiku 4.5, parallel Promise.all, 0.85 confidence threshold
ACR composerworkers/api/src/services/acr-composer.tsMerges scan + decisions, renders HTML, calls weasyprint
CMS detectorworkers/api/src/services/cms-detector.tsWordPress / Squarespace / Webflow / Wix / Shopify / Instructure Canvas / Drupal / Ghost

Tool surface (18 tools)

pdf.* (existing, pre-Phase 1)

ToolPurpose
pdf.convertUpload + start PDF β†’ HTML conversion
pdf.statusPoll job
pdf.resultGet violations
pdf.conformanceVPAT/conformance report
pdf.getHtmlFetch the converted HTML
pdf.editHtmlSurgical find/replace or full rewrite

url.* (Phase 1)

ToolPurpose
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)

ToolPurpose
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 at pdf.theaccessible.org/settings β†’ API Keys. Rate-limited per key.
  • Per-tool ownership: Every tool that touches a job calls loadJob() which reads url-fetch:${jobId} from KV and verifies job.userId === ctx.userId. 404 on mismatch (no existence leak).
  • human_verification table: writes always set decided_by_user_id = ctx.userId from the verified JWT. Reads filter by both job_id AND decided_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 via auth.localStorage so headless renders hit authenticated pages without the user pasting cookies. Implemented in apps/remediate/src/app/dashboard/page.tsx (buildSessionAuth).

Pre-grader behavior

  • Model: claude-haiku-4-5-20251001. Set ANTHROPIC_API_KEY in .env (already present on .4; missing on Lambda β€” Lambda doesn’t currently serve user MCP traffic).
  • Lazy: runs on first acr.queue call 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 with source: '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 null on any failure β†’ item stays for human.

Adding a new criterion to the pre-grader

  1. Add an artifact extractor to workers/api/src/services/verification-queue.ts (look at the existing 11 β€” extract111, extract131, etc.).
  2. Add the criterion ID to SUPPORTED_CRITERIA.
  3. Write a focused question template β€” keep it under ~150 words for token budget.
  4. Add a positive test in workers/api/src/__tests__/services/verification-queue.test.ts showing the artifact extracts cleanly from a sample DOM.
  5. The pre-grader picks it up automatically on next deploy.

ACR document generation

  • Composer: workers/api/src/services/acr-composer.ts
  • Render path: composeAcr merges scan vpat.criteria with human_verification rows (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.ts service (calls the accessible-pdf-weasyprint sidecar on .4). When WEASYPRINT_URL is unset, falls back to HTML-only with a warnings entry.
  • Storage: acr/{userId}/{jobId}.html and .pdf. Presigned via @aws-sdk/s3-request-presigner (lazy-imported), TTL 600s.
  • Sign-off prefill: pulls full_name and email from profiles. Organization is intentionally not prefilled today (no column on profiles). 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

Terminal window
# Lambda (optional but recommended for parity)
cd workers/api && npm run build:lambda
cd ../../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 root

After 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:

Terminal window
cd apps/skills/accessibility/dxt
npx -y @anthropic-ai/dxt pack
mv 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

OperationCostNotes
URL scan~$0.02–0.05Existing β€” Puppeteer + axe-core run, no incremental MCP cost
acr.queue first call~$0.005–0.02Anthropic Haiku 4.5 Γ— 11–18 criteria (parallel). Subsequent calls free (cached in DB).
acr.generate~$0.001Composer + 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_KEY is set on the host serving the request (verify .4 env via ssh -i ~/.ssh/nightly-audit [email protected] '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:

Terminal window
rm -rf ~/.npm/_npx

Then 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 in apps/remediate/src/app/dashboard/page.tsx should have run. Verify the request body to /api/v2/fetch-url includes auth.localStorage.
  • For other sites: auth.cookies is subject to Chromium’s 4096-byte per-cookie limit. Use auth.localStorage for larger session blobs (Supabase, etc.).

Phase log

PhaseShippedDescription
12026-05-08url.* MCP tools (PRs #596)
22026-05-08CMS detection coverage + LLM-tuned tool descriptions (#598)
32026-05-08acr.queue / acr.decide / acr.state + AI pre-grader + human_verification table (#599)
42026-05-08acr.generate with sign-off + signed download URLs (#600)
52026-05-09Skill bundle (DXT + Anthropic Skill formats) (#602)

Open follow-ups

  • Cost tracking β€” wire AI calls into a cost_tracking table 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 MCP tools/list to catch drift
  • ACR organization prefill β€” 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.ts for files, acr-composer.ts for URLs) β€” extract a shared HTML shell when a third renderer is needed
  • Phase 6: PDF flow gets the same acr.* treatment URLs got