Configuration

Reference for pair-cli's config.json, registry definitions, behavior modes, and target configuration.

pair-cli uses a config.json file to define how Knowledge Base content is distributed to your project. This file controls which registries exist, where they install, and how files are synchronized.

File Location

The config file is located at the root of the KB dataset:

packages/knowledge-hub/dataset/config.json    # In monorepo (source)
~/.pair/kb/{version}/config.json              # In cached download

Override with --config <path> on any command.

Resolution Order

More than one configuration can be in play at once: the CLI's own defaults, the KB you are installing, and your project. They are merged per registry, weakest first:

LayerSourceWins over
1. CLI defaultsconfig.json shipped with pair-clin/a
2. Source KB declarationpair.config.json at the root of the KB named by install --sourceCLI defaults
3. Project configurationpair.config.json in the project being installed intothe source KB
4. Explicit --config <path>the file you nameeverything

The source declares, the consumer overrides. An external KB that declares skills.prefix: "acme-kb" installs its skills under that prefix with nothing to copy into your project, and if your project declares a skills.prefix of its own, yours wins. Only the fields a layer actually states are overridden; the rest fall through.

Layer 3 is your project's pair.config.json only. A config.json in your project root is not read as a Pair configuration: the name is too common to claim.

What layer 2 may say

The source KB is the one layer whose content you do not control: it is shipped by whoever publishes the KB. So it may describe its own content and namespacing, and nothing else. Exactly these registry fields are honoured:

FieldWhat a source KB may stateChecked as
sourcea KB-relative path that stays inside the KBabsolute, ..-escaping, Windows-drive and control-character values are dropped, and so is a path that only resolves outside the KB, through a symlink
excludewhich of its own files are narrowed outarray of strings
flattenwhether its own directories collapseboolean
flattenDepthhow far they collapsepositive integer
descriptionfree textnon-empty string, no control character
prefixa single path segmentone containing /, \, .., or a control character is dropped

include is not honoured, even though it looks like a content field: for a mirror registry it controls which target folders are cleaned up when absent from the source (a WRITE decision, not a content one) so a declared include is dropped like targets.

Everything else in a source pair.config.json is ignored, not merged, targets, target_path, behavior, working_path and any top-level key. A KB can never decide where the install writes; that stays with the CLI defaults, your project, and --config.

Nor can it decide where the install reads. The bound is physical, not just on the name: each half is checked after every symlink on the path is followed:

  • a declared source must resolve inside the KB;
  • every symlink met while copying must resolve inside that registry's own source directory: the narrower bound. One resolving anywhere else is skipped, with a warning naming it. One resolving back inside it is followed only when it points at a file: a symlink to a directory is skipped too (also at a warning), because following one would need a cycle guard, self -> . is contained as well. Ship a directory of content as a real directory, not as a link to one.

So a KB cannot make pair-cli install copy files from elsewhere on your machine into your repository, and a registry ships only what physically lives under it. A link like .pair/knowledge/shared -> ../../shared-guidelines is not installed even though its target is inside the KB: the target is outside the knowledge registry's source. Put shared content under the registry's own source directory instead.

A field failing its check is dropped on its own: the layer beneath supplies the value and the rest of the declaration still applies. If what remains is well-formed but still does not add up (flatten: false over a registry that sets flattenDepth, say), the whole declaration is dropped with a warning and your own resolution stands: a source's config never aborts your install, and is never half-applied.

Four further guards apply to layer 2, and only to it:

  • A malformed source pair.config.json is ignored with a warning printed to the console (never partially applied) and installation continues on your project's resolution. "Malformed" also covers valid JSON that is not an object, or whose asset_registries is not an object.
  • A declaration whose fields are all dropped does not report itself as applied: the Configuration: … line naming the KB is printed only when at least one declared field actually survived, and a KB that declared something and contributed nothing gets its own warning.
  • A registry the source declares that this CLI has no definition for (a newer KB read by an older CLI) is not installed: it is reported as skipped, with the reason declared by source, unknown to this CLI.
  • Layer 2 never introduces a registry. To adopt one the CLI does not know, declare it in full in your own pair.config.json (source, behavior, description, targets, …): a partial entry relying on the source for the rest fails validation, because the source's version of that registry was never merged beneath yours.

Layer 2 applies only to a named source (--source, --git, --url), and it applies to pair-cli install and pair-cli update alike: an update that did not read the declaration would re-install the same content under the CLI's default names, leaving both copies behind. The default KB path resolves exactly as before.

