Skip to content

Library API ​

The TypeScript API that src/index.ts exports: createQuorum and its ports, a review and its events, abort, the errors, the config types, and every export. There is no npm package yet: a program imports src/index.ts from a clone, and the examples on this page do so from a file at the root of the clone. Bun runs it as it is; the code is erasable TypeScript only, so Node 22.18 or later on the 22 line, or 23.6 or later, which strip types by default and are what the engines field of package.json asks for, load it too.

createQuorum ​

ts
declare function createQuorum(options: QuorumOptions, ports: QuorumPorts): Quorum

interface QuorumOptions {
  readonly profiles: readonly Profile[]
  readonly concurrency?: number
  readonly maxFindings?: number
}

interface QuorumPorts {
  readonly profiles: Readonly<Record<string, ProfilePorts>>
  readonly events?: EventSink
}

interface ProfilePorts {
  readonly research: Provider
  readonly voters: Readonly<Record<string, Provider>>
}

interface Quorum {
  readonly review: (input: { readonly diff: string }, options?: { readonly signal?: AbortSignal }) => Promise<QuorumResult>
  readonly warnings: readonly string[]
}

Options and ports ​

FieldHoldsDefault
profilesThe profiles to run: at least one, ids uniqueRequired
concurrencyThe most provider calls in flight at once in one review, shared by every profile; a positive integer4
maxFindingsThe most findings of a research reply each profile reads, the only ones that can reach its panel; a positive integer50
ports.profilesOne entry per profile, keyed by profile id: research, its research provider, and voters, one provider per lens keyed by lens idRequired
ports.eventsThe event sink: a function that receives every event of every reviewNone

One provider object may fill several ports: the research provider of a profile can also be every one of its voters.

Input checks ​

createQuorum checks its options and ports before anything else, and throws one ConfigError that lists every issue, one path: message line each, never a TypeError, so a program without types gets the same report:

PathMessage
options, portsmust be an object
profilesmust be an array of profiles, or must hold at least one profile
profiles.<index>, <id> and belowThe profile's own issues: The checks
profiles.<index>.idrepeats the profile id "security"
concurrency, maxFindingsmust be a positive integer, got 0, the value quoted when it is a number
ports.profilesmust be an object keyed by profile id
ports.profiles.<id>is missing, must be an object with a research provider and voters, or names no profile of the run
ports.profiles.<id>.researchis missing, or must be a provider: an object with a string id, a string model and a complete function
ports.profiles.<id>.votersis missing, or must be an object keyed by lens id
ports.profiles.<id>.voters.<lens>is missing, the provider message above, or names no lens of the profile
ports.eventsmust be a function

A port for a profile or a lens the review does not have is refused, as a likely typo. A profile's ports are checked only when the profile itself has no issue, and no port is checked when profiles is not an array. Every record keyed by an id is read through its own properties, so constructor is as valid an id as any other. For the security profile with concurrency: 0, maxFindings: 2.5, a research port that is not a provider, its defenses voter missing and one named typo instead, a port for a profile named other, and a sink that is a string, the message reads:

text
concurrency: must be a positive integer, got 0
maxFindings: must be a positive integer, got 2.5
ports.profiles.security.research: must be a provider: an object with a string id, a string model and a complete function
ports.profiles.security.voters.defenses: is missing
ports.profiles.security.voters.typo: names no lens of the profile
ports.profiles.other: names no profile of the run
ports.events: must be a function

The snapshot ​

Each profile is compiled once, when the quorum is created: its vote template rendered for each lens, its thresholds resolved, and its providers captured beside the { id, model } each had then, which is what events and meta report. A later change to a profile, or to which provider a port names, is never seen by a review of that quorum. The provider objects themselves are called as they are.

warnings ​

The settings of the profiles that cannot take full effect, each starting at its profile id, such as a policy level that is never reached: Warnings. A quorum with warnings still runs.

review ​

ts
const result = await quorum.review({ diff }, { signal: AbortSignal.timeout(10 * 60_000) })
  • diff is the unified diff, as a string. A blank one runs no stage and calls no model; the review still sends run:start and run:end, and resolves with empty lists.
  • signal, optional, stops the review: Abort.
  • A quorum reviews any number of diffs, one per call. Each review has its own concurrency limit and its own signal, and several may run at once.

It resolves with the result, or rejects:

WithWhen
A ConfigErrorThe input is malformed, before any event: input: must be an object holding the diff string when the input is not an object, such as the diff text passed on its own; diff: must be a string; options: must be an object; or signal: must be an AbortSignal
The signal's reasonThe signal aborted, before the review or while it ran
The sink's errorThe event sink threw
A ProviderError, or what a provider of your own throwsA profile's research call failed, unless it was a refusal of kind too-large, which is a gap
An OutputErrorA profile's research reply is not one JSON object holding a findings array and nothing else

A failed vote never rejects a review: it is recorded in its verdict.

Events ​

The sink receives every event of a review, synchronously and in order, as each thing happens:

