Project file
A kapi project is a portable kapi.yaml recipe at a project's root that captures a content workflow: which files count as content, the voice profile, terms and coordinates that govern them, source and target languages, tool pipelines (flows), plugin requirements, and processing defaults. It carries no credentials or state, so it is always safe to commit. For when to use a project versus an ad-hoc run, see Projects.
Discovery
Like git, kapi walks up the directory tree from where you run it and uses the nearest kapi.yaml recipe, running in project mode with that project's languages, defaults, and flows. A directory holds at most one kapi.yaml, so discovery is unambiguous. An explicit -p <path> overrides discovery, and KAPI_NO_PROJECT=1 disables it so the CLI runs ad-hoc.
Format
version: v1
id: prj_k3m7xq2vw9ta5r8ybn4dcp
name: My App
collections:
- path: "src/locales/en/*.json"
target: "src/locales/{lang}" # mirror each matched file under the per-language dir
preset: nextjs
plugins:
okapi: "^1.47.0"
flows:
translate:
steps:
- tool: translate
config:
provider: anthropic
model: <provider-model-id>
translate-and-qa:
steps:
- tool: translate
config:
provider: anthropic
- tool: qa
pseudo:
steps:
- tool: pseudo-translate
config:
expansionPercent: 30
defaults:
source_language: en
target_languages:
- fr
- de
- ja
concurrency: 4
parallel_blocks: 3
encoding: utf-8
exclude:
- "**/*.generated.json"
merge:
conflict_policy: translator-wins
memory:
fuzzy_threshold: 75
segmentation:
source: true
terms_source: .kapi/terms.json
memory_source: .kapi/memory/memory.json
Fields
Required
| Field | Type | Description |
|---|---|---|
version | string | Schema version, must be "v1" |
name | string | Project display name |
Optional
| Field | Type | Description |
|---|---|---|
id | string | The project's stable identity (see Project identity) |
collections | Collection[] | The content this project governs (see Collections) |
profiles | map | Governance bound per product, keyed by profile name (see Profiles and channels) |
preset | string | A framework preset name (see kapi init --list-presets) (e.g., nextjs, react-intl) applied to formats |
plugins | map | Plugin requirements keyed by name (e.g., okapi: "^1.47.0") |
requires | map | Plugin name → semver constraint that gates loading; the recipe refuses to load without a registered plugin |
flows | map | Named flow definitions (see Flow Steps) |
defaults | Defaults | Processing defaults (see Defaults; source/target languages live here) |
ship_gate | Gate | A single catch-all ship gate (see Ship gates) |
ship_gates | ShipGateRule[] | Scoped ship-gate rules; the most specific matching rule wins (see Ship gates) |
gates | map | Named gate registry a ship_gates rule can reference by name (see Ship gates) |
source_gate | Gate | Coverage gate over the source authoring ladder (see source_gate) |
verified_gate | Gate | A single catch-all verified gate: the bar a locale must clear to read as verified, independent of shipping (see Ship gates) |
verified_gates | ShipGateRule[] | Scoped verified-gate rules, in the same when/gate form as ship_gates; the most specific rule wins. With neither key set, no locale is verified |
Project identity
kapi init writes an id: into every recipe it scaffolds:
version: v1
id: prj_k3m7xq2vw9ta5r8ybn4dcp
name: My App
The id is random, minted once, and derived from nothing else in the project. Everything kapi records about the project locally is keyed on it, so you can rename the project, move the folder, or clone the repository onto another machine and the recorded context still belongs to the same project. Two checkouts of one repository are one project because they share one recipe.
name: is the label you read in kapi status and in Kapi Desktop. Change it whenever you like.
Keep the id: line when you commit the recipe, and keep it when you copy a recipe as a starting point for a genuinely different project: delete the line there and run kapi init --mint-id in the new project, which writes a fresh id and leaves the rest of the file untouched.
A recipe with no id: keeps working. kapi identifies such a project by its name:, and kapi init --mint-id gives it an id whenever you want one.
Older kapi releases read a recipe carrying an id: without complaint: an unrecognized top-level key round-trips as an extension. Such a release still identifies the project by its name, so a project shared between two kapi versions is keyed differently by each until both carry this release.
Collections
collections is a list. Each entry is either a bare entry (one glob and its
target, written inline) or a named collection, which groups content items
under a name and can bind them to a point in the project's context space.
collections:
# Bare entry: a pattern and where it lands.
- path: "src/locales/en/*.json"
target: "src/locales/{lang}"
# Named collection: a name, the directory it lives in, its items.
- name: docs
channel: sdk/docs
base: web
content:
- path: "docs/**/*.mdx"
target: "i18n/{lang}/{path}.mdx"
| Field | Type | Description |
|---|---|---|
name | string | Collection name. Required to bind a channel:, and what a ship-gate rule's collections: selector matches |
base | string | The directory this collection lives in; every path, target and item base below is written relative to it |
channel | string | The point in the context space this collection's content sits at, always profile/channel (see Profiles and channels) |
coordinates | map | Declared axes (brand, mode) the collection sits at, overlaid per axis on defaults.coordinates; product and channel come from channel: and are rejected here (see Declared axes) |
content | ContentItem[] | The collection's content items |
source_language | string | Overrides defaults.source_language for this collection |
target_languages | string[] | Overrides defaults.target_languages for this collection |
source_only | bool | The collection has no target language: kapi up skips it and target-coverage gates exclude it. Rejected if the collection or any item also carries a target |
path | string | Bare entries only: the glob, written instead of content: |
format | string or map | Bare entries only |
target | string | Bare entries only |
A named collection needs at least one item under content: and cannot also
carry a path:; a bare entry needs a path:.
Set source_only: true on content kapi reads but never writes, such as package
descriptions and installer strings. The flag changes no behaviour on its own,
since a collection with no target was already source-only. It records the
intent, and loading fails if the collection contradicts it. See
Check-only content in other assets.
ContentItem
| Field | Type | Description |
|---|---|---|
path | string | Glob pattern for source files, relative to the collection's base (required) |
base | string | Directory a matched file's path is made relative to; defaults to the glob's fixed prefix |
format | string or map | Format ID; auto-detected per file if omitted |
target | string | Output path template (see Source and target paths) |
source_language | string | Overrides the collection's and the project's source language |
target_languages | string[] | Overrides the collection's and the project's target languages |
channel | string | Overrides the collection's channel: for the files this item matches (see Profiles and channels) |
redaction | RedactionSpec | Overrides defaults.redaction for this item |
comments | bool or map | Declares the comments in the matched files as content to check: Go source, read for its comments alone, and YAML, XML-based, HTML, Markdown, MDX, PO and properties formats, whose comments are checked beside the values their readers extract. The map form declares them too, and its directives list names markers your own tools read at the start of a comment line, which are set aside beside those under defaults.comments.directives. Its channel places the comments at a point of their own, a qualified profile/channel: each comment is checked under that point's voice and terms, and the rest of the file stays at the item's point. Its only: true declares the files for their comments alone: kapi checks the comments and reads none of the values, so kapi up, flow runs, kapi merge, kapi extract, kapi stats, kapi status coverage and the --ship gates leave the values alone, and the item cannot also set target, target_languages, redaction, format.config or format.preset. Go source is read for its comments alone with or without only. A format that supplies no comments makes the check report that it did not run (see Comments in source code) |
base
A collection's base is where the collection lives. Every path, target
and item base beneath it is written relative to that directory and joined onto
it, so a collection reads as the tree it governs rather than as a prefix repeated
on every line:
collections:
- name: docs
base: web
content:
- path: "docs/**/*.mdx" # → web/docs/**/*.mdx
target: "i18n/{lang}/{path}.mdx" # → web/i18n/{lang}/{path}.mdx
An item's own base is the separate, narrower thing: the directory a matched
file's path is made relative to, which drives the {relpath} / {path} /
{dir} tokens. An item that declares none keeps none, and the tokens then
relativize against the joined pattern's own fixed prefix.
Source and target paths
path is a doublestar glob: **
matches across directories (e.g. src/**/*.json) and {a,b,c} matches any of
the listed alternatives (e.g. input/store/*.{json,yaml,html}). One glob can
therefore cover a whole directory of mixed content; the format is auto-detected
per file unless you pin format. Patterns resolve relative to the project root.
** does not follow a symbolic link into another directory, so the linked
packages a package manager keeps under node_modules stay out of a match. A
link to a file that the pattern matches is read, and so is a linked directory
that the pattern names before its first wildcard.
A file belongs to exactly one item. Items are matched in the order they are
written, across all collections, and the first item whose path matches a file
claims it; later items never see that file, however specific their pattern. An
item declared comments: {only: true} claims only comments, wherever it is
written: the first other item that matches a file claims its values, and the
comments stay with the comments-only item unless that other item comes first
and declares them itself. To place one file at its own point, or under its own
format or target, list its item before the glob that would otherwise cover it:
collections:
- name: docs
channel: acme/docs
content:
- path: "docs/api-reference.md" # claimed here, at the reference point
channel: acme/reference
- path: "docs/**/*.md" # everything the item above did not claim
The base in play here is the item's own: the directory a matched file's path
is made relative to, after any collection base has been folded in. It defaults
to the glob's fixed prefix, the literal part of path before the first wildcard
(input/docs/ for input/docs/**/*.md). The relative path drives the {relpath}
/ {path} / {dir} tokens and how much of the source tree a directory-mirror
target reproduces.
target is the output-path template, expanded per source file and target
language. The supported tokens are:
| Token | Meaning | Example (base input/, source input/docs/api.md) |
|---|---|---|
{lang} | Target language | fr |
{relpath} | Source path relative to base, with extension | docs/api.md |
{path} | Source path relative to base, without extension | docs/api |
{dir} | Directory portion of {relpath} (empty at the root) | docs |
{filename} | Filename with extension | api.md |
{name} | Filename without extension (alias {basename}) | api |
{ext} | Extension without the dot | md |
A bare * is shorthand for {name}.
Directory-mirror (the easy default). When target ends with /, or its last
segment has no extension and no wildcard/token, it names a directory and the
source's {relpath} (under base) is mirrored beneath it. So target: output/{lang} reproduces the source tree under each per-language root
(input/docs/api.md → output/fr/docs/api.md) with no * and no risk of a
doubled extension:
collections:
- path: "input/**/*.md"
target: "output/{lang}"
Token template (the explicit form). When you need to reshape the layout, name the tokens:
collections:
- path: "src/locales/en/*.json"
target: "src/locales/{lang}/{name}.{ext}" # api.json → src/locales/fr/api.json
Profiles and channels
Content is written for a point in the context space. Two axes are structural:
the product it belongs to and the channel it ships on. A key under
profiles: is a product, the channels that profile lists are the channels that
product ships on, and a collection names the point its content sits at with one
channel: reference. Further axes are declared under coordinates: (see
Declared axes). Context is the concept; this
section is the schema.
profiles:
console:
channels: [app, docs]
voice: .kapi/profiles/console/voice.yaml
sdk:
channels:
- id: docs
concept: term:9a1c0f42b7
- cli
voice: .kapi/voice.yaml
valid_to: 2026-12-01
collections:
- name: console-app
channel: console/app
content:
- path: "console/i18n/**/*.kbf.json"
- name: sdk-docs
channel: sdk/docs
content:
- path: "web/docs/**/*.mdx"
- path: "web/docs/legal/**/*.mdx"
channel: sdk/cli
| Field | Type | Description |
|---|---|---|
channels | Channel[] | The surfaces this product ships on. A slug, or {id, concept} in long form |
voice | VoiceBinding | The voice profile governing this product's content, in the same forms as defaults.voice. Unset keeps defaults.voice |
termstore | string | A standalone terms store governing this product's content, resolved relative to the project root. Unset, the project's own store governs |
concept | string | The concept that names this product, for display. Carried and shape-checked, never resolved during matching |
valid_from | string | Date (YYYY-MM-DD) or RFC3339 instant this profile starts governing, inclusive. Unset is unbounded (see Profile validity) |
valid_to | string | Date or RFC3339 instant it stops, exclusive. Unset is unbounded |
The map key is the profile's name: the product-axis value its collections carry,
and the directory under .kapi/profiles/<name>/ holding the files it overrides.
A profile that binds neither a voice nor a vocabulary is still a profile: the
directory is the binding.
Profile names and channels are slugs: lowercase letters, digits and hyphens, starting with a letter or digit. They are machine identifiers (stable, never translated, comparable byte for byte), which is why a profile or a channel may carry a concept for display but resolution never looks at it.
A collection's channel: is always written profile/channel: the product owns
the surface, and the binding reads as one. A bare channel name is a load error
that spells out the qualified form(s), even when exactly one profile declares it.
Binding a channel requires a named collection. A
collection that binds none is governed by the project default point,
defaults.voice and the project's own terms.
A content item may carry its own channel:, in the same qualified form and under
the same load-time validation, for the files that item matches. It is the finest
point a recipe can name, and everything resolves to it: a flow run partitions
those files out of the rest of the collection and gives them their own voice and
terms, kapi check gates them against that vocabulary, and
kapi context search reports it as the
point each use sits at.
On a connected project, the resolved product and channel travel to the server as
the content's coordinates, so both venues resolve the same voice for the same
content. A profile's termstore: does not: it is a path into the local project, and
a connected run warns that the binding applies locally only.
Conventional files
A profile's own directory answers before the project default does, so overrides kept in the conventional place need no line in the recipe. Resolution for a collection's point, most specific first:
| Store | Order |
|---|---|
| voice | profiles.<name>.voice → .kapi/profiles/<name>/voice.yaml → defaults.voice → .kapi/voice.yaml → voice.yaml |
| terms | --termstore → profiles.<name>.termstore → .kapi/profiles/<name>/terms.json (when the file exists) → the project's own store |
The channel then refines the voice that resolved: a matching entry under the
voice profile's own channels: replaces its tone, its style, or both, and can
add vocabulary rules. The profile's own vocabulary applies on every channel, and
a channel's rules only add to it.
Declared axes
Beyond product and channel, a project names the further axes its content varies
along under defaults.coordinates. Every collection inherits them; a collection
that sits elsewhere on one axis sets that axis in its own coordinates: and
keeps the rest. Per axis, the most specific value wins.
defaults:
coordinates:
brand: acme
collections:
- name: sdk-docs
channel: sdk/docs
coordinates:
mode: reference
content:
- path: "web/docs/**/*.mdx"
| Axis | Conventional values | Meaning |
|---|---|---|
brand | a slug | The brand a point sits under, coarser than product |
mode | tutorial, how-to, reference, explanation | The kind of document at the point, in the Diátaxis sense |
The map is open: any other axis name is accepted, and the values are
conventions rather than an enumeration. product and channel are refused
under coordinates: at load, because both are derived from channel: and a
declared copy could contradict the derived one. A collection's resolved point
(the defaults, overlaid with what channel: derives, overlaid with its own
coordinates:) is what kapi context reports and what a connected project
sends to the server.
Profile validity
valid_from and valid_to bound a profile's governance in time, the same
half-open window a term carries, with valid_from inclusive and valid_to
exclusive, so a valid_to date excludes that whole day. A bound is a date
(YYYY-MM-DD) or an RFC3339 instant; an unparseable bound or an inverted range
fails at load.
A profile outside its window does not govern. Resolution falls through to the next binding (a collection's channel under an expired item channel, then the project default) exactly as if the expired profile were absent, and the run, check or push that applied the fall-through reports it once:
governance: profile "sdk" expired 2026-12-01; governing with the project default
The instant is the run's own wall clock; there is no flag to resolve for another
date. kapi context search reads every
bounded profile against the current time and labels it active, upcoming or
expired, so a window can be seen before it closes.
Defaults
defaults holds project-wide processing settings that individual content items can override. Several of these are governed by M-01.
| Field | Type | Description |
|---|---|---|
source_language | string | BCP-47 source locale (e.g., en) |
target_languages | string[] | BCP-47 target locales |
locale_format | string | Locale style for the {lang} placeholder: bcp-47 (default) or posix |
concurrency | int | Number of files to process in parallel |
parallel_blocks | int | Number of blocks to process in parallel within a file |
encoding | string | Input file encoding (default: utf-8) |
exclude | string[] | Glob patterns no collection claims: content scanning skips them, and a file you name that one matches sits at the project's default point (e.g. **/*.generated.json) |
formats | map | Per-format preset, config, and detection priority overrides (see formats) |
merge | MergeDefaults | How kapi merge resolves a translator's target against an existing target or content-memory entry (see merge) |
memory | MemoryDefaults | Content-memory pre-fill on kapi extract and write-back on kapi merge (see memory) |
segmentation | SegmentationDefaults | Opt-in sentence-level segmentation overlay applied on extract (see segmentation) |
redaction | RedactionSpec | Replace sensitive content with protected placeholders before processing and restore it afterwards (see redaction) |
annotations | AnnotationDefaults | write: lists the annotation types a writer draws into the document as inline marks, out of what the format declares it can carry. Empty draws every type the format supports; naming a type a format cannot carry asks for nothing there |
comments | map | directives: markers your own tools read at the start of a comment line, set aside wherever kapi reads the project's comments. channel: the point, a qualified profile/channel, at which every declared item's comments sit unless the item names its own (see Comments in source code) |
voice | VoiceBinding | Bind a voice profile as standing project context (see voice). A profile's own voice: overrides it for that product's content |
coordinates | map | The project's default point on the declared axes (brand, mode), inherited by every collection; product and channel are derived from channel: and rejected here (see Declared axes) |
terms_source | string | Committed, git-tracked native source bundle (a .terms.json) the project's terms are compiled from; kapi apply edits it and re-imports into the project store, so git diff is the review surface. Binds any path. Unset, kapi falls back to <root>/.kapi/terms.json (the conventional home in the committed context graph), then <root>/terms.json |
memory_source | string | Committed, git-tracked native source bundle (a .memory.json) the project content memory is compiled from, the content-memory analogue of terms_source. It has no conventional-location fallback: a project has one approved term list but many memory bundles (one per content surface), so a seed is always named |
tools | map | Project-level tool presets, keyed by tool name: config applied wherever that tool runs in a project flow. A step's own config overrides a preset per key. term_rules: is the key term-check, translate, recycle, dnt-check and pseudo-translate take here (one term, what to use instead, and a severity; see the voice profile reference) |
locales | map | Per-target-language overrides keyed by locale. Each entry's tools: presets merge over defaults.tools and under a step's own config whenever a flow runs for that locale, so one locale can enable redaction or a different model without forking the flow (see the kapi loop) |
flow | string | The project's default flow, the composed flow kapi up runs to catch up every locale. A built-in name (e.g. translate) or a key in flows:. Empty runs the built-in default (recycle → translate) |
materialize | string | Who delivers translated files. manual (default) leaves delivery to kapi merge or kapi up --materialize; on-converge makes kapi up write a locale's files to the collection's target: only once its gated scopes are shippable |
jobs | int | How many target languages one kapi up pass runs concurrently. 0 (default) leaves it to the runner (4); --jobs overrides per run |
source_gate | string | Source-first convergence level: the source status a block must reach before it is translated (see source_gate) |
Individual content items can override redaction per entry (ContentItem.redaction).
source_gate
defaults.source_gate is the source-first convergence gate: the source
status a block must reach before the loop translates it. It gates the fan-out,
and is distinct from the top-level source_gate coverage bar
that kapi check --ship evaluates. Source-first convergence settles the source
(terminology, voice, source checks) and holds the fan-out on it, so an unsettled,
non-compliant, un-term-checked source is never translated into every locale only
to be redone when it changes. See
Convergence: source first.
defaults:
source_gate: checked # the default: source cleared its automated terminology,
# voice, and source checks (no human bottleneck)
# source_gate: approved # require a human/agent source sign-off first
# source_gate: none # opt out: raw fan-out, no source gate
| Value | Meaning |
|---|---|
| (unset) | Runner applies the default (checked) |
authored | Presence baseline: any non-empty source qualifies |
checked | Default: source passed its automated terminology, voice and source checks |
approved | A human or agent signed off the source (voice-critical or regulated content) |
none | Deliberate opt-out: translate without settling the source first |
On a connected project, a below-gate source holds the convergence run
(source_not_ready) and opens a source-review task rather than translating; the
source-side coverage bar and this convergence level are independent controls.
formats
defaults.formats configures formats by name, so a single wildcard content item
(path: "input/*", no format:) can let each file's engine be auto-detected
while you still tune individual formats in one place. Each entry takes:
| Field | Type | Description |
|---|---|---|
preset | string | A named format preset applied to this format (see kapi formats) |
config | map | Reader option overrides for this format (merged under, and overridable by, a content item's format.config) |
priority | int | Detection priority; higher wins when several formats claim the same extension |
priority is the engine-pin escape hatch. Most extensions map to one format, but
some are claimed by several: .srt by both okf_vtt and okf_regex, .txt by
several plain-text engines. Bumping the preferred one steers auto-detection
without naming a format on every item:
plugins:
okapi-bridge: "*"
defaults:
formats:
okf_vtt:
priority: 110 # .srt → WebVTT engine (beats okf_regex)
okf_json:
config:
useFullKeyPath: true
collections:
- name: Source Files
content:
- path: "input/*" # every file; engine auto-detected, two pinned above
target: "output/{lang}"
Plugin formats already outrank built-in ones by default (priority 100 vs 50), so
declaring okapi-bridge is enough for input/* to resolve to the okf_*
engines; priority only matters to break ties between formats from the same
source.
merge
| Field | Type | Description |
|---|---|---|
conflict_policy | string | translator-wins (default), existing-wins, or newest-wins |
memory
| Field | Type | Description |
|---|---|---|
fuzzy_threshold | int | Minimum fuzzy match score (0–100) to pre-fill the target on extract (default: 75) |
segmentation
| Field | Type | Description |
|---|---|---|
source | bool | Toggle sentence-level segmentation of source text on extract |
srx | string | Optional SRX rules file; built-in default rules are used when empty |
redaction
The sensitive term list lives in a separate rules file referenced by rules (so it can be gitignored); this spec points at it and selects detection backends.
| Field | Type | Description |
|---|---|---|
enabled | bool | Turn redaction on for extract/merge |
rules | string | Path to a redaction rules YAML file |
detectors | string[] | Detection backends: rules and/or entities |
placeholder | string | Override the visible stand-in template, e.g. [REDACTED:{category}] |
voice
Bind a voice profile under defaults.voice. Exactly one source is expected. The
short form is the profile file itself, voice: .kapi/voice.yaml, which is what
a recipe writes when the profile is a file in the project, as it usually is.
| Field | Type | Description |
|---|---|---|
profile_file | string | Path to a standalone profile YAML, resolved relative to the project root |
profile | string | Name of a profile in the local voice store |
pack | string | Name of a built-in starter pack |
Flow Steps
Each flow contains an ordered list of steps. See Flow Steps Format for the full specification.
flows:
my-flow:
steps:
- tool: translate # tool name (required)
config: # tool-specific config (optional)
provider: anthropic
label: "Translate" # display label (optional)
Steps can also define parallel branches:
flows:
parallel-qa:
steps:
- parallel:
- tool: qa
- tool: qa
config:
provider: anthropic
Ship gates
Ship gates decide when translated content is shippable: coverage thresholds
over the target lifecycle ladder (draft → translated → reviewed →
signed-off), enforced by kapi check --ship and
driven toward by kapi up. The model (ladders,
derived state, review) is the kapi loop; this section is
the schema. All of these keys are top-level (siblings of defaults) and
optional: with none set, nothing is gated.
| Field | Type | Description |
|---|---|---|
ship_gate | Gate | A single catch-all gate: state name → threshold |
ship_gates | ShipGateRule[] | Scoped rules, each a selector plus a gate; when present it replaces ship_gate |
gates | map | Named gate registry; a ship_gates rule references an entry with gate: <name> |
source_gate | Gate | A gate over the source authoring ladder (authored → checked → approved) |
verified_gate | Gate | A single catch-all verified gate, in the same threshold form as ship_gate |
verified_gates | ShipGateRule[] | Scoped verified-gate rules; the most specific rule wins |
Shipping and being verified are independent readings. A verified gate names the
bar a locale must clear to be reported as verified (by kapi status --ship
and the ship-aware language picker), and a locale can ship without meeting it.
With neither verified key set, no locale is verified.
A scope no ship gate rule matches is not gated. kapi status and kapi up
name it not gated rather than shippable, and kapi status --ship records
"state": "not_gated" for its locale. Nothing holds a not-gated scope back on
coverage, so kapi up delivers it under materialize: on-converge and a
language picker offers it. Stale wording, a translation a reviewer turned down,
a failing check, or terms that were not checked withhold a scope whether or not
a gate matches it. Each scope reads as one of three
ship states:
| State | When |
|---|---|
shippable | A ship gate matches the scope and the scope clears it, with nothing withholding it |
withheld | The scope is short of a matching gate, or stale, rejected, failing or unchecked content holds it back |
not_gated | No ship gate matches the scope, and nothing withholds it |
Gate and thresholds
A gate maps a lifecycle state to a threshold. A scope clears the gate when, for every threshold, at least that percent of its units are at that state or higher on the ladder. A threshold is written in one of two forms:
| Form | Example | Meaning |
|---|---|---|
| Short | reviewed: 80 | At least 80% at reviewed or higher; approver class defaults to human |
| Extended | reviewed: {pct: 80, by: any} | pct (0–100) plus an explicit approver class by |
pct must be 0–100; a threshold of 0 means "not required". by selects the
approver class for the decision rungs (reviewed, signed-off): human
(the default) counts only decisions made by a person or an agent acting for
one, any also counts autonomous AI approvals. The short form always means
by: human; an AI-approved unit never satisfies it. See
approver classes for the rationale.
Simple form, one bar for the whole project:
ship_gate: { translated: 100, reviewed: 80 }
Every unit translated, the important 80% reviewed by a person; the long tail ships machine-translated.
Extended thresholds, the same shape with explicit pct/by:
ship_gate:
translated: 100
reviewed: { pct: 100, by: any }
Fully reviewed, but autonomous AI reviews count toward the bar.
ShipGateRule
ship_gates is an ordered rule list. Each rule has:
| Field | Type | Description |
|---|---|---|
when | Selector | Optional match: locales and/or collections (string lists). Omitted = catch-all default |
gate | Gate or string | An inline threshold map, or the name of an entry in the gates registry |
An empty selector axis matches everything, so a rule with no when: is the
default. When several rules match a (collection, locale) unit, the rule
constraining the most axes wins wholesale; among equal specificity, the
earliest-listed wins.
Scoped rules, where different scopes answer to different bars:
ship_gates:
- when: { locales: [de] } # contractually reviewed market
gate: { translated: 100, reviewed: 100 }
- when: { collections: [Legal] } # regulated content, every locale
gate: { translated: 100, signed-off: 100 }
- gate: { translated: 100, reviewed: 80 } # the default
Named gates, defined once in the gates registry and referenced by name:
gates:
contractual: { translated: 100, reviewed: { pct: 100, by: human } }
machine: { translated: 100, reviewed: { pct: 100, by: any } }
ship_gates:
- when: { locales: [de, ja] }
gate: contractual
- gate: machine
A tiered per-market strategy built from these pieces is worked through in Tier gates per market.
source_gate
The source-side coverage gate: a threshold map over the authoring ladder
(authored → checked → approved), gating the author's own content rather
than the translations.
source_gate: { checked: 100 }
Every source unit clears its bound voice and terminology checks before
kapi check --ship passes. Like ship_gate, the top-level source_gate is a
coverage bar evaluated by kapi check --ship; source drift never blocks an
ordinary build.
This is distinct from defaults.source_gate, the
source-first convergence level (checked / approved / none) that decides
whether the loop translates a block at all. The coverage bar answers is the
source shippable?; the convergence level answers may this source be translated
yet?.
Key Properties
- No credentials: API keys are never stored in a kapi project file. They come from the OS keychain (
kapi credentials add, shared with Kapi Desktop) or from environment variables. - No state: no sync cursors, caches, or timestamps. A kapi project file is always clean and safe to commit.
- Portable: one
kapi.yamlper project folder; share via git. - CLI-compatible:
kapi run flowname -p kapi.yaml
Using with Kapi CLI
# Run a flow from a Kapi project
kapi run translate -p kapi.yaml
# Override defaults with CLI flags
kapi run translate -p kapi.yaml --target-lang de --provider openai
# One-shot mode still works without a project
kapi translate -i file.json --target-lang fr
Using with Kapi Desktop
- File > Open loads a project by its folder (the one containing
kapi.yaml) - File > New Project creates one from scratch: a name, a location and a source language
- File > Recent Projects reopens one you have worked in before
The app opens to your projects and edits the recipe in place: content and governance under Project Settings, the stores under Context. See the desktop overview.
See also
- C-01: Project model: the architectural decision behind the
kapi.yamlrecipe and its extension mechanism. - kapi.yaml Project File Format: the implementation note; the
KapiProject,Defaults,CollectionandProfilestruct layouts, the named-collection content shape, validation rules, and how platform extensions decode unknown keys. - Flow Steps Format: the full step specification used by
flows.