pair-cli package ships the KB's pair.config.json in the release ZIP, so a KB installed from its published artifact declares exactly what the same KB installed from a directory or a git clone declares.

Structure

{
  "registryName": {
    "source": ".source-dir",
    "behavior": "mirror",
    "description": "Human-readable description",
    "include": ["/agents"],
    "flatten": false,
    "prefix": "",
    "targets": [
      { "path": ".target-dir/", "mode": "canonical" }
    ]
  }
}

Registry Fields

FieldTypeRequiredDescription
sourcestringYesSource directory within the KB dataset
behaviorstringYesSync behavior: mirror, add, overwrite, skip
descriptionstringYesHuman-readable description shown by --list-targets
includestring[]NoSource-relative folder entries to mirror, matched by path segment, not globs (default: the whole source)
excludestring[]NoSource-relative entries never installed: the whole subtree is skipped
flattenbooleanNoFlatten directory hierarchy into dash-separated names
flattenDepthnumberNoBound flattening to the registry's entry depth: the first N source segments are joined, anything deeper is preserved as a real sub-path. Requires flatten: true; must be a positive integer. Omitted = every separator is flattened
prefixstringNoPrefix to prepend to flattened directory names
targetsTarget[]NoMulti-target distribution configuration

exclude matches path segments, not string prefixes: "process/setup" skips process/setup and everything under it, and leaves a sibling process/setup-helper alone. It means never install, not uninstall, behavior: overwrite performs no cleanup, so an entry already present in the target from an earlier run stays there when it later becomes excluded.

Remove it by hand, or use behavior: mirror for a registry whose target should track the source exactly.

Behavior Modes

ModeDescriptionUse Case
mirrorOverwrite all files, delete removed filesKnowledge layer (upstream content)
addAdd new files only, never overwrite existingAdoption layer (user decisions)
overwriteOverwrite existing files, add new ones, don't deletePartial updates
skipDo nothing if target existsOne-time initialization

Behavior Summary

  • mirror: Target becomes an exact copy of source. Missing files are deleted. Use for content that should always match upstream.
  • add: Only creates files that don't exist yet. Never modifies or deletes. Use for user-owned content like adoption files.
  • overwrite: Updates existing files and adds new ones, but doesn't delete. Use for incremental updates.
  • skip: Completely skips the registry if target directory exists. Use for one-time setup.

Target Configuration

Each registry can distribute to multiple targets:

{
  "targets": [
    { "path": ".claude/skills/", "mode": "canonical" },
    { "path": ".github/skills/", "mode": "symlink" },
    { "path": ".cursor/skills/", "mode": "symlink" }
  ]
}

Target Modes