EventFieldsSent
run:startprofiles, the ids in order; providers, who reviews for each profile: research and lenses, each a { id, model }First, when the review starts
flagflag, as meta.flags holds it: A flagOnce per flag, right after run:start, before any stage
stage:startprofile; stage: research, dedup or panelWhen a stage of a profile starts
stage:endprofile, stage; durationMs, the stage's wall-clock time, waits for a concurrency slot included; count, the findings it produced, or the verdicts for panelWhen that stage ends
callprofile; role: research or vote; lens, for a vote; provider, its { id, model }; durationMs, the provider's latency inside its slot; ok; usage, when the call succeeded and its counts are validOnce per provider call, failed ones included
finding:invalidprofile; reason, such as src/auth.js:3 is not in the diffOnce per finding dropped for citing code outside the diff
incompleteentry, as meta.incomplete will hold itOnce per gap, as it is recorded
voteprofile, findingId, lens; keep: true, false, or null when the vote failed; error, for a failed voteOnce per vote
verdictprofile, findingId, keptVotes, totalVotes; confidence, when it reached one; status: kept, discarded or unreviewedOnce per finding, when all its votes are in
run:endkept, discarded, unreviewed and incomplete, the counts; durationMsLast, when the review ends

The first events of the review on The JSON result, as the sink received them:

text
{"type":"run:start","profiles":["security"],"providers":{"security":{"research":{"id":"default","model":"qwen3.6:27b"},"lenses":{"reachability":{"id":"default","model":"qwen3.6:27b"},"impact":{"id":"default","model":"qwen3.6:27b"},"defenses":{"id":"default","model":"qwen3.6:27b"}}}}}
{"type":"stage:start","profile":"security","stage":"research"}
{"type":"call","profile":"security","role":"research","provider":{"id":"default","model":"qwen3.6:27b"},"durationMs":0,"ok":true,"usage":{"inputTokens":1840,"outputTokens":412}}
{"type":"finding:invalid","profile":"security","reason":"src/auth.js:3 is not in the diff"}
{"type":"incomplete","entry":{"profile":"security","reason":"finding-unreadable","detail":"findings[4]: quote: Invalid input: expected string, received undefined"}}
{"type":"stage:end","profile":"security","stage":"research","durationMs":8,"count":3}
  • The sink runs inside the review. Each event is delivered before the review goes on, so a slow sink slows the review.
  • Events carry no model text beyond error strings, the file name a finding:invalid reason quotes, which is the finder's, cleaned and capped at 80 characters, and, for a diff without per-file headers, the file and detail of a vote-failed entry or of a vote's request-too-large entry, which hold the finder's name for the file, cleaned and capped at 260. The file of a flag event is the diff's own path, and absent for a diff without per-file headers.
  • The sink gets a copy of each event. Changing one changes neither the review nor its result.
  • meta agrees with the events. meta.usage and each invalidFindings are counted from them.
  • A sink that throws aborts the review with its error, so the bug surfaces instead of being swallowed: no profile starts another call, no further event is delivered, and the review rejects with that error, even when it threw on run:end.

Abort ​

ts
const controller = new AbortController()
const pending = quorum.review({ diff }, { signal: controller.signal })
controller.abort()
await pending // rejects with controller.signal.reason

An aborted review never resolves, and never fails open:

  • It rejects with the signal's reason, whatever error a call it cut raised. A signal already aborted rejects at once, before any event or call.
  • The signal is checked before every stage, and inside every call's concurrency slot before the provider is called, so a call still queued when the review is aborted never starts. Every request carries the review's signal, so a provider can stop a call in flight. It is checked once more after the profiles have run, a blank diff included, so an abort that lands when no stage is left to check still rejects.
  • A vote cut by the abort is never a failed vote. It rethrows the abort's reason, so an aborted review never resolves with a finding the abort left unreviewed, and the review rejects at once, without waiting for the votes still in flight.
  • No event is delivered once the review is aborted.
  • One failed profile stops the others. A profile whose research fails aborts the review with that error, and the review rejects with it. When the research call itself fails, the review is aborted before the call's concurrency slot is released, so no other profile starts a call after it. When the reply is not a valid envelope, the review is aborted when the reply is checked, which happens after the call has released its slot, so a call another profile had queued may already have started. Either way, no event arrives after the rejection.
  • An abort after run:end has no effect: the review is over, and resolves with its result.
  • Stopping a call in flight is cooperative. The shipped adapters pass the signal to fetch. A provider that ignores it can keep an aborted review waiting until its call settles, unless another call of the review stops on the signal first; the review then rejects all the same.

Errors ​

ClassExtendsFieldsThrown for
QuorumErrorErrorname, the class's own nameThe base of every error quorum raises on purpose
ConfigErrorQuorumErrorissues, each a { path, message }, every one; the message holds one path: message line per issue for the first 50, then and <n> more issuesInvalid configuration or input, every issue at once. The CLI exits 2 on it
ProviderErrorQuorumErrorproviderId, kind, status, retryableA provider call that failed: ProviderError
OutputErrorQuorumErrorstage, reason; snippet, the first 200 characters of the replyA model reply that could not be read: <stage> reply unusable: <reason> (reply starts: "<snippet>")

A failed research call rejects the review with its ProviderError, and an unusable research reply with its OutputError. The same errors from a vote are recorded in the vote's error instead. A ProviderError of kind too-large, from research or from a vote, is a request-too-large gap instead: the review does not reject, and a provider of your own raises it to have its own refusal of an oversized request recorded so. Any object with an own kind property of 'too-large' counts, whatever its class, so a refusal from a second copy of quorum is a gap too.

