Skip to content

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.

cora.config.ts
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.json holds the same object.
  • defineConfig only adds types. cora.config.ts can 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.

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:

cora.config.json
{
"$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.

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 scan notes a crawl that stopped at one with links left to follow.
  • exclude holds regular expressions, each tested unanchored against a link’s absolute URL. Credentials in the URL read REDACTED there, 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.txt disallows for CORA, except on the origin of baseUrl. Set ignoreRobotsTxt for 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:

cora.config.ts
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, bestPractices and 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.
  • standard chooses 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.
  • bestPractices runs the default set’s best practices, the rules no WCAG success criterion requires.
  • overrides set the rules a selector names: a rule ID such as axe/region, a WCAG success criterion such as 1.4.6, a category such as forms, or an engine’s rules such as axe/*. on runs them, and off stops them. report runs 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.

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:

cora.config.ts
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' } },
},
],
});

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.

Terminal window
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 update keeps 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.

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:

cora.config.ts
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.settle says when a page has settled, and when to stop waiting and scan it as it is. Raise settle.timeout for a page whose content keeps arriving for longer. Raise settle.requestTimeout with it for a page whose content waits on a slow request. See Waiting for a page.
  • page.waitFor holds the scan until an element matches a CSS selector, or until a function that receives Playwright’s Page resolves. Only cora.config.ts can hold a function. It also receives { signal }, which aborts once CORA stops waiting. Pass it to a wait that takes one, such as the signal option of Playwright’s waits from Playwright 1.62, so the wait stops with the scan.
  • page.exclude lists 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’s exclude replaces the global list rather than adding to it, so exclude: [] scans its pages whole.
  • page.timeout bounds each visit of a page. page.retries visits a page again, in a fresh tab, when a visit fails to load it or runs out of time.
  • routes match 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 over page, 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.

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:

cora.config.ts
import { defineConfig } from '@vispero/cora/config';
export default defineConfig({
baseUrl: 'http://localhost:5173',
browser: { locale: 'fr-FR', timezone: 'Europe/Paris' },
});
  • browser.locale is a BCP 47 language tag, such as fr-FR or de. It sets navigator.language, each request’s Accept-Language header, and how the page formats dates and numbers.
  • browser.timezone is an IANA time zone name, such as Europe/Paris or America/New_York. An offset, such as +01:00, fails with config.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.

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:

cora.config.json
{
"baseUrl": "http://localhost:4173",
"targets": ["/", "/pricing"],
"server": {
"command": "npm run build && npm run preview",
"url": "http://localhost:4173"
}
}
  • server.command starts the server. server.url is where it answers once it is ready, usually your baseUrl.
  • 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.exited when it exits first, or server.timed-out at server.timeout. cora scan --debug keeps 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_STAMPS on, so an app it builds with CORA’s build plugin names each finding’s source line. sourceStamps set to false turns 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 init sets it for your dev server where your dev script, or your start script where you have no dev, 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:

config.json
{ "server": { "reuseExistingServer": true } }

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:

cora.config.json
{
"baseUrl": "https://localhost:5173",
"browser": { "ignoreHTTPSErrors": true }
}
  • It applies to every browser CORA opens, cora scan’s and cora-mcp’s, and to cora scan’s wait for server.url, which then counts the server ready though its certificate is untrusted. Without it, that wait fails with server.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 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:

config.json
{
"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 info shows where the file is, whether it was read, and what was ignored.
  • $schema names the user config’s schema for your editor, as its entry says.

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:

Terminal window
CORA_POLICY_FAIL_ON=serious npx cora scan
  • A switch takes 1 or true, and 0 or false. 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.
  • color has no CORA_ variable. The terminal’s own NO_COLOR and FORCE_COLOR decide it where neither --color, --no-color nor your user config sets it, but for CI and coding agents, whose output is plain unless FORCE_COLOR is 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.

A setting takes its value from the first of these that sets it:

  1. a flag, such as cora scan --base-url;
  2. its environment variable;
  3. your user config;
  4. the project’s config;
  5. 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.timeout still 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.

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.

config.json
{ "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 cora runs in, or a directory that holds it, whose .gitignore would hide your files. CORA_STATE_DIRECTORY set so fails with config.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 folder cora runs in, since you commit it. A file a flag names, such as cora scan --out or --report, goes where the flag says, relative to the working directory.
  • It is your own setting, so the project’s config never holds it. cora and cora-mcp locate their state as they start, before they load the project’s config.
  • A cora release that does not know the setting, such as one a project pins, ignores it with a warning, and keeps its state in .cora/.

If you use a screen reader, such as NVDA, JAWS or VoiceOver, turn on screenReader in your user config:

config.json
{ "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:

Bash or zsh
export CORA_SCREEN_READER=1
PowerShell
$env:CORA_SCREEN_READER = "1"
cmd
set CORA_SCREEN_READER=1

In Bash and zsh, it can also go before one command, for that run alone:

Terminal window
CORA_SCREEN_READER=1 npx cora init

PowerShell 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’s Scanning 3/11 and 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 as Lines 1 to 7, now lines 1 to 8:, and each line under it starts with added:, removed: or unchanged: 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 reads Gate failed: … rather than as a badge.

What it leaves alone:

  • Colors. color decides 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: cora prints no progress line there and asks nothing, though a diff is still in words.
  • cora-mcp, and machine output such as --json and --out -, whose readers are programs.

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_STATE names one, relative to the working directory.
  • setup: a function, only in cora.config.ts, that receives Playwright’s BrowserContext and signs it in. It runs once, before the first target.
cora.config.ts
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. cora keeps 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 setup fail 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.