ModeDescription
canonicalPhysical copy of files (primary target)
symlinkRelative symlink to canonical target
copyIndependent copy (used when symlinks aren't supported)

Notes:

  • Exactly one target must be canonical
  • symlink targets create relative symlinks to the canonical copy
  • Windows falls back to copy mode (symlinks not reliably supported)

Transform Configuration

When flatten: true and prefix is set, directory names are transformed:

Source:  .skills/capability/verify-quality/SKILL.md
Flatten: capability/verify-quality → capability-verify-quality
Prefix:  capability-verify-quality → pair-capability-verify-quality
Target:  .claude/skills/pair-capability-verify-quality/SKILL.md

The transform also updates:

  • YAML frontmatter values (e.g., name: verify-quality → name: pair-capability-verify-quality)
  • Markdown links between transformed files
  • Skill cross-references (e.g., /verify-quality → /pair-capability-verify-quality)

Bounded Flattening (flattenDepth)

Without flattenDepth, every separator is flattened, so a sub-directory of an entry becomes a sibling entry of its own. Set flattenDepth to the number of source segments an entry occupies and only those are joined; anything deeper stays a real sub-path inside the entry:

Source:  .skills/process/review/references/deep.md
Flatten: process/review/references → process-review/references   # flattenDepth: 2
Prefix:  process-review/references → pair-process-review/references
Target:  .claude/skills/pair-process-review/references/deep.md

Compare with the unbounded result for the same source: .claude/skills/pair-process-review-references/deep.md: a sibling directory outside the skill, with the skill's own ./references/deep.md link pointing at nothing.

Notes:

  • Set it when your registry's entries own sub-directories (e.g. an Agent Skills references/ progressive-disclosure directory).
  • A directory deeper than flattenDepth is treated as content, not an entry: it gets no prefix, no frontmatter name sync, and no skill-reference mapping.
  • Entries normally all sit at that depth, and a mixed-depth corpus is still workable: a shallower entry is fine while it owns no sub-directory (pair's own root-level next skill installs beside 39 two-segment ones), and aborts the copy only once it does. So does an entry deeper than the depth that nothing owns as content. Either way the copy fails with an explicit error before writing anything, instead of installing something broken.

Example: Skills Registry

{
  "skills": {
    "source": ".skills",
    "behavior": "overwrite",
    "flatten": true,
    "flattenDepth": 2,
    "prefix": "pair",
    "description": "Agent skills distributed to AI tool directories",
    "targets": [
      { "path": ".claude/skills/", "mode": "canonical" },
      { "path": ".github/skills/", "mode": "symlink" },
      { "path": ".cursor/skills/", "mode": "symlink" },
      { "path": ".agent/skills/", "mode": "symlink" },
      { "path": ".agents/skills/", "mode": "symlink" },
      { "path": ".windsurf/skills/", "mode": "symlink" }
    ]
  }
}

Working Area Override

.pair/working/ (checkpoints, reports) is operational data, not knowledge. It is protected simply by never being a registry target: pair-cli install/pair-cli update only touch paths inside a configured registry, so a directory that is not a registry source or target is never created, modified, or deleted (D14). There is no runtime carve-out, instead, pair-cli validate-config rejects any config whose registry target would overlap the working area. See the KB Structure Reference for the full convention.

Override the default path with a top-level working_path field, a sibling of asset_registries, not nested inside it. The value must be a project-relative path (relative to the project root, like asset_registries targets); an absolute working_path is rejected by pair-cli validate-config:

{
  "working_path": ".pair/scratch",
  "asset_registries": { "...": "..." }
}

When set, the override path (not .pair/working) is the reserved path that no registry target may overlap, checked by pair-cli validate-config.

A top-level link_validation field, a sibling of asset_registries, tunes how pair-cli kb-validate treats relative markdown links whose target is missing:

{
  "link_validation": {
    "optional_link_patterns": ["apps/**", "packages/**"]
  }
}

optional_link_patterns marks matched-but-missing targets as warnings instead of errors, so a KB can be validated in isolation without disabling whole registries. Patterns merge (union, trimmed and deduplicated) with --optional-link-patterns, and --strict overrides them back to errors.

A pattern is matched against the target resolved relative to the KB root, and, only where the link as written is a genuine spelling of that same target (its ../ climb lands on the KB root) or the target leaves the KB tree, against the link as written; only the documented glob syntax is supported, anything else (brace expansion, extglob, leading !) is matched literally and therefore never matches.

Write patterns in the resolved form, as above: it is the same string whatever the depth of the file the link is written in. A target that is a true sibling of the KB root resolves one level up (../apps/**), and **/apps/** covers both anchorings. The written form (../../apps/**) matches only links whose ../ climb lands exactly on the KB root, so it stops matching the same target as soon as it is linked from a deeper file.

The section is read from the config of the KB being validated (<--path>/pair.config.json, falling back to <--path>/config.json when there is no pair.config.json, the same config the registries are resolved from) so validating pair-cli kb-validate --path ./my-kb uses ./my-kb/pair.config.json (or ./my-kb/config.json), not the config of the directory you run the command in.

Full semantics and glob syntax: kb-validate: Optional link patterns.

Execution Engine (engine)

A top-level engine field, a sibling of asset_registries, names the agent process pair-cli run drives:

{
  "engine": { "id": "pi" },
  "asset_registries": { "...": "..." }
}

id names the engine: pi, opencode, claude or codex. Two optional maps, keyed by engine id, sit beside it:

  • bin: where THIS machine keeps an engine's executable, when PATH cannot find it ({"bin": {"pi": "/opt/pi/bin/pi"}}). Consulted first when the engine is resolved.
  • model: the model an engine drives, run-wide ({"model": {"codex": "gpt-5.5"}}). Per-stage selection is the workflow profile's.
{ "engine": { "id": "codex", "bin": { "codex": "/usr/local/bin/codex" }, "model": { "codex": "gpt-5.5" } } }

Anything else (an unknown id, a non-object block, an unknown field, a bin/model key that is not an engine id, an empty value) is a validation error, never a silent fall-back to the default: a typo that quietly ran a different engine would be indistinguishable from a working configuration.

The block is optional and delta-only. Precedence, strongest first:

LevelWins overNotes
--engine <id>everythingPer-invocation choice
engine.id in pair.config.jsonthe schema defaultCommitted project preference
Schema default (claude)n/aA repository with no pair.config.json runs from this

pair-cli run prints the resolved engine, the exact command line and the level it came from before it executes anything.

Autonomy and project-trust approval are deliberately not configurable here. They are per-invocation flags only (--autonomous, --approve-project-trust): a committed file must never be able to grant an unattended run permission to skip confirmations or to execute code from a repository the engine does not trust.

Workflow Profiles (workflowProfiles)

An optional top-level workflowProfiles field declares which engine, model, effort level and context policy each delivery-cycle stage (prepare, validate, implement, green, verify, contract, merge) uses, for both pair-cli run --card and the in-session pair-workflow-cycle skill, through one shared resolver.

{
  "workflowProfiles": {
    "default": "cheap-green",
    "files": ".pair/adoption/tech/workflow-profiles/*.json",
    "inline": { "quick": { "name": "quick", "defaults": { "effort": "low" } } }
  }
}
  • default: the profile used when no --profile / --workflow-config is passed.
  • files: a glob or array of globs; each file is one profile, indexed by its own name.
  • inline: profiles declared directly, keyed by name. Wins over a file of the same name.

A profile is { name, description, defaults, stages, modelClasses } (name required; an inline profile takes its key as name). A stage entry overrides only the fields it declares, falling back to defaults, then to the KB default (the run's engine, its default model, default effort, fresh context).

FieldValues
enginedefault (the run's engine), pi, opencode, claude, codex. Spawned per stage by pair-cli; in a session only reported
modeldefault (the engine's own), an explicit id, a class (cheap, balanced, frontier) mapped to an id by modelClasses, or by-tier (the card's risk:* label through ## Model Policy). A near-miss of a keyword is refused as a typo
effortdefault, low, medium, high, xhigh, max. In a session enforced on Codex, a prompt request on Claude; pair-cli sends it to claude (--effort) and codex (-c model_reasoning_effort; xhigh/max map to high); for pi and opencode it warns once and marks the stage ignored
contextfresh, reuse. reuse only into prepare and green

Resolution is once per run, in this order: --workflow-config (verbatim), then --profile, then workflowProfiles.default, then the KB default. Both flags work with --card, --root or --parallel (forwarded to every child card). The coordinator prints the resolved profile, its source and the per-stage table before the first dispatch; node .claude/skills/pair-workflow-cycle/scripts/workflow-profile.mjs resolve --root . [--profile <name>] [--tier risk:<tier>] prints the same table (table in its JSON) without running anything.

An unknown name is profile-unresolved; context: "reuse" on a stage the cycle's transition table forbids (validate, verify, …), an unknown stage or key, or a name collision is refused at load, never a silent fallback. The profile's name and hash are recorded in every handoff but are not part of what invalidates prior evidence.

No workflowProfiles field and no flag: nothing changes.

Worked example and recipe: Automated development, Autonomy and per-stage engine and model. Full reference: the KB's workflow-profiles.md (automation guidelines).

Validation

Validate your config with:

pair-cli validate-config
pair-cli validate-config --config ./custom-config.json

What Gets Validated

  • Required fields present (behavior, source, description)
  • Behavior values are valid (mirror, add, overwrite, skip)
  • Target paths are accessible
  • Transform config is valid (non-empty prefix, not combined with symlink mode; flattenDepth a positive integer, and only alongside flatten: true)
  • Source paths exist (if specified)
  • Include patterns are valid arrays
  • No registry's target overlaps a reserved path, the working area (default or overridden working_path), and working_path is project-relative; see Working Area Override

Environment Variables

VariableDescriptionDefault
PAIR_KB_CACHE_DIROverride the KB cache root; slots are created under it. Must be an ABSOLUTE path with no .. segments: the CLI rejects anything else, because slots are deleted recursively~/.pair/kb
PAIR_KB_DEFAULT_URLOverride default GitHub release URLGitHub latest release
PAIR_GIT_TOKENSets an origin-scoped http.extraheader (git >= 2.31) for https:// git clones of private repos, never http://, never in the URL. Also refuses any redirect while attached.Not set
PAIR_DIAGEnable diagnostic logging (1 to enable)0