Config types ​

ts
declare function defineConfig(config: QuorumConfig): QuorumConfig

loadConfig reads a named config file and fromConfig builds a quorum from a config: loadConfig and fromConfig. The CLI and runQuorum read their settings through these two functions: Configuration. These exports write and type-check a config:

  • QuorumConfig types what a TS or JS config exports as its default: $schema, for an editor, version, providers, roles, concurrency, maxFindings and profiles, every one optional. A provider is an object implementing Provider, or its settings, every one optional too: preset, protocol, baseUrl, model, contextTokens, apiKeyEnv and apiKey, then auth, headers, query, extraBody, structuredOutput, temperature, maxTokens, maxTokensField and systemPrompt, as Providers describes them, and the fields of a claude-code provider, args and, in a code config only, command: Claude Code options.
  • QuorumConfigFile types what a YAML or JSON config holds: the same settings, as data only, so a provider is always its settings, and names the environment variable that holds its key in apiKeyEnv, never holding one.
  • ConfigInput types what fromConfig takes: the settings of QuorumConfig, with every prompt a string, since a program's config reads no file and so holds no { file }.
  • defineConfig returns the config it is given, as it is. Wrapping a default export in it has the editor check every field.

A config that imports nothing from quorum when it runs types its object with import type and satisfies instead, since import type is erased:

ts
// quorum.config.mts
import type { QuorumConfig } from './src/index.ts'

export default {
  providers: {
    local: { preset: 'ollama', model: 'qwen3.6:27b', contextTokens: 131072 },
    hosted: { preset: 'openai', model: 'gpt-5', contextTokens: 400000, apiKey: process.env.OPENAI_API_KEY },
  },
  roles: { research: 'local', voter: 'hosted' },
} satisfies QuorumConfig

The .mts extension makes the file an ES module whatever the nearest package.json says: Node refuses export in a .ts or .js file of a package whose package.json says "type": "commonjs". Node also strips erasable TypeScript only, so a config holds no enum, namespace or parameter property. An editor checks a YAML or JSON config against the JSON Schema committed as schema/quorum.schema.json: a YAML file names it in a comment, # yaml-language-server: $schema=<path or URL of the schema>, and a JSON file in its "$schema" field.

loadConfig and fromConfig ​

ts
declare function loadConfig(options?: {
  readonly cwd?: string
  readonly env?: Env
  readonly configPath?: string
  readonly selection?: ProfileSelection
}): Promise<{ readonly config: ResolvedConfig, readonly source: string | undefined, readonly warnings: readonly string[] }>

declare function fromConfig(
  config: ConfigInput | ResolvedConfig,
  options?: { readonly env?: Env, readonly events?: EventSink },
): Promise<Quorum>

type Env = Readonly<Record<string, string | undefined>>

interface ProfileSelection {
  readonly ids: readonly string[]
  readonly path: string
}

loadConfig reads the config file it is named, if any, lays the environment over it and resolves the result into a ResolvedConfig: every layer merged, every reference bound, every key read. fromConfig builds a quorum from a config: a ConfigInput, which it checks and resolves, or a ResolvedConfig, which it uses as it is. Both read process.env when no env is given, and both throw a ConfigError before anything is sent, with every issue of the step that failed: loadConfig, those of the file, or else those of the resolution; fromConfig, those of the config's check, or else those of the resolution and the providers.

ts
import { fromConfig, inertLine, loadConfig } from './src/index.ts'

const { config } = await loadConfig({ configPath: 'quorum.yaml' })
const quorum = await fromConfig(config)
for (const warning of quorum.warnings) console.error(inertLine(`warning: ${warning}`))
const result = await quorum.review({ diff })

