# A11yHawk
[](https://www.npmjs.com/package/a11yhawk)
[](https://github.com/charlesjones-dev/a11yhawk/actions/workflows/ci.yml)
[](./LICENSE)
Open-source, self-hostable web accessibility scan engine. A11yHawk loads a page in a real browser, runs deterministic Lighthouse accessibility audits, and (optionally) has an LLM of your choice analyze the full page context - screenshot, accessibility tree, and sanitized HTML - against every WCAG criterion for the version and level you select. The result is a structured JSON report, a human-readable markdown report, a self-contained HTML report page, and an annotated screenshot with issues boxed on the page.
Runs entirely on your own infrastructure. No accounts, no telemetry, no phone-home; the only network traffic is the page you scan and the LLM endpoint you configure (and none at all in Lighthouse-only mode beyond the page itself).
> **Status: pre-1.0.** The library API, CLI, and HTTP server mode are functional and tested. The API may change between minor versions until 1.0; see the [CHANGELOG](./CHANGELOG.md).
## How it works
```text
validate URL
|
v
Playwright capture
(screenshot, a11y tree, HTML)
|
v
Lighthouse audit
(deterministic; accessibility + optional performance)
|
v
LLM analysis
(BYOK, optional; skipped in Lighthouse-only mode)
|
v
parse and score
(always recomputed by the engine)
|
v
reports
(JSON, markdown, HTML, annotated screenshot)
```
- **Playwright capture**: full-page screenshot (tiled to your LLM provider's image limits), Chrome DevTools accessibility tree, and HTML sanitized down to its accessibility-relevant structure.
- **Lighthouse**: accessibility-category audits in a subprocess, reusing the same browser over CDP. Findings are mapped to WCAG criteria and fed into the LLM prompt for cross-referencing. Opt in to the performance category and both run from the same page load (see [Lighthouse performance category](#lighthouse-performance-category)).
- **LLM analysis** (optional): the model receives the screenshot tiles, compact accessibility tree, sanitized HTML, Lighthouse findings, and a condensed matrix of every WCAG criterion for your chosen version + level, and returns structured issues with remediation guidance. Scores and statistics are recomputed by the engine from the actual findings, never trusted from the model (see Security).
- **Lighthouse-only mode**: omit the LLM config and you get a deterministic, no-API-key scan in a few seconds.
## Requirements
- Node.js >= 20 (ESM-only package; use `import`)
- Chromium for Playwright (one-time): `npx playwright install chromium` from a project with a11yhawk installed, or `npx playwright@1.65.0 install chromium` from anywhere else (browser downloads are pinned per Playwright version, and an unpinned `npx playwright` outside a project resolves the registry's latest)
- For LLM mode: an API key for [OpenRouter](https://openrouter.ai/) or any OpenAI-compatible endpoint, or an Anthropic API key to call the Claude API directly (see [Anthropic provider](#anthropic-provider)). You bring your own key and pay your own token costs; A11yHawk adds nothing on top.
## Install
```sh
npm install a11yhawk
npx playwright install chromium
```
## Quick start
Lighthouse-only (no API key needed):
```js
import { scan } from 'a11yhawk';
const report = await scan('https://example.com');
console.log(report.structured.overallScore, report.structured.issues.length);
```
Full AI analysis:
```js
import { scan } from 'a11yhawk';
const report = await scan('https://example.com', {
llm: { apiKey: process.env.OPENROUTER_API_KEY },
wcagVersion: '2.2',
wcagLevel: 'AA',
onProgress: (e) => console.error(`[${e.stage}] ${e.message}`),
});
```
Write the reports:
```js
import { writeFileSync } from 'node:fs';
import { renderHtmlReport, scan } from 'a11yhawk';
const report = await scan('https://example.com', { llm: { apiKey: process.env.OPENROUTER_API_KEY } });
writeFileSync('report.json', JSON.stringify(report.structured, null, 2));
writeFileSync('report.md', report.markdown);
writeFileSync('report.html', renderHtmlReport(report)); // self-contained, opens from file://
if (report.annotatedScreenshot) writeFileSync('annotated.jpg', report.annotatedScreenshot);
```
Scanning many pages? Hold an engine so the browser stays warm across scans:
```js
import { A11yHawkEngine } from 'a11yhawk';
const engine = new A11yHawkEngine();
try {
for (const url of urls) {
const report = await engine.scan(url, { llm: { apiKey } });
// persist report however you like
}
} finally {
await engine.close();
}
```
## API
### `scan(url, options?)` / `engine.scan(url, options?)`
One-shot `scan()` accepts `ScanOptions & EngineOptions` and manages the browser lifecycle for you. `A11yHawkEngine` takes `EngineOptions` in its constructor and `ScanOptions` per scan; it also exposes `setConcurrency(n)` (max concurrent page analyses, 1-10) and `close()` (shuts the browser down; the engine relaunches it on the next scan).
**ScanOptions**
| Option | Type | Default | Notes |
| ----------------------- | ---------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `llm` | `ScanLlmOptions` | omitted | Omit entirely for Lighthouse-only mode |
| `llm.apiKey` | `string` | required in LLM mode | Key for the configured provider |
| `llm.provider` | `'openrouter' \| 'anthropic'` | `'openrouter'` | `'anthropic'` calls the Claude API directly; see [Anthropic provider](#anthropic-provider) |
| `llm.model` | `string` | `anthropic/claude-sonnet-5` | Any model id your endpoint accepts. Anthropic default: `claude-opus-5-5` |
| `llm.baseUrl` | `string` | `https://openrouter.ai/api/v1` | Any OpenAI-compatible endpoint (Anthropic default: `https://api.anthropic.com`). Must be a public address unless `allowPrivateNetworks` is set |
| `llm.generationParams` | `GenerationParams` | engine defaults | maxTokens; temperature, topP, frequencyPenalty (OpenRouter only); effort (Anthropic only) |
| `llm.pricing` | `Record<string, ModelPricing>` | none | Anthropic only: USD per million tokens by model id, for `usage.cost` |
| `llm.refusalFallback` | `boolean` | `true` | Anthropic only: re-run a declined request on Anthropic's recommended fallback model |
| `llm.debug` | `boolean` | `false` | Verbose prompt/response logging |
| `wcagVersion` | `'2.0' \| '2.1' \| '2.2'` | `'2.1'` | |
| `wcagLevel` | `'A' \| 'AA' \| 'AAA'` | `'AA'` | |
| `headers` | `ScanHeader[]` | none | Custom request headers (cookies, auth): headers go only to the scan URL's origin, cookies to its host; see Security |
| `lighthouse` | `boolean \| ScanLighthouseOptions` | `true` | `false` skips the audit (LLM mode only); an object selects categories, see below |
| `lighthouse.categories` | `LighthouseCategory[]` | `['accessibility']` | `'accessibility' \| 'performance'`; must include `'accessibility'`; one run, one page load |
| `lighthouse.includeRaw` | `boolean` | `false` | Attach the trimmed raw Lighthouse result as `report.lighthouse.raw` |
| `screenshot` | `boolean` | `true` | `false` skips screenshot capture entirely (Lighthouse-only mode; see Bulk scanning) |
| `annotate` | `boolean` | `true` | Draw severity-colored boxes on a copy of the screenshot |
| `onProgress` | `(e: ScanProgressEvent) => void` | none | Stages: validating, capturing, auditing, analyzing, processing, annotating, complete, failed |
| `logger` | `Logger` | console logger | Bring your own structured logger |
**EngineOptions**
| Option | Type | Default | Notes |
| ------------------------ | --------------- | -------------- | ------------------------------------------------------------------------------------- |
| `allowPrivateNetworks` | `boolean` | `false` | See [Security](#security) before enabling |
| `requestFilter` | `RequestFilter` | none | Your own host policy for every browser request. See [Request filter](#request-filter) |
| `browser.disableSandbox` | `boolean` | `false` | Chromium `--no-sandbox`. See [Chromium sandbox](#chromium-sandbox) first |
| `browser.debug` | `boolean` | `false` | Extra capture diagnostics |
| `logger` | `Logger` | console logger | |
**ScanReport**
| Field | Type | Notes |
| --------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `structured` | `StructuredScanOutput` | Machine-readable source of truth: score, statistics, WCAG coverage, issues with remediation, passed checks |
| `markdown` | `string` | Human-readable report |
| `screenshot` | `Buffer \| null` | Full-page JPEG |
| `annotatedScreenshot` | `Buffer \| null` | Issues boxed on the page, when annotation resolved any selectors |
| `lighthouse` | `LighthouseTransformedResult \| null` | Lighthouse findings mapped to WCAG, plus `lighthouseVersion` and (when requested) `performance` / `raw` |
| `usage` | `ScanUsage \| null` | Tokens + cost in USD, the provider, and (Anthropic) cache writes and the model that answered; `null` in Lighthouse-only mode |
| `finalUrl` | `string` | Guard-validated post-redirect URL that was actually analyzed |
| `filteredRequests` | `FilteredRequest[]` | Hosts `requestFilter` refused during capture without failing the scan (`{ host, reason }`, one per host, at most 100). Empty without a filter |
| `durationMs` | `number` | |
Use `renderHtmlReport(report)` to turn any `ScanReport` into a single self-contained HTML document (inline CSS/JS, images as data URIs, renders from `file://`, itself WCAG AA accessible). Use `renderSarifReport(report)` to get a SARIF 2.1.0 log for CI tools.
### Anthropic provider
Set `llm.provider: 'anthropic'` to call the Claude API directly with an Anthropic API key instead of going through OpenRouter:
```js
const report = await scan('https://example.com', {
llm: {
provider: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY,
generationParams: { effort: 'high' },
pricing: { 'claude-opus-5-5': { inputPer1M: 4, outputPer1M: 20 } },
},
});
```
- **Defaults.** The model is `claude-opus-5-5` (`DEFAULT_ANTHROPIC_MODEL`) and `baseUrl` is `https://api.anthropic.com`, checked by the same endpoint guard as OpenRouter's.
- **Request.** The response is streamed, with `maxTokens` defaulting to 64000. `effort` is sent as `output_config.effort` only when you set it; otherwise the model's own default applies (`medium` on Claude Opus 5.5). `temperature`, `topP`, and `frequencyPenalty` are not sent, because current Claude models reject non-default sampling values. The system prompt is the same for every scan and is sent as a cached block, so scans that run within the cache lifetime read it from the cache.
- **Refusal fallback.** Claude's safety classifiers can decline a request. On models that support it (Claude Fable 5.1 and 5, Opus 5.5 and 5, Sonnet 5.5, and Mythos 5.1; not Haiku), the engine opts into Anthropic's server-side fallback, which re-runs a declined request on the model Anthropic recommends for the refusal category. `usage.servedModelId` then names the model that answered. Set `refusalFallback: false` to turn it off. A refusal that stands fails with `llm-refused`.
- **Cost.** The Claude API reports tokens, not dollars, so `usage.cost` is estimated from `llm.pricing`: USD per million tokens, keyed by model id. Cache reads default to 0.1x the input price and cache writes to 1.25x. After a fallback, every attempt is priced at its own model's rates, so list the fallback models too (such as `claude-opus-5` and `claude-opus-4-8`); if any model that ran has no entry, `cost` is 0. The estimate ignores long-context price tiers, such as Claude Haiku 5.5's higher rates above 100K input tokens, and it prices a declined attempt even when Anthropic does not bill that refusal category.
- **Screenshots.** Tiles are cut 1932 px tall at the 1920 px viewport width, so Claude's high-resolution models read them without downscaling and one request can carry more than 20 of them. A page that needs more than 100 tiles fails with a non-retryable `llm-failed` before any request is sent.
- **Retries and timeouts.** The SDK retries an attempt that fails before its response starts streaming (connection errors, 408, 409, 429, 5xx) up to 2 times, and waits up to 5 minutes for a response to start. A stream that sends nothing for 5 minutes is cut. A failure partway through a stream is not retried. Unlike OpenRouter's, a 429 is a retryable `llm-rate-limit`.
- **No environment.** The client is configured only from your options. `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_CUSTOM_HEADERS`, profiles, and the other variables the SDK reads by default are ignored.
### Lighthouse performance category
Add `'performance'` to `lighthouse.categories` to collect performance metrics from the **same Lighthouse run and page load** as the accessibility audit; no second pass, no extra navigation. Works in Lighthouse-only mode (no API key) and in LLM mode alike:
```js
const engine = new A11yHawkEngine();
const report = await engine.scan(url, {
lighthouse: { categories: ['accessibility', 'performance'] },
screenshot: false, // optional: skip capture in bulk Lighthouse-only scans
});
report.lighthouse.performance.score; // 0-100 (or null if uncomputable)
report.lighthouse.performance.metrics; // FCP/LCP/TBT/Speed Index (ms), CLS
report.lighthouse.performance.opportunities; // top improvements, largest first
report.lighthouse.lighthouseVersion; // provenance, e.g. "13.4.0"
```
`report.lighthouse.performance` is a `LighthousePerformanceResult`:
| Field | Type | Notes |
| ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `score` | `number \| null` | Category score 0-100; `null` when Lighthouse could not compute one (metrics may remain) |
| `metrics.firstContentfulPaintMs` | `number?` | FCP, milliseconds |
| `metrics.largestContentfulPaintMs` | `number?` | LCP, milliseconds |
| `metrics.cumulativeLayoutShift` | `number?` | CLS, unitless, 3 decimals |
| `metrics.totalBlockingTimeMs` | `number?` | TBT, milliseconds |
| `metrics.speedIndexMs` | `number?` | Speed Index, milliseconds |
| `opportunities[]` | array | Failing audits with an estimate: `auditId`, `title`, `estimatedSavingsMs?`, `estimatedSavingsBytes?`; sorted by time savings, capped at 10 |
Notes:
- `'accessibility'` is always required in `categories`: it is the analysis source for the structured report. Omitting it throws `ScanError` `invalid-options`.
- A failed run surfaces through the existing `lighthouse-failed` code (fatal in Lighthouse-only mode, non-blocking in LLM mode); requesting performance adds no new error shapes.
- **Performance runs are serialized engine-wide**, regardless of `setConcurrency(n)`: parallel traces on one machine contend for CPU and skew FCP/LCP/TBT. Other scan stages still run concurrently, so for the strictest metric fidelity run performance scans at concurrency 1 on an otherwise idle machine.
- Performance runs get a 60s Lighthouse timeout (accessibility-only runs keep 30s).
- `lighthouse: { includeRaw: true }` attaches the raw Lighthouse result as `report.lighthouse.raw` for audits the engine does not map, trimmed of its largest dead weight (full-page screenshot artifact, screenshot audits, localization tables).
- The structured report (`report.structured`) stays accessibility-only; performance data lives on `report.lighthouse` and is not persisted into `StructuredScanOutput`.
### Bulk scanning
For crawling many pages, hold one `A11yHawkEngine` for the whole run instead of calling the one-shot `scan()` per page, and disable outputs you will not use:
```js
const engine = new A11yHawkEngine();
try {
for (const url of urls) {
const report = await engine.scan(url, {
screenshot: false, // no capture, no tiling, no annotation, no Buffers in the report
lighthouse: { categories: ['accessibility', 'performance'] },
});
await persist(report);
}
} finally {
await engine.close();
}
```
- `screenshot: false` skips screenshot capture entirely; `report.screenshot` and `report.annotatedScreenshot` are `null` and memory stays flat across thousands of sequential scans. It requires Lighthouse-only mode (the LLM analysis needs the screenshot), so combining it with `llm` throws `invalid-options`.
- Chromium shuts down whenever no scan is in flight and relaunches on the next one, returning its native memory to the OS between sequential scans by design. Overlapping scans (`setConcurrency(n)` > 1 with concurrent callers) share one warm browser.
- Every scan-time failure is a `ScanError` with a `retryable` flag; map `retryable: true` onto your crawler's retry queue and drop the rest.
- The repo carries an opt-in soak test asserting flat memory and stable handle counts across sequential scans on one engine: `A11YHAWK_SOAK=1 A11YHAWK_SOAK_SCANS=1000 NODE_OPTIONS=--expose-gc npx vitest run src/engine/scan.soak.test.ts`.
### Request filter
`EngineOptions.requestFilter` lets you refuse hosts on top of the SSRF guard, for example sites a malware or phishing block list names. The engine calls it with the parsed `URL` of every request the browser makes: the scan URL (before the browser starts), navigations, redirect hops, subresources, iframes, popups, and the page Lighthouse opens. It runs only after the SSRF checks pass, so it can refuse more, never less.
```js
import { A11yHawkEngine } from 'a11yhawk';
const blocked = new Set(['phish.example', 'malware-cdn.example']);
const engine = new A11yHawkEngine({
requestFilter: (url) =>
blocked.has(url.hostname) ? { allowed: false, reason: 'on our block list' } : { allowed: true },
});
```
- A refused scan URL, main-page navigation, or main-page redirect fails the scan with the non-retryable `blocked-by-filter` code.
- Any other refused request is aborted, like an ad blocker would, and its host is listed in `report.filteredRequests`.
- The filter may return a promise. The engine caches nothing, and capture pages run each request through it twice (the context guard and the browser-wide guard), so cache slow lookups yourself.
- A thrown error or rejected promise counts as a refusal. Catch errors inside the filter if it should fail open.
- WebSocket connections are not checked, as with the SSRF guard.
### Error handling
Every pipeline failure throws a `ScanError` with a `code` and a `retryable` flag, so queue-based hosts can map failures onto their retry semantics:
| Code | Retryable | Meaning |
| ------------------- | ---------------------------- | --------------------------------------------------------------------------------------------- |
| `invalid-options` | no | Contradictory configuration (e.g. no LLM and Lighthouse disabled) |
| `invalid-url` | no | URL failed validation (malformed, non-http(s), or blocked target) |
| `blocked-request` | no | The SSRF guard blocked a navigation or redirect |
| `blocked-by-filter` | no | `requestFilter` refused the scan URL, the main page's navigation, or one of its redirects |
| `capture-failed` | yes | Browser navigation/capture failure (often transient) |
| `lighthouse-failed` | yes | Lighthouse failed in Lighthouse-only mode (non-blocking in LLM mode) |
| `llm-auth` | no | API key invalid or expired, or (Anthropic) not allowed to make the request |
| `llm-billing` | no | Anthropic account out of credit or over its spend limit |
| `llm-rate-limit` | no; yes for Anthropic | Endpoint rate limit hit |
| `llm-refused` | no | The model declined to analyze the page (Anthropic), after any fallback |
| `llm-failed` | yes; no for an Anthropic 4xx | Other LLM call failure, including an unknown model or too many screenshot tiles for Anthropic |
| `llm-malformed` | no | Model response was not parseable as the expected JSON, or was cut off at `maxTokens` |
Decide retries from `error.retryable` rather than from the code: for the Anthropic provider, `llm-rate-limit` is retryable and `llm-failed` from a 4xx is not.
```js
import { ScanError, scan } from 'a11yhawk';
try {
await scan(url, options);
} catch (error) {
if (error instanceof ScanError && error.retryable) requeue(url);
else reportPermanentFailure(error);
}
```
## CLI
<!-- CLI-DOCS:START -->
Run it without installing anything:
```sh
# Free Lighthouse-only scan; reports written to ./a11yhawk-output/<timestamp>/
npx a11yhawk https://example.com
# Full AI analysis (bring your own OpenRouter-compatible key)
npx a11yhawk https://example.com --api-key sk-...
# Or call the Claude API directly with an Anthropic key
npx a11yhawk https://example.com --provider anthropic --api-key sk-ant-...
# One-time Chromium download if you have never used Playwright on this machine.
# The version pin matters here: outside a project, an unpinned `npx playwright`
# resolves the registry's latest, whose browser builds a11yhawk cannot use.
# `npx a11yhawk doctor` prints the exact command when Chromium is missing.
npx playwright@1.65.0 install chromium
```
The default command scans a single URL and writes `report.json` and `report.md` (add `html` for the self-contained HTML report), plus `screenshot.jpg` and `annotated.jpg` when available. LLM mode turns on automatically when an API key is present (flag or env) and `--no-llm` is absent; otherwise the scan runs Lighthouse-only, which needs no key and finishes in seconds.
### Commands
| Command | Description |
| -------------------------- | ----------------------------------------------------- |
| `a11yhawk <url> [options]` | Scan a URL (default command) |
| `a11yhawk serve [options]` | Run the HTTP server (see [Server mode](#server-mode)) |
| `a11yhawk doctor` | Verify Chromium, Lighthouse, and key configuration |
| `a11yhawk --help` | Show usage |
| `a11yhawk --version` | Print the version |
### Scan options
| Flag | Default | Description |
| ------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--provider <name>` | `openrouter` | `openrouter` (any OpenAI-compatible endpoint) or `anthropic` (env: `A11YHAWK_PROVIDER`) |
| `--model <id>` | `anthropic/claude-sonnet-5` | LLM model id; `claude-opus-5-5` by default with `--provider anthropic` (env: `A11YHAWK_MODEL`) |
| `--api-key <key>` | none | API key for the provider; presence enables LLM mode (env: `A11YHAWK_API_KEY`) |
| `--base-url <url>` | `https://openrouter.ai/api/v1` | Provider endpoint (`https://api.anthropic.com` for `anthropic`); a local one needs `--allow-private` (env: `A11YHAWK_BASE_URL`) |
| `--no-llm` | off | Force Lighthouse-only mode even if a key is set |
| `--wcag <2.0\|2.1\|2.2>` | `2.1` | WCAG version |
| `--level <A\|AA\|AAA>` | `AA` | Conformance level |
| `--header "Name: value"` | none | Custom request header; repeatable |
| `--format <list>` | `json,md` | Comma-separated output formats: `json`, `md`, `html`, `sarif` |
| `--output <dir>` | `./a11yhawk-output/<timestamp>` | Directory for report files and screenshots |
| `--stdout` | off | Print structured JSON to stdout instead of writing files |
| `--open` | off | Open `report.html` after writing (implies `html` in `--format`) |
| `--fail-below <score>` | none | Exit 1 when the overall score is below `<score>` (CI gate) |
| `--allow-private` | off | Permit private/internal scan targets and LLM endpoints (env: `A11YHAWK_ALLOW_PRIVATE`) |
| `--no-annotate` | off | Skip screenshot annotation |
| `--no-lighthouse` | off | Skip the Lighthouse audit (LLM mode only) |
| `--quiet` | off | Errors only |
| `--verbose` | off | Debug logging |
Progress is streamed to stderr as `[stage] message`; report data goes to stdout only under `--stdout`, so `a11yhawk <url> --stdout > report.json` pipes cleanly. `--stdout` writes no files, so it takes precedence over `--open`.
### Environment variables
The CLI reads these as fallbacks; an explicit flag always wins. The library itself never reads the environment.
| Variable | Equivalent flag | Notes |
| -------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ |
| `A11YHAWK_API_KEY` | `--api-key` | Enables LLM mode when set |
| `A11YHAWK_PROVIDER` | `--provider` | `openrouter` or `anthropic`; checked only in LLM mode |
| `A11YHAWK_MODEL` | `--model` | LLM model id |
| `A11YHAWK_BASE_URL` | `--base-url` | Provider endpoint |
| `A11YHAWK_ALLOW_PRIVATE` | `--allow-private` | Set to `true` to permit private/internal targets |
| `A11YHAWK_DISABLE_SANDBOX` | (none) | Set to `true` to launch Chromium with `--no-sandbox` (see [Chromium sandbox](#chromium-sandbox)) |
### Exit codes
| Code | Meaning |
| ---- | ---------------------------------------------------------------------------- |
| `0` | Success |
| `1` | Scan succeeded but the score was below `--fail-below` |
| `2` | Scan error (a `ScanError`; its `[code] message` is printed to stderr) |
| `3` | Configuration error (bad flag, contradictory options, or a `doctor` blocker) |
### `doctor`
`a11yhawk doctor` verifies that a scan can run at all: Node.js >= 20, a usable Playwright Chromium (it prints the version-pinned `npx playwright@<version> install chromium` command matching its bundled Playwright when that is what is missing), and a resolvable Lighthouse CLI. It also reports whether an API key is configured and which provider `A11YHAWK_PROVIDER` selects, both informational only since Lighthouse-only mode needs neither. It exits `0` when Lighthouse-only scanning is possible and `3` otherwise, so it doubles as a CI preflight.
### CI example
```yaml
# .github/workflows/a11y.yml
name: Accessibility
on: [push]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx playwright@1.65.0 install --with-deps chromium
# Ubuntu runners block the user namespaces that Chromium's sandbox needs.
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Accessibility gate
run: npx a11yhawk https://your-site.example --fail-below 90 --format json,html --output a11y-report
- if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: a11y-report/
```
Add `--api-key ${{ secrets.OPENROUTER_API_KEY }}` for full AI analysis. The job fails when the score drops below 90 and always uploads the report as a CI artifact.
<!-- CLI-DOCS:END -->
## Server mode
<!-- SERVE-DOCS:START -->
`a11yhawk serve` runs a small HTTP service over the same engine, with one warm browser shared across scans. Jobs live in memory: there is no database, and a restart drops every job and report. Persistence is the host's concern by design.
### Running it
```sh
# From an install, or a clone after `npm run build`
a11yhawk serve # listens on :4000
# Docker (browser + system deps baked in; the recommended path).
# Images are published to GHCR with each tagged release. The seccomp profile
# lets Chromium's sandbox run (see Chromium sandbox below).
curl -fsSLO https://raw.githubusercontent.com/charlesjones-dev/a11yhawk/main/docker/seccomp-chromium.json
docker run --rm -p 4000:4000 --security-opt seccomp=seccomp-chromium.json ghcr.io/charlesjones-dev/a11yhawk:latest
# Docker Compose (see examples/docker-compose.yml)
docker compose -f examples/docker-compose.yml up
```
### Endpoints
| Method and path | Description |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /scans` | Enqueue a scan. Body `{ "url": "...", "options": { ... } }`. Returns `202 { id }`, `400` for invalid options (including an `llm.baseUrl` on a private network), or `503` while 100 scans are already waiting. |
| `GET /scans/:id` | Job status, plus the report once completed or an error once failed. |
| `GET /scans/:id/report.html` | The rendered HTML report. `404` until the scan completes. |
| `GET /healthz` | Liveness, uptime, job counts, and the queue limit. Never requires auth. |
Enqueue a scan and poll it to completion:
```sh
# Lighthouse-only scan (no API key needed)
id=$(curl -s localhost:4000/scans \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}' | jq -r .id)
# Poll until .status is "completed" or "failed"
curl -s localhost:4000/scans/$id | jq '{status, score: .report.structured.overallScore}'
# Save the HTML report once it is ready
curl -s localhost:4000/scans/$id/report.html -o report.html
```
The `options` object accepts only `llm { apiKey, provider, model, baseUrl, generationParams }`, `wcagVersion`, `wcagLevel`, `headers`, `lighthouse` (boolean or `{ categories, includeRaw }`, same semantics as the library option), `screenshot`, and `annotate`; any other field is ignored, including the library-only `llm.pricing` and `llm.refusalFallback`. In the JSON report, screenshots come back as base64 `data:` URIs rather than raw bytes, and a submitted `llm.apiKey` is never echoed back in any response.
### Configuration
Every setting is an environment variable, read only by the server layer (never the library):
| Variable | Default | Purpose |
| --------------------------- | ------- | ------------------------------------------------------------------------------------- |
| `A11YHAWK_PORT` (or `PORT`) | `4000` | Listen port. `serve --port <n>` overrides both. |
| `A11YHAWK_AUTH_TOKEN` | (unset) | When set, every endpoint except `/healthz` requires `Authorization: Bearer <token>`. |
| `A11YHAWK_CONCURRENCY` | `2` | Max concurrent scans (clamped 1-10). Extra scans queue FIFO, up to 100 waiting. |
| `A11YHAWK_JOB_TTL_SECONDS` | `3600` | How long a finished job is retained before eviction. |
| `A11YHAWK_ALLOW_PRIVATE` | `false` | Permit scanning private/internal targets (see the note below). |
| `A11YHAWK_DISABLE_SANDBOX` | `false` | Launch Chromium with `--no-sandbox`. See [Chromium sandbox](#chromium-sandbox) first. |
### Server security note
`allowPrivateNetworks` is a server-level posture, set only by `A11YHAWK_ALLOW_PRIVATE`. A request body can never turn it on: that field, along with `browser` and `logger`, is stripped from submitted options. Leave it off anywhere scan URLs come from people you do not trust, and put network-layer egress rules around the server when it is multi-tenant. The same posture governs a submitted `llm.baseUrl`: with it off, an endpoint on a private network is rejected with `400`, and every connection to the endpoint is checked again when the scan runs. The provider's error responses are logged, never returned to clients; a failed job reports only the HTTP status. The HTML report is served with a `Content-Security-Policy` that lets only its own script run. A client gets 30 seconds to send its request. `/healthz` stays open even when a token is set, so load balancers can probe it; it shows uptime and how many scans are queued and running, so keep it off the public internet if that matters to you. See the Security section below for the residual egress caveats that apply to every mode.
<!-- SERVE-DOCS:END -->
## Security
A11yHawk is designed to be safe to embed in services where scan URLs come from untrusted users, and honest about its residual risks.
- **SSRF request guard, default-on.** Every request the scanned page makes is validated at the browser context level: scheme checks, per-request DNS resolution with no cached allow-verdicts, redirect-hop detection, service workers blocked, private, loopback, link-local, and reserved targets refused (including IPv6 addresses that embed one, such as NAT64 and 6to4 forms). A second guard intercepts every request in the shared browser, including the page Lighthouse opens for its audit, so the scanned page's scripts cannot reach a private address during the audit either, and a redirect to one is refused before it is sent. Scan URLs are re-validated at scan time (not just submission time) to narrow DNS-rebinding windows.
- **Host policy.** `requestFilter` adds your own refusals (for example a threat block list) to every request both guards see. See [Request filter](#request-filter).
- **`allowPrivateNetworks: true`** exists because scanning your own internal apps is a primary self-hosting use case. It only widens which resolved addresses the guard accepts; every other protection stays active. Leave it `false` anywhere scan URLs come from people you don't trust.
- **LLM endpoint guard.** `llm.baseUrl` gets the same SSRF posture as scan targets: unless `allowPrivateNetworks` is set, an endpoint on a private, loopback, or link-local address is refused before the scan starts, and the LLM client refuses any connection (including a redirect hop or a re-resolved hostname) that lands on one. Both providers get this check: the OpenRouter client through an HTTP agent, the Anthropic client through a fetch built on the same agent, which also never follows a redirect. A local endpoint such as Ollama needs `allowPrivateNetworks: true` (CLI: `--allow-private`).
- **Chromium sandbox, default-on.** A page that exploits a browser bug stays inside Chromium's sandbox. Linux hosts and containers need user namespaces for it. See [Chromium sandbox](#chromium-sandbox).
- **Known residual risks**: WebSocket connections are not intercepted, and Chromium resolves a hostname again after the guard has checked it, which leaves a short DNS-rebinding window. Network-layer egress filtering is the only complete mitigation. If you run A11yHawk multi-tenant, put egress rules around it.
- **The browser's DevTools port is unauthenticated.** Chromium listens for the DevTools protocol on a random loopback port so Lighthouse can reuse the browser, and Chromium offers no way to require credentials there. Any process on the same host, or any container sharing its network namespace (a Kubernetes sidecar, for example), can connect, drive the browser, read the pages and headers of other scans, and switch off the egress guard. Web pages cannot use it: Chromium refuses DevTools connections from web origins and checks the `Host` header. Run A11yHawk in its own container or on a host without untrusted local users; the Docker image gets its own network namespace unless you run it with `--network host`.
- **Custom headers stay with the target.** Header and bearer-token entries in `headers` are added only to requests whose origin (scheme, host, and port) matches the scan URL, so third-party scripts, CDNs, and analytics on the page never receive them. Subdomains and the `www`/bare variant are different origins, so pass the URL the page finally lands on. Cookie entries (and a `Cookie` header entry) are set as host-only cookies for the scan URL and follow normal cookie rules, which ignore the port: the browser sends them to that host on any port, for paths under the scan URL's directory, and over plain `http` as well when the scan URL is `http`. Residual gap: Playwright re-sends a request's headers on its redirect hops, so a same-origin URL that redirects to another origin still delivers them to the redirect target.
- **Subprocess hygiene**: the Lighthouse CLI is spawned with `shell: false` and argv arrays; the attacker-controllable URL is never interpreted by a shell. The child gets only `PATH`, `HOME`, `USERPROFILE`, the temp-directory variables, `SystemRoot`, `LANG`, `LC_ALL`, and `CHROME_PATH`, never the rest of your environment, so keys such as `A11YHAWK_API_KEY` stay out of the Lighthouse dependency tree.
- **Key handling**: API keys arrive as options, are never logged, and error messages from the LLM layer are sanitized so keys cannot leak through error chains. The Anthropic client reads no `ANTHROPIC_*` environment variable, so the key, endpoint, and headers it sends are only the ones you pass. An HTTP error from the endpoint is reported by status only; the response body goes to the log. The built-in and CLI loggers also mask key-shaped strings, URL credentials (`https://user:pass@...`), and token-like query values (`token`, `key`, `sig`, `code`, and similar). A custom `logger` receives values unmasked.
- **Model output is untrusted.** A scanned page can try to steer the model through its content, so the engine treats the response as data: the score is recomputed from the criteria the model checked (0 when it reports none), statistics are recounted from the issues, `structured.url` is the URL you scanned, and severities, levels, and other fields are coerced to their allowed values before anything renders them.
- **Page content is marked as data.** Everything the prompt takes from the page (HTML, accessibility tree, URL, and Lighthouse data) sits between boundary markers, the system prompt tells the model to treat marked content and text in the screenshots as data, never instructions, and page content is escaped so it cannot fake a marker. That narrows prompt injection but cannot rule it out: a page can still try to talk the model out of findings. For pages you don't control, check LLM-mode results against `report.lighthouse`, which comes from deterministic audits the model cannot change.
- **Error messages name no internal addresses.** A scan URL refused because its hostname resolves to a private address fails without naming the address, which goes to the log only, so a server client cannot use refusals to map internal DNS names.
- **Report output**: everything interpolated into the HTML report, including provider token counts, is entity-escaped, so a malicious scanned page or provider cannot inject markup or script into the report. Server mode also serves it with a `Content-Security-Policy` that allows only the report's own inline script.
- **No telemetry.** Nothing is collected, nothing phones home. Lighthouse's own error reporting is switched off on every run, whatever an earlier interactive `lighthouse` run on the machine saved.
### Chromium sandbox
Chromium runs with its sandbox on. A page that exploits a renderer bug is still held in a process that can't open files or make network connections by itself. Before 0.8.0, Playwright launched Chromium with `--no-sandbox` because the engine never asked for the sandbox.
On Linux, the sandbox needs user namespaces. When Chromium can't create them, the scan fails and Chromium logs `No usable sandbox!`. Two common setups block them:
- **Docker's default seccomp profile.** Run the container with `--security-opt seccomp=docker/seccomp-chromium.json` from this repository. That file is Docker's default profile with two added rules: `clone` may create user, PID and network namespaces, and `unshare` may create a user namespace. Mount, UTS, IPC, cgroup and time namespaces stay blocked. Keep Docker's default AppArmor profile. On Ubuntu hosts, `apparmor=unconfined` blocks the namespaces again. `examples/docker-compose.yml` already sets the profile.
- **Ubuntu 23.10 and later, outside a container.** AppArmor blocks user namespaces for programs that have no AppArmor profile of their own, and Playwright's downloaded Chromium has none. This includes GitHub Actions' Ubuntu runners. On a throwaway CI runner, `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` lifts the restriction, as the CI example above does. On a long-lived machine, give Chromium its own AppArmor profile instead. Chromium's [AppArmor notes](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) describe the options.
`browser.disableSandbox` (CLI and server: `A11YHAWK_DISABLE_SANDBOX=true`) turns the sandbox off. Use it only where the host leaves no other choice, and put the network controls above around the scanner.
Found a vulnerability? Please open a GitHub security advisory rather than a public issue.
## For AI agents
Everything above applies; this section is deliberately compact and exact.
- Package: `a11yhawk` (npm). ESM only. Node >= 20. Requires Chromium: `npx playwright@1.65.0 install chromium` (pin matches the bundled Playwright; unpinned is fine only inside a project that has a11yhawk installed).
- Exports: `scan`, `A11yHawkEngine`, `renderHtmlReport`, `renderSarifReport`, `ScanError`, `createLogger`, `DEFAULT_MODEL`, `DEFAULT_ANTHROPIC_MODEL`, plus all types (`ScanOptions`, `EngineOptions`, `ScanReport`, `StructuredScanOutput`, `AccessibilityIssue`, ...). Full `.d.ts` shipped.
- Minimal complete program:
```js
import { scan } from 'a11yhawk';
const report = await scan('https://example.com'); // Lighthouse-only, no key needed
// report.structured.overallScore: number 0-100
// report.structured.issues: Array<{ severity: 'critical'|'high'|'medium'|'low', wcagCriteria: string, title: string, remediation: string, ... }>
```
- LLM mode: add `{ llm: { apiKey } }`; key is an OpenRouter key unless `llm.baseUrl` points elsewhere. For an Anthropic key, add `provider: 'anthropic'` (calls the Claude API directly; default model `claude-opus-5-5`; set `llm.pricing` to get a cost). Expect 1-5+ minutes and provider token costs per scan in LLM mode; Lighthouse-only takes seconds and is free.
- Scanning localhost or private hosts, or using a local `llm.baseUrl` (Ollama, LM Studio), requires `{ allowPrivateNetworks: true }`.
- All failures are `ScanError` with `.code` (see table above) and `.retryable`. Decide retries from `.retryable`, not `.code`: a non-retryable error will fail identically on retry, so do not loop on it.
- CI gating pattern: run a scan, compare `report.structured.overallScore` to your threshold, exit non-zero below it. Or use the CLI's built-in gate: `npx a11yhawk <url> --fail-below 80`.
- Shelling out instead of importing: `npx a11yhawk <url> --stdout` prints the structured JSON to stdout (progress goes to stderr). Exit codes: 0 success, 1 below `--fail-below`, 2 scan error, 3 configuration error. `npx a11yhawk doctor` preflights the environment.
- A self-hostable HTTP mode exists (`a11yhawk serve`, `POST /scans` then poll `GET /scans/:id`); see Server mode above.
### Agent Skill
This repo ships an [Agent Skill](https://agentskills.io/) that teaches coding agents to run A11yHawk scans with the CLI and library. Install it with the [skills CLI](https://skills.sh/):
```sh
npx skills add charlesjones-dev/a11yhawk --skill a11yhawk
```
The skill lives in [`skills/a11yhawk/SKILL.md`](./skills/a11yhawk/SKILL.md).
## Examples
Runnable scripts live in [`examples/`](./examples/). They import `a11yhawk` by package name (Node resolves the package's own name from inside the repo), so every script is byte-for-byte what you would write in your own project.
From a clone:
```sh
npm install
npx playwright install chromium # one-time browser download
npm run build # examples resolve 'a11yhawk' to the local dist/ build
```
Then:
```sh
# Self-contained demo: serves a deliberately broken page locally, scans it,
# writes an HTML report to examples/output/. No API key, no external network.
node examples/local-fixture.mjs
# Free Lighthouse-only scan of any public URL, summary to stdout
node examples/lighthouse-only.mjs https://your-site.example
# Full AI analysis: writes report.json / report.md / report.html + screenshots
# to examples/output/. Takes minutes and costs tokens on your key.
OPENROUTER_API_KEY=sk-... node examples/full-scan.mjs https://your-site.example
# CI gate: exit 0 if score >= threshold, 1 below it, 2 on scan error
node examples/ci-gate.mjs https://your-site.example 80
```
`full-scan.mjs` also honors `A11YHAWK_MODEL` to override the default model. If you copy a script into your own project, replace the build step with `npm install a11yhawk`; nothing else changes.
The directory also holds two deployment templates: `docker-compose.yml` (server mode as a container) and `github-action.yml` (a copy-paste CI job gating on `--fail-below`).
## Output formats
- **`structured`** (JSON): overall score (0-100: the percent of checked WCAG criteria with no issues found, recomputed by the engine and 0 when the model reports no criteria; the Lighthouse accessibility score in Lighthouse-only mode), per-severity statistics, WCAG coverage marking each checked criterion as issues found or no issues found (`passed`), issues with location/selector, code context, impact, and remediation, passed checks. Stable shape; treat as the source of truth.
- **`markdown`**: the same content as a readable report.
- **HTML** (via `renderHtmlReport`): a single dark-theme file with score ring, severity breakdown, sortable/collapsible issues with client-side resolve tracking (localStorage), and the annotated screenshot. Attach it to CI artifacts, tickets, or email; it has zero external dependencies.
- **SARIF** (via `renderSarifReport`, or `--format sarif` for `report.sarif`): a SARIF 2.1.0 log for CI and code scanning tools. Each WCAG success criterion is one rule (`wcag/1.1.1`), so a criterion keeps its rule id across scans. Issues without a criterion use `best-practice` or `unmapped`. Each issue is one result: critical and high map to `error`, medium to `warning`, and low to `note`. The result message gives the location, the WCAG criterion, the failing element, and the suggested fix. Every result points at the scanned page URL, because the scan sees rendered HTML, not your source files. In Lighthouse-only mode the fix is Lighthouse's text advice, not corrected HTML.
Reports describe what was checked and what was found on one page. Automated and AI checks find WCAG failures but cover only part of WCAG, so a scan cannot establish conformance, and the reports say so: they are not a compliance certification.
## Roadmap
- **GitHub Action** wrapper.
- JUnit output format.
## Contributing
Contributions welcome. Setup:
```sh
git clone https://github.com/charlesjones-dev/a11yhawk
cd a11yhawk
npm install
npx playwright install chromium
npm run verify # lint + typecheck + tests + build; must pass before a PR
```
| Script | What it does |
| ------------------ | -------------------------------------------- |
| `npm run verify` | The full gate: lint, typecheck, tests, build |
| `npm test` | Vitest in watch mode |
| `npm run test:run` | Tests once |
| `npm run format` | Prettier write |
Conventions: TypeScript strict (including `noUncheckedIndexedAccess`), ESM only, tests colocated as `*.test.ts` next to the module they cover. The request guard, URL validator, and Lighthouse spawn are security-sensitive; changes there get extra scrutiny and must not weaken defaults. Please keep PRs focused and include tests for behavior changes.
## Relationship to AccessHawk
A11yHawk is the open-source engine behind [AccessHawk](https://accesshawk.ai), extracted and maintained by the same author. The hosted product adds scheduling, history, dashboards, teams, and a managed API around this engine; the engine itself - capture, audits, AI analysis, and reports - is all here, and running it on your own infrastructure is the point.
## License
Apache-2.0. Copyright Charles Jones.