Configuration
cora reads its configuration from the project root. That is the nearest directory, from where it runs upwards, that holds cora.config.ts, cora.config.json, or a package.json listing @vispero/cora. A project has one config file, not both. Without one, cora uses the defaults. cora-mcp reads the same config for a coding agent’s scans.
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ baseUrl: 'http://localhost:5173', targets: ['/', '/pricing', { crawl: '/docs/', maxPages: 50, maxDepth: 3 }], rules: { overrides: { 'axe/region': 'off' } },});cora.config.jsonholds the same object.defineConfigonly adds types.cora.config.tscan use any TypeScript, and import other modules.- An unknown key is an error, since it is usually a typo.
The config reference lists every key, with its type, its default and what it does. This guide shows how the keys work together.
Editor validation
Section titled “Editor validation”In cora.config.json, $schema names the config’s JSON Schema. An editor such as VS Code then checks each key and value as you type, and completes them. @vispero/cora ships the schema, so name your installed copy’s by its path from the config’s directory:
{ "$schema": "./node_modules/@vispero/cora/schemas/cora-config/v1.json", "baseUrl": "http://localhost:5173", "server": { "command": "npm run dev", "url": "http://localhost:5173" }, "targets": [ { "crawl": "/", "maxPages": 25, "maxDepth": 2, "exclude": ["(?<!/)/(?:[Ll]og|[Ss]ign)[-_]?(?:[Oo]ut|[Oo]ff)(?:[/?#.]|$)"] } ]}These are the keys of the starter config cora init writes for a Vite app, which it writes as cora.config.ts beside a package.json: The starter config says what each key does.
In a monorepo, start the path where the package manager installed cora, such as ../../node_modules/@vispero/cora/schemas/cora-config/v1.json from a package whose dependencies are hoisted to the root. Where no cora is installed, name the copy in the docs instead, as the $schema entry says, with what cora init writes. cora.config.ts needs none, since defineConfig types it.
Targets
Section titled “Targets”Each entry of targets is a page, as an absolute URL or a path, or a crawl:
{ crawl: '/', // where the crawl starts maxPages: 100, // the most pages it takes in maxDepth: 3, // the most links away from the start exclude: ['/logout', '/admin/'], ignoreRobotsTxt: false, // whether it follows links robots.txt disallows}- Both limits have defaults, so no crawl runs unbounded: set them for the part of the site you want, since no one count fits every site.
cora scannotes a crawl that stopped at one with links left to follow. excludeholds regular expressions, each tested unanchored against a link’s absolute URL. Credentials in the URL readREDACTEDthere, as results files store them.- The crawl never follows a link that a pattern matches, and never scans a page that a redirect lands on when a pattern matches it.
- The crawl follows no link a site’s
robots.txtdisallows for CORA, except on the origin ofbaseUrl. SetignoreRobotsTxtfor a site you own on another origin. See Crawls and robots.txt.
rules decides which rules count, for cora scan and cora-mcp alike. An entry of routes decides it again for the pages whose route matches its pattern:
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ rules: { standard: 'WCAG 2.2 AA', bestPractices: true, overrides: { 'axe/region': 'off', '1.4.6': 'on', forms: 'report' }, }, routes: [{ pattern: '^/admin/', rules: { overrides: { 'axe/color-contrast': 'off' } } }],});- The default set. Every rule CORA knows is in its default set, which Vispero keeps, or outside it, such as a rule axe-core calls experimental. The rules reference says which.
standard,bestPracticesand broad overrides choose among the default set’s rules. A rule outside it runs only where an override names it by its ID, such as'axe/p-as-heading': 'on'. - A release that changes the default set says so in its release notes, so you can turn a new rule off by its ID until you are ready for it.
standardchooses the conformance standard whose rules run: a WCAG version and level, Section 508 or EN 301 549. Its reference entry gives what each standard runs.bestPracticesruns the default set’s best practices, the rules no WCAG success criterion requires.overridesset the rules a selector names: a rule ID such asaxe/region, a WCAG success criterion such as1.4.6, a category such asforms, or an engine’s rules such asaxe/*.onruns them, andoffstops them.reportruns them and lists their findings apart; they never fail the gate, and never enter the baseline. Where several keys reach one rule, the most specific decides, in the order its reference entry gives.
Which rules count is the project’s decision, so no variable or user config sets rules.
Rules on a route
Section titled “Rules on a route”A route’s rules apply after the global rules, and after the earlier routes that match the page. A rule that one of the route’s keys reaches takes the route’s setting there. Every other rule keeps its earlier one. A page that redirects is scanned by the rules of the page it lands on.
A route’s standard decides every WCAG rule of the default set, so it turns back on a rule that a global ID key turned off. To keep the rule off on the route, set its ID there too:
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ rules: { overrides: { 'axe/color-contrast': 'off' } }, routes: [ { pattern: '^/docs/', // Without its own key, WCAG 2.2 AAA would run axe/color-contrast on these pages. rules: { standard: 'WCAG 2.2 AAA', overrides: { 'axe/color-contrast': 'off' } }, }, ],});Narrowing a run
Section titled “Narrowing a run”cora scan --only runs only the rules its selectors name, and --skip none of those its selectors name. Each takes selectors comma-separated or repeated. cora-mcp’s scans narrow as --only does, through the selectors in their rules argument, and have no counterpart to --skip.
npx cora scan --only forms,1.4.3 --skip axe/label-title-only- A narrowed run never runs a rule the config leaves off.
- Its results record a config hash of their own, which keeps its shards out of a full run’s shard set in
cora report merge. It does nothing more: a narrowed run’s results still merge with a full run’s. - Its coverage lists only the rules it ran. So an accepted violation of a rule it skipped is not verified, never fixed, and
cora baseline updatekeeps it.
cora baseline update reads the config, and drops the accepted violations of a rule the config does not run on their page’s route. A rule set to report keeps its accepted violations, so they are still accepted once it is on again.
Pages and routes
Section titled “Pages and routes”page sets how cora scan and cora-mcp treat each page they scan. routes sets those options again for the pages whose route matches a pattern:
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ baseUrl: 'http://localhost:5173', targets: ['/', { crawl: '/reports/', maxPages: 20, maxDepth: 1 }], page: { waitFor: 'main', exclude: ['#chat-widget'], retries: 1 }, routes: [ { pattern: '^/reports/', page: { settle: { timeout: 15_000 }, waitFor: (page) => page.getByRole('img', { name: 'Revenue chart' }).waitFor(), timeout: 90_000, }, }, ],});page.settlesays when a page has settled, and when to stop waiting and scan it as it is. Raisesettle.timeoutfor a page whose content keeps arriving for longer. Raisesettle.requestTimeoutwith it for a page whose content waits on a slow request. See Waiting for a page.page.waitForholds the scan until an element matches a CSS selector, or until a function that receives Playwright’sPageresolves. Onlycora.config.tscan hold a function. It also receives{ signal }, which aborts once CORA stops waiting. Pass it to a wait that takes one, such as thesignaloption of Playwright’s waits from Playwright 1.62, so the wait stops with the scan.page.excludelists CSS selectors for elements no rule evaluates, such as a third-party chat widget or a consent banner you do not own. See Elements left out. A route’sexcludereplaces the global list rather than adding to it, soexclude: []scans its pages whole.page.timeoutbounds each visit of a page.page.retriesvisits a page again, in a fresh tab, when a visit fails to load it or runs out of time.routesmatch a page’s route, its URL without its origin, such as/reports/2026?tab=charts, against regular expressions, each tested unanchored. Every matching entry applies, in order. Its options win overpage, and a later entry’s over an earlier one’s, option by option.
Routes apply to every page: a target, a page a crawl found, or one an agent scans. A page that redirects is waited for by the options of the page it lands on. Its visit keeps the requested page’s page.timeout and page.retries.
The settle options, waitFor and exclude change what a scan finds, so only the config file sets them. page.timeout and page.retries change only whether a slow page finishes, so other sources can also set them for one run or one machine: page.timeout’s and page.retries’ reference entries name which.
Locale and time zone
Section titled “Locale and time zone”Every page cora scan and cora-mcp load runs in one locale and one time zone, whatever your machine’s own. So a page shows the same dates, numbers and language, and a scan finds the same, on every machine. browser.locale and browser.timezone set them, to scan the page that visitors in another locale see:
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ baseUrl: 'http://localhost:5173', browser: { locale: 'fr-FR', timezone: 'Europe/Paris' },});browser.localeis a BCP 47 language tag, such asfr-FRorde. It setsnavigator.language, each request’sAccept-Languageheader, and how the page formats dates and numbers.browser.timezoneis an IANA time zone name, such asEurope/ParisorAmerica/New_York. An offset, such as+01:00, fails withconfig.invalid, as does a tag or a name CORA does not know.- Both change what a scan finds, so only the config file sets them. One locale and one time zone serve a whole run, so a route sets neither.
The app’s server
Section titled “The app’s server”server has cora scan start your app’s server before it scans, and stop it once the scan ends, as Playwright’s webServer does. So a CI job needs no step that starts a server and waits for it:
{ "baseUrl": "http://localhost:4173", "targets": ["/", "/pricing"], "server": { "command": "npm run build && npm run preview", "url": "http://localhost:4173" }}server.commandstarts the server.server.urlis where it answers once it is ready, usually yourbaseUrl.- The scan begins once the URL answers. The server stops however the scan ends, with what it started. A process that leaves the command’s process group keeps running, as with Playwright’s
webServer. - A server that never gets ready fails the scan with
server.exitedwhen it exits first, orserver.timed-outatserver.timeout.cora scan --debugkeeps the command’s output in the log file. - A server already answering at the URL fails the scan with
server.already-running, so a scan never reads a server that something else left running. - Source stamps. The command runs with
CORA_SOURCE_STAMPSon, so an app it builds with CORA’s build plugin names each finding’s source line.sourceStampsset tofalseturns that off. In an Angular app, neither switches Angular’s own source locations: the build’s configuration does (Angular). - cora-mcp never starts the server. Start your app before a coding agent scans it.
cora initsets it for your dev server where yourdevscript, or yourstartscript where you have nodev, starts one, as The starter config shows.
To scan a server you keep running yourself while you work, turn on server.reuseExistingServer in your user config, which a CI runner does not have:
{ "server": { "reuseExistingServer": true } }Self-signed certificates
Section titled “Self-signed certificates”A dev server served over HTTPS often presents a certificate your machine does not trust, such as a self-signed one. Google Chrome refuses to load its pages, so cora scan records each as not scanned, with the reason navigation-failed. browser.ignoreHTTPSErrors has the browser load them anyway, as Playwright’s option of that name does:
{ "baseUrl": "https://localhost:5173", "browser": { "ignoreHTTPSErrors": true }}- It applies to every browser CORA opens,
cora scan’s and cora-mcp’s, and tocora scan’s wait forserver.url, which then counts the server ready though its certificate is untrusted. Without it, that wait fails withserver.timed-out, naming the certificate. - The browser then checks no certificate, on any host, so turn it on only for an app you serve yourself.
- Only the config file sets it. It changes nothing a scan of a page finds, so it leaves the config’s hash as it is.
Your user config
Section titled “Your user config”Your user config holds your own settings, which apply in every project. It is config.json in your per-user config directory, which the user config reference locates on each platform. Every cora and cora-mcp you run reads it.
| The project’s config holds | Your user config holds |
|---|---|
| What to scan, which rules run and how the gate judges | How CORA logs and colors its output, and whether it writes for a screen reader |
| How its pages are waited for | Where CORA keeps a project’s state |
| Whether the app’s build carries source stamps, and how its server starts | How cora-mcp runs its browser, and whether cora scan shows its own |
Three keys are in both, since your machine may need them set differently from the project:
page.timeout, the bound on a page’s visit, which a slow machine may need longer;server.timeoutandserver.reuseExistingServer, for the app’s server on your machine.
{ "log": { "level": "info" }, "mcp": { "headless": true }}- Each file holds only the keys of its own reference: the user config reference and the config reference.
- The user config is JSON alone, since it runs no code. CORA never writes it.
- Every CORA release on your machine reads the one file, so a release ignores what it does not take rather than failing, as What a release does not take says.
npx cora infoshows where the file is, whether it was read, and what was ignored. $schemanames the user config’s schema for your editor, as its entry says.
Environment variables
Section titled “Environment variables”Most settings that hold a single value have an environment variable: CORA_ and the key in upper snake case, with _ for each .. So CORA_BASE_URL sets baseUrl, and CORA_POLICY_FAIL_ON sets policy.failOn. A variable suits a setting for one run or one machine, such as a CI job’s:
CORA_POLICY_FAIL_ON=serious npx cora scan- A switch takes
1ortrue, and0orfalse. An empty variable counts as unset. - A value the setting does not accept fails with
config.invalid-environment, naming the variable. - A key that holds a list, a record or a function has no variable. Nor do the keys that decide which rules count, or how a page is waited for and loaded: the project’s config decides those for everyone who scans it.
colorhas noCORA_variable. The terminal’s ownNO_COLORandFORCE_COLORdecide it where neither--color,--no-colornor your user config sets it, but for CI and coding agents, whose output is plain unlessFORCE_COLORis set.
Each key’s Environment line in the two references names its variable, or none. The environment variable reference lists every variable cora and cora-mcp read. The proxy and certificate variables are your machine’s rather than settings: see Networks and proxies.
Which value wins
Section titled “Which value wins”A setting takes its value from the first of these that sets it:
- a flag, such as
cora scan --base-url; - its environment variable;
- your user config;
- the project’s config;
- its default.
color is the one exception: --color and --no-color, then your user config’s color, win over NO_COLOR and FORCE_COLOR, as no-color.org asks of a tool’s own flags and config.
- The value that wins replaces the others whole. Targets on the command line replace the config’s whole list rather than add to it.
- Your user config never changes what the project decides, since each file holds only its own keys. The three keys in both are a bound and server settings, which your machine may need set differently.
- A route’s own
page.timeoutstill wins on the pages it matches, over a flag, a variable or your user config. - The project’s config is checked whole, even where a flag or a variable replaces one of its keys. So an invalid value in it fails every run, naming the file.
- A flag or a variable that sets a project key changes the config’s hash, as an edit to the file does, when the key enters the hash. The config reference marks each key that does. Your own settings never change the hash.
The state directory
Section titled “The state directory”cora and cora-mcp keep a project’s state in .cora/ in the project root, and outside a project in the folder they run in (Scanning without a project): each scan’s results file, the log files you ask for, cora-mcp’s records and browser files, and the report directory a CI scan writes. .cora/ ignores itself in git, so it never shows in git status.
Your user config’s stateDirectory, or CORA_STATE_DIRECTORY, moves it. A relative path is relative to the project root, or outside a project to the folder cora runs in.
{ "stateDirectory": ".cache/cora" }- Everything
.cora/holds moves with it, the report directory included. The directory ignores itself in git wherever it is. - It is never the project root, or outside a project the folder
coraruns in, or a directory that holds it, whose.gitignorewould hide your files.CORA_STATE_DIRECTORYset so fails withconfig.invalid-environment. A user config’s value set so is ignored with a warning, and.cora/applies. - The baseline,
cora.baseline.json, stays in the project root, or outside a project in the foldercoraruns in, since you commit it. A file a flag names, such ascora scan --outor--report, goes where the flag says, relative to the working directory. - It is your own setting, so the project’s config never holds it.
coraand cora-mcp locate their state as they start, before they load the project’s config. - A
corarelease that does not know the setting, such as one a project pins, ignores it with a warning, and keeps its state in.cora/.
Screen readers
Section titled “Screen readers”If you use a screen reader, such as NVDA, JAWS or VoiceOver, turn on screenReader in your user config:
{ "screenReader": true }CORA does not detect a screen reader, so the setting stays off until you turn it on. Your user config keeps it on for every command, in every shell and every project.
CORA_SCREEN_READER=1 turns it on, and CORA_SCREEN_READER=0 off, over your user config. Set it in your shell to turn the setting on for every command you run there, such as a first cora init before your user config exists:
export CORA_SCREEN_READER=1$env:CORA_SCREEN_READER = "1"set CORA_SCREEN_READER=1In Bash and zsh, it can also go before one command, for that run alone:
CORA_SCREEN_READER=1 npx cora initPowerShell and cmd have no such form for one run, so set the variable for the session.
If you already set ACCESSIBLE for other tools’ prompts, CORA honors it too: set to anything but 0 or false, it turns screenReader on where neither CORA_SCREEN_READER nor your user config sets it. Either of those turns it off again for CORA alone.
A screen reader speaks whatever the terminal shows anew. A line rewritten in place is new each time, so a progress count would be spoken again at every change, often without its label. A rewrite can also go unspoken: NVDA ignores a change of one character, which it takes for a typed character, so a count that moves by one digit, as Scanning 3/11 does to Scanning 4/11, is silent. With screenReader on, the cora commands write each thing once, in order:
- A question is asked line by line, such as
cora init’s: the question on a line of its own, with its choices in words and its default, then you type your answer on the next line, or press Enter for the default. Ending the input, as Ctrl+D does on Linux and macOS, cancels the question, whatever you typed on that line, and Ctrl+C stops the command. - Progress is printed as new lines, such as
cora scan’sScanning 3/11and the page it is on, rather than as one line rewritten in place, at most one every 10 seconds while nothing else is printed. When updates come faster, only the latest is printed, and the last one always is: an update still waiting is printed early, ahead of whatever the command prints next. - Nothing moves the cursor or redraws a line, so your screen reader’s review of the terminal finds the output as it was written. A question has no frame around it.
- A file’s diff is in words, such as
cora init’s: a line names the file and counts the lines it removes and adds, a line names each stretch’s lines, such asLines 1 to 7, now lines 1 to 8:, and each line under it starts withadded:,removed:orunchanged:in place of the+,-or space that marks it, since NVDA at its default punctuation level does not speak-. - Tables and summaries are labeled lines, as they are on a pipe: each of
cora status’s scans and fixes is one line, each value after its label, no value is padded into a column, and no mark such as✓stands beside a word, which says the same alone. The gate’s verdict readsGate failed: …rather than as a badge.
What it leaves alone:
- Colors.
colordecides them, so turn it off as well if you want plain text. - Where nothing is drawn or asked anyway, as in CI, under a coding agent, or with stderr redirected to a file:
coraprints no progress line there and asks nothing, though a diff is still in words. - cora-mcp, and machine output such as
--jsonand--out -, whose readers are programs.
Signing in
Section titled “Signing in”To scan pages behind a sign-in, give cora a signed-in browser with either key under browser:
storageState: a Playwright storage-state file, which holds cookies and local storage, relative to the project root. Reuse the file a Playwright authentication setup project writes. Or save one by signing in by hand:npx playwright codegen --save-storage=.auth/state.json <url>. Outside a project,CORA_BROWSER_STORAGE_STATEnames one, relative to the working directory.setup: a function, only incora.config.ts, that receives Playwright’sBrowserContextand signs it in. It runs once, before the first target.
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({ baseUrl: 'http://localhost:5173', targets: ['/account', '/settings'], browser: { async setup(context) { const page = await context.newPage(); await page.goto('http://localhost:5173/sign-in'); await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL ?? ''); await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD ?? ''); await page.getByRole('button', { name: 'Sign in' }).click(); await page.waitForURL('**/account'); await page.close(); }, },});setup must finish within browser.setupTimeout, 60 seconds by default, or the scan fails with signin.setup-failed; raise it if your sign-in takes longer. setup receives a second argument, { signal }, which aborts when that time passes or the scan is stopped: pass it to what accepts one, such as fetch, or listen for it with signal.addEventListener('abort', …) to stop a wait of your own, such as a timer you clear. A setup that ignores the signal can keep the process running after the scan has failed.
setup also covers headers (context.setExtraHTTPHeaders), HTTP basic authentication (context.setHTTPCredentials) and cookies (context.addCookies). cora-mcp’s browser signs in the same way: see Signing in for coding agents.
- Keep secrets out of the config. Read them from environment variables in
setup, as above.corakeeps such variables’ values out of its output by their names, as What logs contain says. - Keep storage-state files out of version control. They hold session cookies.
- Make
setupfail when sign-in fails, as the example does by waiting for the signed-in page. Otherwise a redirect to a sign-in page on the same host can pass the gate, as Targets that were not scanned says.