fromConfig({}, { env: process.env }) needs no file: the environment alone names a provider, a model and the model's context size, with QUORUM_RESEARCH_MODEL and QUORUM_RESEARCH_CONTEXT_TOKENS. The CLI and runQuorum read the QUORUM_* variables, with the deprecated RESEARCH_* and VOTER_* aliases, and the file QUORUM_CONFIG names through these two functions.

  • options.cwd is the directory a relative configPath resolves against, the working directory by default. source is the absolute path of the file read, and undefined when none was.
  • QUORUM_CONFIG must be an absolute path. A relative one is refused with a ConfigError holding the issue QUORUM_CONFIG: must be an absolute path, whether or not configPath is given: a variable set once would resolve inside whatever directory the process runs in, and in CI that is a pull request's checkout, which could plant the file. A configPath is named per call, so it may be relative.
  • options.selection names the profiles that run, over QUORUM_PROFILES, which is still read and checked: a malformed variable is refused even when a selection is given. path is what the selection's issues name, and the CLI passes --profile. Each id must match ^[a-z][a-z0-9_-]*$, and none may repeat.
  • options.events is the event sink of the quorum, as createQuorum takes it.
  • options.env is also the environment a claude-code provider's child starts from, less what the provider's auth mode removes: The auth modes. Without an env, fromConfig hands process.env to the claude child, the whole of it, for a ResolvedConfig too. A program that holds secrets claude does not need passes an env of its own, with PATH and what claude authenticates with.
  • A process imports a TS or JS config once. A second loadConfig of the same file gets the module the first import cached, since Node and Bun cache a module by its URL.
  • A named config is trusted input. It chooses where each key goes, and a TS or JS one runs code: in CI it must come from the base branch or from outside the pull request's checkout, never from the pull request under review, and its prompt files with it (Layering).
  • A ResolvedConfig holds providers, the ones a profile that runs uses, by name: a spec with its key read into apiKey, or the snapshot of an object implementing Provider; profiles, each a ResolvedProfile: its profile, research, the name of its research provider, and voters, the name of each lens's provider, by lens id; concurrency, maxFindings, and warnings. It is a copy, frozen all the way down: a provider object is a frozen snapshot of the program's, with no prototype, named by its config key as its id.
  • A ResolvedConfig holds the keys it read, as a code config does: never print or log one. The CLI's --print-config redacts them.
  • fromConfig of a ResolvedConfig reads no environment: what loadConfig resolved is what runs, whatever options.env holds, except that a claude-code provider's child still starts from options.env, else process.env.
  • A config is read as its own fields, with one limit. A field planted on Object.prototype is a prototype pollution bug in the same process. An enumerable one is a field of every object, so it is refused as is not a known field wherever the schema does not know it: a planted baseUrl is refused at the top level, while a planted concurrency, maxFindings or version passes when the config holds no object that refuses it. One planted with Object.defineProperty is not enumerable, and the schema reads each field it knows through the prototype, so it becomes a field of the config, or of every provider that leaves it unset. Code that can plant either can read the keys directly: quorum does not defend against it.

Layering ​

Each layer sits over the one before it: the built-in defaults, then the file configPath names (else the one QUORUM_CONFIG names), then the environment.

  • The built-in defaults. A config that defines no provider gets one named default, with no model and no context size, so QUORUM_RESEARCH_MODEL and QUORUM_RESEARCH_CONTEXT_TOKENS, or a file, must set them. The rule: with no provider configured and no base URL, preset or protocol variable for a role, that role talks to the Ollama on this machine through its native API, http://localhost:11434, where Ollama listens, on 127.0.0.1, by default; a role whose variables set a base URL without a preset or a protocol speaks openai-compatible to that URL, as before. A voter with no variables of its own shares the research provider: The voter's fallback. The native API tells Ollama the context size and makes it refuse an overflow (Ollama 0.12.6 or later; an older server ignores truncate and shift), where its OpenAI-compatible /v1 cuts an oversized prompt in silence. A config with no profiles field runs the security profile.
  • A profile over its base. A profile named after a built-in profile, or with extends, starts from it: its prompts and its policy levels replace the base's one field at a time, and its lenses replace the base's lenses as a whole.
  • undefined is unset, null is set. In a program's config or a code config, a field set to undefined leaves the layer below's value in place, as if it were not written; null is a value, such as temperature: null, which leaves the field out of the request.
  • No file is searched for. A file loads only when it is named, by configPath first, then QUORUM_CONFIG: a quorum.config.yaml in the working directory is never read, since in CI that directory is a pull request's checkout, and a pull request could plant one.
  • A named config is trusted like code. It chooses where each key goes: a baseUrl of its own beside an apiKeyEnv sends the key that variable holds to that host. A TS or JS config also runs, with the job's environment. So in CI a named config must come from the base branch or from a path outside the pull request's checkout, never from the pull request under review, and its prompt files, which resolve against its directory, must come from the same place.

The environment variables ​

VariableSets
QUORUM_CONFIGloadConfig only: the config file to read, when configPath is not given. It must be an absolute path
QUORUM_PROFILESThe profiles that run: ids separated by commas, each a profile the config defines or a built-in one, none twice
QUORUM_RESEARCH_PRESET, QUORUM_VOTER_PRESETA role's provider, built from a preset
QUORUM_RESEARCH_PROTOCOL, QUORUM_VOTER_PROTOCOLA role's provider, built from a wire protocol: openai-compatible, anthropic, gemini or ollama, or claude-code for your installed Claude Code CLI
QUORUM_RESEARCH_BASE_URL, QUORUM_VOTER_BASE_URLThe base URL of a role's provider. Set on its own, with no provider in a config and no preset or protocol variable for the role, it names an openai-compatible server, except for a voter derived from a research provider whose variables name a preset or a protocol, which keeps that protocol: The role variables
QUORUM_RESEARCH_MODEL, QUORUM_VOTER_MODELThe model of a role's provider
QUORUM_RESEARCH_CONTEXT_TOKENS, QUORUM_VOTER_CONTEXT_TOKENSThe context window of a role's provider's model, in tokens: a whole number from 1 to 999999999. It has no deprecated alias
QUORUM_RESEARCH_API_KEY, QUORUM_VOTER_API_KEYThe key of a role's provider, as a literal apiKey
QUORUM_CLAUDE_CODE_COMMANDThe claude executable of every claude-code provider a profile uses: an absolute path, or a name found in the absolute entries of PATH; a relative path and a .cmd or .bat file are refused. It wins over a code config's command: Where the command comes from
RESEARCH_BASE_URL, RESEARCH_API_KEY, RESEARCH_MODEL, VOTER_BASE_URL, VOTER_API_KEY, VOTER_MODELDeprecated aliases: each is read as the QUORUM_ variable of the same name
  • The QUORUM_ form wins over its alias when both are set, and one warning names every alias that is set, whichever form won: RESEARCH_MODEL is deprecated: set QUORUM_RESEARCH_MODEL instead. A preset, a protocol and a context size have no alias.
  • Every value is trimmed, and a blank one is unset. Only the variables the environment holds itself are read, never one an object it inherits from holds.
  • QUORUM_PROFILES refuses what would review nothing or the wrong thing: an empty id, an id that is not lower-case letters, digits, _ and - after a letter, or an id named twice.

The role variables ​

  • The research variables set the provider roles.research names, which a voter or a lens that shares it inherits.
  • The voter variables set the provider roles.voter names when that is another provider than research's. When the voter shares the research provider, they derive a provider named voter from it, and bind the voter role to it. A config that already has a provider named voter cannot be derived over: rename it.
  • A derived voter keeps what is safe on its endpoint, as the voter's fallback of Configuration does, and a key, header, query parameter, body field or model never reaches another origin:
QUORUM_VOTER_BASE_URLThe voter's base URLIts keyIts modelIts context sizeThe rest of the research provider's settings
UnsetThe research provider'sQUORUM_VOTER_API_KEY, else the research provider's keyQUORUM_VOTER_MODEL, else the research provider'sQUORUM_VOTER_CONTEXT_TOKENS, else the research provider's while no other voter variable but the key is setAll kept
The same origin as the research provider'sQUORUM_VOTER_BASE_URLQUORUM_VOTER_API_KEY, else the research provider's keyQUORUM_VOTER_MODEL, else the research provider'sQUORUM_VOTER_CONTEXT_TOKENS onlyAll kept
Another origin, or no http(s) originQUORUM_VOTER_BASE_URLQUORUM_VOTER_API_KEY, else none: a preset's own variable is not readQUORUM_VOTER_MODEL, which is then requiredQUORUM_VOTER_CONTEXT_TOKENS onlyapiKeyEnv, headers, query, extraBody and model dropped; the preset, protocol, auth, structuredOutput, temperature, maxTokens, maxTokensField and systemPrompt kept
A QUORUM_VOTER_PRESET or QUORUM_VOTER_PROTOCOL is setIts own, or the preset'sQUORUM_VOTER_API_KEY, else the preset's own variable at the preset's originQUORUM_VOTER_MODEL, which is then requiredQUORUM_VOTER_CONTEXT_TOKENS onlyNone: the provider is built from the voter variables alone

The origin is compared as the voter's fallback compares it: the scheme, the host in lower case with no trailing dot, and a port that is not the default.

  • On the built-in default provider, a voter base URL on its own is openai-compatible. A derived voter whose variables set a base URL with no preset or protocol speaks openai-compatible, on another origin than research's and on the same one too (QUORUM_VOTER_BASE_URL=http://localhost:11434/v1), and does not inherit the default's native protocol. One that sets only a model, a context size or a key keeps research's endpoint and protocol. A voter derived from a provider a config defines, or from a research provider whose variables name a preset or a protocol, inherits that provider's protocol, as before.
  • A PRESET or PROTOCOL variable builds the role's provider afresh. QUORUM_RESEARCH_PRESET=anthropic is not merged into the default provider, whose protocol it would contradict: the research provider is a provider of that preset, from the role's variables alone. BASE_URL, MODEL, CONTEXT_TOKENS and API_KEY alone override fields of the provider they reach.
  • A context size belongs to one model on one endpoint. A role's preset, protocol, base URL or model variable drops the context size of the provider it sets, unless QUORUM_<ROLE>_CONTEXT_TOKENS sets one; a key variable alone keeps it. A derived voter never inherits the research provider's context size for another model or another endpoint.
  • No variable changes a provider object. A role bound to an object that implements Provider with a variable set for it is an issue at the variable, never an ignored one.

Roles and profiles ​

  • The role defaults. One provider binds both roles. Several need roles.research, or the config is refused; roles.voter is roles.research when unset. A profile's own roles override the top-level ones, and a lens's provider overrides both.
  • Which profiles run. The ones selection names, else the ones QUORUM_PROFILES names, else the ones profiles defines, else security. profiles: {} with no selection is refused: a run that reviews nothing never passes.
  • A profile's base. A profile named after a built-in profile starts from it, so profiles: { security: { gating: false } } is the security profile without gating; so does one with extends. A profile of its own needs both prompts and its lenses. Its gating is the base's, else true.
  • Every defined profile and provider is checked, whether or not a run selects or uses it, so a broken definition fails every run, as an unknown field does.

Keys ​

A provider's key is the first of:

  1. A literal apiKey: a program's or a code config's, or QUORUM_<ROLE>_API_KEY, which wins over a variable a file names with apiKeyEnv. An empty one is unset, as a blank variable is, so the next source applies.
  2. The variable apiKeyEnv names. A variable that is not set is an issue, never a silent run without a key.
  3. The preset's own variable (OPENAI_API_KEY for openai), only while the base URL keeps the preset's origin. At another origin it is not read, and a warning says so.
  4. For azure-openai, whose preset has no base URL, AZURE_OPENAI_API_KEY, only on an https host under openai.azure.com.
  • A claude-code provider reads a key in api-key mode only: apiKey, then the variable apiKeyEnv names, else ANTHROPIC_API_KEY. It has no preset, and a key in another mode is refused.
  • AZURE_OPENAI_API_KEY is one variable for every Azure resource. It is read for any host under openai.azure.com, so a voter whose own variables name the azure-openai preset on another Azure host sends that key there.
  • A QUORUM_<ROLE>_BASE_URL set on a provider with an explicit apiKeyEnv keeps that key at the new URL. It overrides the provider itself, not a fallback, so a literal apiKey, headers, query, extraBody and model also stay with the provider at the new URL. A preset's own variable is withheld off its origin, with a warning.
  • A voter derived onto another origin gets no key of the research provider, whichever way it was set: apiKey, apiKeyEnv, a preset's variable or a QUORUM_RESEARCH_API_KEY.
  • Keys are read only for the providers a profile that runs uses, so a CI job that runs one profile needs that profile's keys alone. Every provider is still checked, without its key.
  • A key sent over plain http to a host other than this machine (localhost, *.localhost, 127.0.0.0/8 and [::1] are this machine) is warned about.
  • A resolved config holds its keys, so never print or log one. No message or warning repeats a key, a URL, a header value or a variable's value, and a variable's name is printed only when it is an upper-case name.

Warnings and errors ​

loadConfig returns the config's warnings: the deprecation line, the line naming the QUORUM_ variables that nothing reads, a preset's key that is not sent, a key sent over plain http, and a key that Claude Code would bill. The warnings of the quorum fromConfig returns hold those first, then the core's: a profile setting that cannot take full effect, Warnings. A provider object is read only while its config is checked, inside the guard that keeps what it throws to the error's name: its model once and its complete twice (the check of plain data, then the snapshot), into a frozen snapshot with no prototype that calls the object as it is, and that snapshot is named by its config key in events and in meta, whatever id the object holds.

PathMessageCause
The path as givenThe file's own problem, such as does not exist, is not valid YAML at line 2, column 1 (DUPLICATE_KEY), has a syntax error or failed to load (Error)loadConfig could not read the file, or it is not valid
QUORUM_CONFIGmust be an absolute pathloadConfig read a relative QUORUM_CONFIG; configPath may be relative to cwd
configmust be an objectA ConfigInput that is not a plain object
configcould not be read (Error)A getter or a proxy of the config threw, one of a provider object's included: each provider object is read only here, and only the error's name is kept, an error when that is not a plain class name
configholds more than 10000 valuesA ConfigInput too large to walk
<path>is not allowed as a key: it can replace the prototype of an object, must be plain data: an object, an array, a string, a finite number, true, false or null, must be a finite number, or is nested more than 64 keys deepA ConfigInput that holds data a config may not
<path>The schema's: is not a known field, is required, must be a string, must be at least 1, and the likeA field of the wrong shape, at its path
QUORUM_PROFILESmust list profile ids separated by commas, each matching ^[a-z][a-z0-9_-]*$An empty id, or an id of another form
QUORUM_PROFILESnames a profile more than oncesecurity,security
QUORUM_PROFILES, or the selection's path (--profile)unknown profile "tema" (did you mean "team"?), or unknown profile "style": the profiles are team and securityAn id that is neither defined nor built in
The selection's pathmust be a profile id matching ^[a-z][a-z0-9_-]*$An empty id, or an id of another form, in selection.ids
The selection's pathnames a profile more than onceselection.ids names an id twice
The selection's pathmust name at least one profileselection.ids is empty
QUORUM_RESEARCH_CONTEXT_TOKENS, QUORUM_VOTER_CONTEXT_TOKENSmust be a whole number of tokens, from 1 to 999999999A context size variable that is not a whole number written in decimal digits in that range: 32k, 1e5, 0x10, 1.5 and 0 are refused, and the message never repeats the value
QUORUM_VOTER_MODELmust be set when the voter variables name another endpoint than the research provider'sThe voter variables name another origin, a preset or a protocol, and no model
QUORUM_RESEARCH_MODEL, and the other variables of a rolecannot change providers.mine, an object implementing ProviderA variable set for a role bound to a provider object
providers.voteris the name the voter variables give the provider they derive: rename this providerThe voter variables derive a provider, and one is already named voter
profilesmust hold at least one profile, or be left outprofiles: {} with no QUORUM_PROFILES
profiles.<id>.prompts.research, profiles.<id>.prompts.vote, profiles.<id>.lensesis required: the profile extends no built-in profileA profile of its own without prompts or lenses
roles.research, roles.voter, profiles.<id>.roles.research, profiles.<id>.roles.voter, profiles.<id>.lenses.<index>.providerunknown provider "claud" (did you mean "claude"?), or unknown provider "x": the providers are a and bA reference to a provider that is not defined: the defined name one or two edits away, else the names. A name that is not a safe name is not repeated: names an unknown provider
roles.researchis required when several providers are definedSeveral providers, and no research role
profiles.<id> and belowThe profile's own issues: The checksA profile that fails a check
No pathno model configured: set QUORUM_RESEARCH_MODEL, or name a config file that sets oneThe built-in provider has no model
No pathno context size configured: set QUORUM_RESEARCH_CONTEXT_TOKENS, or name a config file that sets contextTokensThe built-in provider has no context size. A missing model is reported first, and alone
providers.<name>.contextTokensis required: the model's context window in tokens, which quorum checks every request againstA provider of the config with no contextTokens, used by a profile or not. A derived voter that shares the research provider, context size included, is not reported twice: the research provider's issue stands for it
QUORUM_RESEARCH_CONTEXT_TOKENSmust be set when the research variables name a model or an endpoint: a context size belongs to one model on one endpointThe research variables name a preset, a protocol, a base URL or a model, which drops the context size of the provider they set
QUORUM_VOTER_CONTEXT_TOKENSmust be set when the voter variables name a model or an endpoint: a context size belongs to one model on one endpointThe same for the voter variables, on the derived voter or on the voter's own provider
providers.<name>CORP_KEY is not setThe variable apiKeyEnv names, or the preset's own, is not set; ANTHROPIC_API_KEY for a claude-code provider in api-key mode
providers.<name>claude CLI not found; install Claude Code or set QUORUM_CLAUDE_CODE_COMMANDA claude-code provider a profile uses, and no command and no claude in an absolute entry of PATH
QUORUM_CLAUDE_CODE_COMMANDmust be an absolute path, or a name to find on PATH, is a Windows script, which runs only through a shell: name claude.exe instead, or names no executable fileA relative path, a .cmd or .bat file, or a command that is no executable file. A code config's command has the last at providers.<name>.command
providers.<name>.apiKeyEnvnames a variable that is not setThe same, for a name that is not an upper-case variable name: a key pasted there is never printed
providers.<name>.<field>The builder's: Presets as data and Configuration errorsA provider that the builder refuses, defined or derived
QUORUM_RESEARCH_PRESET and the other variables of a role: the preset, the protocol, the base URL, the model, the context size and the keyThe builder's, such as must be one of ollama, ollama-cloud, ...A provider that the builder refuses on a field a variable set: the issue names the variable, never the provider the variables built
providers.<name>.modelmust be a stringA provider object whose model is not a string

The warnings, each but the first two starting at the path it is about:

WarningCause
RESEARCH_MODEL is deprecated: set QUORUM_RESEARCH_MODEL insteadA deprecated alias is set; one line names every alias, and every QUORUM_ variable to set instead
QUORUM_PROFILE is not a variable quorum readsA QUORUM_ variable that is set and that quorum does not read: one line names every such variable, sorted, and prints a name that is not an upper-case variable name as *
providers.gpt.baseUrl: is not the endpoint of the openai preset, so OPENAI_API_KEY is not sent: set apiKeyEnv to send a keyA provider of a preset whose base URL is not the preset's origin
providers.voter.baseUrl: is not the endpoint of the research provider, so OPENAI_API_KEY is not sent: set QUORUM_VOTER_API_KEY to send a keyA derived voter on another origin than research's, whose provider keeps a preset that has a key variable
providers.gpu.baseUrl: sends the key over plain http to a host other than this machineA key that would travel in cleartext
providers.default: ANTHROPIC_API_KEY is set, so Claude Code bills that key rather than its login: unset it, or set auth to login or api-keyA claude-code provider a profile uses, under inherit or with no auth, while ANTHROPIC_API_KEY is set

Exports ​

Values
createQuorumBuild a quorum
defineConfigType a TS config: Config types
fromConfigBuild a quorum from a config: loadConfig and fromConfig
loadConfigRead a named config file and resolve it with the environment: loadConfig and fromConfig
securityProfileThe default profile, frozen: Profiles
defaultPolicy{ high: 'unanimous', medium: 'majority', low: 'off' }, frozen
anthropicThe Anthropic Messages adapter: Providers
claudeCodeThe adapter that runs your installed Claude Code CLI: Providers
geminiThe Gemini generateContent adapter: Providers
ollamaThe Ollama native chat adapter: Providers
openaiCompatibleThe OpenAI-compatible adapter: Providers
presetsThe presets, frozen: Presets
QuorumError, ConfigError, ProviderError, OutputErrorThe error classes
inertLine, inertJsonRender a line for a terminal or a log, and a value as JSON, inert: Output handling
runQuorumDeprecated: below

The types: Confidence, Finding, Flag, FlagCode, IncompleteEntry, IncompleteReason, Lens, LensVote, Profile, ProfileMeta, ProfileProviders, Prompts, ProviderRef, Quorum, QuorumEvent, QuorumMeta, QuorumOptions, QuorumResult, StageName, Thresholds, Usage, Verdict, VerdictStatus and Vote; ChatMessage, CompletionRequest, CompletionResponse, EventSink, JsonSchema, ProfilePorts, Provider and QuorumPorts; ConfigIssue and ProviderErrorKind; PolicyRules and Rule; AnthropicOptions, Auth, ClaudeCodeAuth, ClaudeCodeOptions, GeminiOptions, HttpProviderOptions, OllamaOptions, OpenAICompatibleOptions, StructuredOutput and SystemPrompt; Preset, PresetName and Protocol; ConfigInput, QuorumConfig and QuorumConfigFile; Env, ProfileSelection, ResolvedConfig, ResolvedProfile and ResolvedProvider. HttpProviderOptions holds the options an HTTP provider takes, OpenAICompatibleOptions adds maxTokensField, and AnthropicOptions, GeminiOptions and OllamaOptions are HttpProviderOptions as it is: Providers. ClaudeCodeOptions holds the options of claudeCode and ClaudeCodeAuth its three auth modes. Preset types a row of presets, PresetName names one, and Protocol names the four wire protocols.

The stages, the profile checks and the CLI's exit-code rule are not exported: a program reaches the stages through createQuorum, and applies the exit codes itself, as the example below does.

runQuorum ​

ts
declare function runQuorum(diff: string, onProgress?: (message: string) => void): Promise<QuorumResult>

Deprecated: use loadConfig and fromConfig. runQuorum reviews diff as the CLI does with no flag: it calls loadConfig() and fromConfig on the result, so it reads the file QUORUM_CONFIG names, if any, which must be an absolute path, and the QUORUM_* variables of process.env with their RESEARCH_* and VOTER_* aliases, and it runs the profiles they select, the security profile alone by default: Configuration. onProgress receives the CLI's progress lines, the flag: lines included, each one inert and each profile's line starting with [<id>] when several run, but neither the banner, nor a warning, nor empty diff: nothing to review, which the CLI writes itself. A configuration error, a missing model included, rejects with a ConfigError, for an empty diff too.

A complete example ​

review.ts, at the root of the clone: the finder on a local model, the panel on a hosted one, a line on stderr per verdict and per gap and the result on stdout, both inert, a ten-minute limit, and the CLI's exit codes.

ts
import { readFile } from 'node:fs/promises'
import { ConfigError, createQuorum, inertJson, inertLine, ollama, openaiCompatible, securityProfile } from './src/index.ts'
import type { QuorumEvent, QuorumResult } from './src/index.ts'

const USAGE = 'Usage: bun run ./review.ts <diff-file>'

function progress(event: QuorumEvent): void {
  if (event.type === 'verdict') console.error(inertLine(`${event.findingId}: ${event.keptVotes}/${event.totalVotes} ${event.status}`))
  if (event.type === 'incomplete') console.error(inertLine(`incomplete: ${event.entry.reason} (${event.entry.detail})`))
}

/** The `Error:` line of a failure, inert: its message can hold text a server sent, and a value with no text form gets a fixed one. */
function errorLine(error: unknown): string {
  let message: string
  try {
    message = error instanceof Error ? error.message : String(error)
  } catch {
    message = 'an error with no text form'
  }
  return inertLine(`Error: ${message}`)
}

/** The CLI's rule: 1 for a kept high finding of a gating profile, 4 for an incomplete review, else 0. */
function exitCode(result: QuorumResult): number {
  const gates = (profile: string): boolean => !Object.hasOwn(result.meta.profiles, profile) || result.meta.profiles[profile].gating !== false
  if (result.kept.some(verdict => verdict.confidence === 'high' && gates(verdict.profile))) return 1
  return result.unreviewed.length > 0 || result.meta.incomplete.length > 0 ? 4 : 0
}

/** Review the diff file at `path`, with the CLI's exit codes: 2 for usage or configuration, 3 for a review that failed. */
async function main(path: string | undefined): Promise<number> {
  let diff: string
  try {
    if (path === undefined) throw new Error('no diff file given')
    diff = await readFile(path, 'utf8')
  } catch (error) {
    console.error(errorLine(error))
    console.error(USAGE)
    return 2
  }
  try {
    const finder = ollama({ id: 'local', baseUrl: 'http://localhost:11434', model: 'qwen3.6:27b', contextTokens: 131072 })
    const panel = openaiCompatible({ id: 'hosted', baseUrl: 'https://api.openai.com/v1', model: 'gpt-6-astra', contextTokens: 400000, apiKey: process.env.OPENAI_API_KEY?.trim() || undefined, temperature: null, maxTokensField: 'max_completion_tokens' })
    const voters = Object.fromEntries(securityProfile.lenses.map(lens => [lens.id, panel]))
    const quorum = createQuorum({ profiles: [securityProfile] }, { profiles: { security: { research: finder, voters } }, events: progress })
    const result = await quorum.review({ diff }, { signal: AbortSignal.timeout(10 * 60_000) })
    console.log(inertJson(result))
    return exitCode(result)
  } catch (error) {
    console.error(errorLine(error))
    return error instanceof ConfigError ? 2 : 3
  }
}

process.exitCode = await main(process.argv[2])
bash
OPENAI_API_KEY=sk-... bun run ./review.ts ./my.diff > result.json

It exits as the CLI does: 0, 1 or 4 by the result; 2 when no file is given, the file cannot be read, or a setting is invalid, such as a key with a space inside it once trimmed; and 3 when the review fails, its ten-minute limit included.

  • Providers — the provider port, the shipped adapters and writing a provider.
  • Profiles — writing a profile, and running several in one review.
  • The JSON result — every field review resolves with.
  • Architecture — the modules behind this API, and the stages of a review.

Released under the MIT License.