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
voice:
profile: acme-docs
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) |
places | map | The project's own places, such as a city, a district or a sales territory, keyed by place id. A geo axis takes these in addition to the built-in places (see Places) |
axes | map | The kind of each declared axis, keyed by axis name: geo, range, ordered or a closed category (see Typed axes) |
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"). The long form takes version, framework_version and format_priority (see Format engines) |
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) |
flows_dir | string | A directory, relative to the recipe, holding one YAML file per flow (see Flow files). No default |
defaults | Defaults | Processing defaults (see Defaults; source/target languages live here) |
context | ContextBackend | Where the project's context is shared: local (the default), file, git or s3 (see Context backend) |
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) |
established_gate | Gate | A single catch-all approval gate: the bar a locale must clear to ship approved rather than translated (see Ship gates) |
established_gates | ShipGateRule[] | Scoped approval-gate rules, in the same when/gate form as ship_gates; the most specific rule wins. With neither key set, no locale ships approved |
Project identity
kapi init writes an id: into every recipe it scaffolds:
version: v1
id: prj_k3m7xq2vw9ta5r8ybn4dcp
name: My App
The id is randomly generated and remains stable when you rename or move the project. Local clones and worktrees with the same id share a context store. On another machine, import or restore the context to make it available there.
name: is the label you read in kapi status and in Kapi Desktop. Change it whenever you like.
Keep the id: line when committing or cloning the same project. When copying a recipe to create a different project, delete that line and run kapi init --mint-id. This assigns a new identity without changing the rest of the recipe.
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.
Releases without support for id: preserve it as an unknown top-level extension and continue to identify the project by name. Mixing versions with different identity handling can therefore produce different project keys.
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 |
redaction | RedactionSpec | The redaction policy for the files this collection holds, on a named collection or a bare entry. It replaces defaults.redaction for those files (see redaction) |
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) |
coordinates | map | The declared axes of the files this item matches, overlaid per axis on the collection's coordinates:. For example, one neighbourhood inside a campaign's city (see Declared axes) |
redaction | RedactionSpec | The redaction policy for the files this item matches. It replaces the collection's redaction: and defaults.redaction for those files (see redaction) |
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, with no 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:
profile: console-voice
sdk:
channels:
- id: docs
concept: term:9a1c0f42b7
- cli
voice:
profile: acme-docs
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 | The name of a terms store governing this product's content: a store kapi terms keeps, the name --termstore takes. A path or a file name (.db, .json) is a load error. Unset, the project's own terms govern |
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 name the project's voice store answers under when the profile binds no
voice: of its own. A profile that binds neither a voice nor a vocabulary is
still a profile, governed by the project defaults.
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 names a store on this machine, and
a connected run warns that the binding applies locally only.
Resolution order
Profile bindings take precedence over project defaults. Resolution for a collection's point, most specific first:
| Store | Order |
|---|---|
| voice | profiles.<name>.voice → the profile the store holds under <name> → defaults.voice |
| terms | --termstore → profiles.<name>.termstore → the project's own terms |
Resolution reads stored context by name. Use
kapi store import to load context
files from a checkout before they affect governance.
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. Word
rules are terms, which apply on every channel.
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. A content item can also set coordinates: for the files it
matches. 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/tutorials/**/*.mdx"
coordinates:
mode: tutorial # these files sit elsewhere on one axis
- 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 its values are free
slugs unless axes: types the axis. product and channel
are refused under coordinates: at load. Both are derived from channel:,
and a declared copy could contradict the derived value. A file's resolved
point merges four layers in order: the defaults, the axes channel: derives,
the collection's coordinates:, and the item's coordinates:. kapi context
reports this point, and a connected project sends it to the server.
Typed axes
The top-level axes: block sets how a declared axis compares its values. A
rule or a term scoped to one value then holds at every value inside it. A rule
kept for market: nordics holds for content at market: no, and one kept for
age: 13-17 holds at age: 14-16.
places:
nordics:
name: Nordics
of: [no, se, dk, fi, is]
axes:
market:
kind: geo
label: Market
audience:
values: [general, new-customers, specialists]
labels:
new-customers: New customers
age:
kind: range
unit: years
lifecycle:
kind: ordered
values: [preview, current, sunset]
defaults:
coordinates:
market: nordics
audience: general
| Field | Type | Description |
|---|---|---|
kind | string | category (the default), geo, range or ordered |
label | string | The axis's display name |
values | string[] | The values the axis takes, in display order. A category axis with values rejects any other value. An ordered axis requires values. A geo or range axis takes none |
labels | map | Display labels per value. A geo axis takes none, because each place declares its own name |
unit | string | A range axis only: what the numbers count, such as years |
An axis takes no other key. A misspelt key fails the load.
| Kind | Values | A value holds at |
|---|---|---|
category | any slug, or the declared values | the same value |
ordered | the declared values, in order | the same value |
geo | a place id: world, a UN M49 region (northern-europe, latin-america-and-the-caribbean), an ISO 3166-1 country in lower case (no, de), an ISO 3166-2 subdivision in lower case (no-03, us-ca), or a place declared under places: | itself and every place inside it |
range | an interval 13-17, an open upper bound 65-, or one number 18 | itself and every interval inside it |
Every value under defaults.coordinates, a collection's coordinates: and a
content item's coordinates: is checked against its axis at load. The load
fails on a value that a closed axis does not declare, a geo value that is not
a place id, or an inverted range. The error lists the valid spellings. An
axis that axes: does not list is an open category. product and channel
cannot be declared under axes:.
Places
A geo axis takes place ids. The built-in places are the world, the UN M49
regions, every ISO 3166-1 country and every ISO 3166-2 subdivision.
To look up a place id, run
kapi context places:
kapi context places oslo
oslo Oslo locality world › europe › northern-europe › no › no-03 › oslo
no-03 Oslo subdivision world › europe › northern-europe › no › no-03
greater-oslo Greater Oslo metro world › europe › northern-europe › no › greater-oslo
The top-level places: block adds the project's own places, keyed by id. Each
place sits within one or more places (within:) or is made of places
(of:). Containment is computed from these declarations only. A rule kept for
Norway applies to content at market: oslo, because Oslo is declared within
the county no-03, which sits within Norway.
places:
oslo:
name: Oslo
type: locality
within: no-03
centroid: [59.91, 10.75]
refs: [wikidata:Q585, geonames:3143244]
grunerlokka:
name: Grünerløkka
type: neighbourhood
within: oslo
shape: places/grunerlokka.geojson
greater-oslo:
name: Greater Oslo
names: { nb: Stor-Oslo }
type: metro
of: [no-03, no-32]
| Field | Type | Description |
|---|---|---|
name | string | The display name. Defaults to the id |
names | map | Display names keyed by locale (nb, en-GB); a lookup by name matches these too |
type | string | subdivision, territory, metro, locality, district, neighbourhood, postal-area or area. Defaults to territory for a place with of: and area for a place with within: |
within | string or string[] | The places this one sits inside. A place inside several places lists each of them |
of | string or string[] | The places this one is made of, such as the countries of a sales territory |
refs | string[] | The place's ids in other gazetteers, as scheme:value: wikidata:Q585, geonames:3143244, nuts:NO081, osm:relation/406091, wof:101752073 |
centroid | [lat, lon] | An approximate centre, where the Explore map in Kapi Desktop draws the place when it has no shape |
shape | string | A GeoJSON file holding the place's outline, relative to the recipe, ending .geojson or .json. The Explore map in Kapi Desktop draws it. Containment ignores it |
A place sets exactly one of within: and of:. A place made of places sits
within every place that contains all of its members. It contains every place
that sits within one of its members. Two places overlap when one contains the
other, when a member of one overlaps the other, or when a declared place sits
within both. A suggestion at greater-oslo and one at oslo therefore
overlap, and review shows them as a conflict when they disagree.
The recipe fails to load when a place is invalid, and the error names the key:
| Recipe | Error |
|---|---|
| an id that a built-in place uses | places.no-03: no-03 is a built-in place (Oslo, Norway); a declared place takes an id of its own |
both within: and of: | places.oslo: a place sits within places or is made of places, never both |
| neither | places.grunerlokka: a place names the places it sits within (`within:`) or is made of (`of:`) |
| an id that is not a place | places.oslo.within: "no-3" is not a place. Did you mean no-31 (Østfold, Norway), no-32 (Akershus, Norway), no-33 (Buskerud, Norway)? Look a place up with `kapi context places no-3` |
a cycle through within: | places.grunerlokka: the place sits within itself through grunerlokka › grunerlokka-north › grunerlokka |
| an unknown type | places.oslo.type: "city" is not a type a declared place takes. Use one of subdivision, territory, metro, locality, district, neighbourhood, postal-area, area |
| a malformed reference | places.oslo.refs: "wikidata:585" does not follow the wikidata scheme (write it as wikidata:Q585) |
| a centroid off the globe | places.oslo.centroid: [159.91, 10.75] is not on the globe: latitude runs from -90 to 90 and longitude from -180 to 180 |
| a shape outside the project | places.oslo.shape: "../oslo.geojson": the path leaves the project |
| a shape file that is missing | places.oslo.shape: places/oslo.geojson: no such file beside the recipe |
| an unknown key under a place | line 44: a place takes no key "parent" (it takes [name names type within of refs centroid shape]) |
If a geo value is not a place id, the recipe fails to load and the error
suggests the correct id: "Oslo" is not a place. Write it in lower case: oslo, "no03" is not a place. Write it with a hyphen: no-03. Otherwise the
error lists the closest matches by name, or the kinds of place id when nothing
matches, followed by the kapi context places command for the value.
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. A tag may carry a -u-sd- subdivision, such as nb-NO-u-sd-no03, to name the place the content is written for |
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 | Charset the files are read in and written back in (default: utf-8). A byte-order mark in a file wins over it; --encoding on the command line wins over the recipe. Binary formats (Office documents, images) are not affected |
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) |
engine | string | The format engine preferred for a file whose extension both a built-in format and a plugin's claim: native or a declared plugin's name (see Format engines) |
formats | map | Per-format preset, config, engine 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) |
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 advisory: true for a rule that reports without failing), and the terminology gate holds every target language to it (see Term rules) |
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). A term_rules: preset here governs that locale at the terminology gate |
flow | string | The project's default flow, the composed flow kapi up runs to catch up every locale. A key in flows:, a file in flows_dir:, or a built-in name (e.g. translate), resolved as kapi run resolves it. 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 collections are shippable, and keeps the drafts of a locale waiting for review in the project's workspace until then |
jobs | int | How many target languages one kapi up pass runs concurrently. 0 (default) leaves it to the runner (4); --jobs overrides per run |
translate_after | string | The source status a block must reach before the loop translates it (see translate_after) |
A collection and a content item can each declare a redaction: of their own (see redaction).
translate_after
defaults.translate_after is the source status a block must reach before the
loop translates it. The loop holds each block's fan-out until its source gets
there. It is a separate setting 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 source is translated into every locale once
it is ready rather than redone when it changes. See
Convergence: source first.
defaults:
translate_after: written # the default: written source whose checks pass
# translates (no human bottleneck)
# translate_after: established # wait until a person approves the source
# translate_after: none # opt out: raw fan-out, no hold
| Value | Meaning |
|---|---|
| (unset) | Runner applies the default (written) |
written | Default: written source translates once its checks pass |
established | A person approved the source (voice-critical or regulated content) |
none | Deliberate opt-out: translate without settling the source first |
On a connected project, a source below this level 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) |
engine | string | The engine that serves the extensions this format claims: native or a declared plugin's name. The entry's key names a format (json) or an extension (.json) |
priority | int | Detection priority among the formats of one engine; higher wins when several claim the same extension |
priority breaks a tie inside one engine. Most extensions map to one format, but
some are claimed by several formats of the same plugin: .srt by both okf_vtt
and okf_regex. Bumping the preferred one steers auto-detection without naming
a format on every item. engine moves an extension to another engine:
plugins:
okapi-bridge: "*"
defaults:
formats:
okf_vtt:
priority: 110 # .srt → WebVTT engine (beats okf_regex)
html:
engine: okapi-bridge # .html → the bridge's okf_html, not the built-in html
okf_json:
config:
useFullKeyPath: true
collections:
- name: Source Files
content:
- path: "input/*" # every file; engine auto-detected, two pinned above
target: "output/{lang}"
A priority never moves a file to another engine: the engine is chosen first (see Format engines), and the priorities rank that engine's formats.
Format engines
A format engine is a provider of formats: native (the formats kapi ships) or a
plugin, named as the recipe declares it (okapi-bridge). When a built-in format
and a plugin's format claim the same extension, the engine order decides which
reads the file. The order, first to last:
- The engine
defaults.formats.<name>.enginepins for the extension. - The engine
--enginenames on the run. - The engine
defaults.enginenames. - The engine
formats.enginenames in the user config. native.- The declared plugins, ranked by
plugins.<name>.format_priority(highest first) and then by name.
The first engine with a format for the extension wins; when several of its
formats claim the extension, the file's content and then priority decide
among them. Detection stays within the recipe's sources: the built-in formats
and the plugins under plugins:. A plugin installed on the machine but not
declared serves none of the project's files, and an engine named under
defaults.engine or defaults.formats.<name>.engine must be native or a
declared plugin.
plugins:
okapi-bridge:
format_priority: 200 # ahead of kapi-av where both claim an extension
kapi-av: "*"
defaults:
engine: okapi-bridge # the bridge reads every extension it claims
formats:
.json:
engine: native # except JSON, which the built-in format reads
A format named outright, with --format, --map or a content item's
format:, is read by that format whatever the engine order says, and its
writer comes from the same engine. To name the format of one engine for a
file several engines read, add the engine to the reference: html@okapi-bridge
is the bridge's HTML format and okf_html@native the built-in one. Under a
defaults.formats.<name>.engine pin, the bare name resolves to the pinned
engine's format. The version and preset parts keep their places:
name[@engine][@version][:preset].
kapi formats lists each format with its engine and marks an extension more
than one engine claims. kapi formats explain <file> says which format reads
a file, from which engine and by which rule, under the recipe in scope.
merge
| Field | Type | Description |
|---|---|---|
conflict_policy | string | What kapi merge does with a block whose translation changed in the project since the extraction: translator-wins (default) replaces it, existing-wins keeps it, newest-wins takes the returned file when it is newer than the translation's file |
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.
A policy can be declared in three places: defaults.redaction, redaction: on
a collection, and redaction: on a content item. Each file is redacted under
the most specific declaration that covers it: its item's, else its
collection's, else the defaults. That declaration replaces the others for the
file. Its fields are not merged with theirs, so a collection that names its own
rules file redacts with that file alone, and enabled: false on a collection
or an item turns redaction off for its files. A redaction: at any of the
three places that sets rules, detectors or placeholder must also set
enabled: true, and the recipe fails to load otherwise; write enabled: false
with no other key to switch redaction off.
| Field | Type | Description |
|---|---|---|
enabled | bool | Turn redaction on for the files the declaration covers |
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, by name. Exactly one source is
expected:
| Field | Type | Description |
|---|---|---|
profile | string | The id (or name) of a profile in the project's voice store, as kapi voice profiles lists it |
pack | string | Name of a built-in starter pack |
The short form is the id alone: voice: fernwell reads as
voice: {profile: fernwell}.
Context files
A recipe names no context file. A project's voice profiles, terms and content
memory live in its store, and the recipe binds a voice or a terms store by name.
Files carrying context (a voice.yaml, a terms.json, content-memory bundles)
are read into the store by
kapi store import, from the project's
.kapi/ directory or from a directory you name, such as a sample's context/.
An edit to one of those files takes effect when you import it again.
When the import brings a voice profile into a project whose recipe binds no voice
under defaults:, it adds the binding to kapi.yaml and says so:
Bound voice Fernwell in kapi.yaml (defaults.voice.profile: fernwell).
The two lines it adds are the only change to the file. When the import brings several profiles, it binds none and lists them with the line to add.
A voice profile under .kapi/profiles/<name>/voice.yaml belongs to the profile
of that name. A profile that binds no voice is governed by the stored profile
of its own name, so a file declaring id: <name> needs no binding. When the
file is stored under another id, the import binds it under
profiles.<name>.voice:
Bound voice Promo Voice for profile promo in kapi.yaml (profiles.promo.voice.profile: promo-voice).
A recipe that still names a file fails to load, and the message names the import
that reads the file and the binding to write instead. The retired keys are
defaults.terms_source, defaults.memory_source, voice: {profile_file: …}, a
voice: that is a path, and a termstore: that is a path or a file name.
A recipe that binds a voice the store does not hold, on a machine where the
project's context has not been imported or pulled, says which binding it is and
the command that brings it: kapi context sync when the project shares its
context, or kapi store import in a checkout carrying the profile. See
Moving context.
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)
The steps run in the order listed, each tool reading what the step before it
emitted. A step holding a parallel: list of tools is parsed but cannot run:
kapi run stops with an error before processing the flow. List those tools as
ordered steps instead.
A qa step selects its backend with mode: rules (the default) runs the
deterministic checks, and ai runs the AI review with the step's provider.
A qa step or preset that sets provider must also set mode, and the recipe
fails to load otherwise. A mode other than rules or ai also stops the
load. A qa config that holds checkTerminology also fails
to load: the rule checks read no terms, so remove the key and run term-check
to check translations against the terms store.
flows:
review:
steps:
- tool: qa
config:
mode: ai
provider: anthropic
Flow files
A project with many flows can keep each in its own YAML file, named for the flow, in a directory the recipe names with flows_dir:. The path is relative to the recipe, and a committed directory is the right place for it; kapi rejects a flows_dir under .kapi/, since that directory is a cache for one checkout.
# kapi.yaml
flows_dir: flows
# flows/guard.yaml, run with `kapi run guard`
description: Check the do-not-translate list
steps:
- tool: dnt-check
config:
terms: [Acme Cloud]
A flow file holds the same steps as an entry under flows:, plus an optional description:. Its steps name no input or output paths: kapi run takes the files from the recipe's collections, or from --input.
A flow name runs the most specific definition: the entry under flows:, then the file of that name, then the built-in flow of that name. So a recipe that declares translate runs its own with kapi run translate and as defaults.flow, and kapi flows lists it in place of the built-in. The kapi translate and kapi pseudo-translate commands always run the built-in flow.
kapi reads no flow files from .kapi/flows/. When it finds some there, it prints one warning naming them; move them to a committed directory and set flows_dir:.
Term rules
term_rules: in a step's config:, in a flow file or under defaults.tools gives the step the wording a translation must use. The terminology gate holds the translations to the same rules, beside the terms bound where the content sits, so kapi check --ship, kapi status and kapi up treat every language the rules name as governed. A rule on a flow step or under defaults.tools applies to every target language; a rule under defaults.locales.<lang>.tools applies to that language. A miss fails the gate unless the rule is marked advisory: true, which reports it. Where a recipe rule and a stored term name the same term, the recipe's rule applies.
flows:
converge:
steps:
- tool: translate
config:
term_rules: &term_rules
- term: billing period
replacement: période de facturation
- term: reading
replacement: relevé
advisory: true
- tool: term-check
config:
term_rules: *term_rules
The fields are in the voice profile reference.
Context backend
context: says where the project's context is shared, so every checkout of the
project, on every machine, shares the same place through
kapi context sync:
context:
backend: git # local (default), file, git or s3
ref: refs/kapi/context # git: a ref in this repository, outside every branch
| Field | Backend | Description |
|---|---|---|
backend | all | local keeps the context on each machine; file, git and s3 share it |
ref | git | The ref the context is kept on. Default refs/kapi/context; a branch or a tag is refused |
remote | git | The git remote fetched from and pushed to. Default origin |
path | file | The directory the context is kept in, such as a mounted team share. A relative path is read against the recipe's directory |
bucket | s3 | The bucket. Required |
prefix | s3 | A key prefix, so one bucket holds several projects |
region | s3 | The bucket's region, when the AWS configuration names another |
endpoint | s3 | The base URL of an S3-compatible service other than AWS |
A field that belongs to another backend is refused. Credentials never go here: a git backend uses the repository's own access, and an S3 backend the standard AWS environment, shared configuration and profile.
A hosted server the recipe connects to can also keep the context. Its
backend value takes no further field here; the server's documentation gives
the recipe shape.
Ship gates
Ship gates set when translated content is shippable: coverage thresholds
over the target lifecycle ladder (draft → translated → established,
where established is the rung kapi status shows as approved), enforced by
kapi check --ship and driven toward by kapi up. The model (ladders,
derived state, review) is described in the kapi loop, and
the schema follows. 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 ladder (written → established) |
established_gate | Gate | A single catch-all approval gate, in the same threshold form as ship_gate |
established_gates | ShipGateRule[] | Scoped approval-gate rules; the most specific rule wins |
A scope that ships has one of two ship states. It ships approved
when it clears its approval gate, and translated, as AI translation,
when it clears its ship gate but not an approval gate. kapi status and
kapi up print these words; kapi status --ship and the ship-aware language
picker spell the approved state established. With neither established_gate
nor established_gates set, no scope ships approved. A project that delivers
only approved content says so in its ship gate, e.g. ship_gate: { established: 100 }.
A locale 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 locale 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 blocks are at that state or higher
on the ladder. A threshold is a percent, 0–100; a threshold of 0 means "not
required". established: 80 reads at least 80% approved. Only a person
approves a block, so an established threshold always counts people.
Simple form, one bar for the whole project:
ship_gate: { translated: 100, established: 80 }
Every block 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
established: 100
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) block, 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, established: 100 }
- when: { collections: [Legal] } # regulated content, every locale
gate: { translated: 100, established: 100 }
- gate: { translated: 100, established: 80 } # the default
Named gates, defined once in the gates registry and referenced by name:
gates:
contractual: { translated: 100, established: 100 }
machine: { translated: 100, established: 100 }
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
(written → established), gating the author's own content, not the
translations.
source_gate: { written: 100 }
Every source block 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.
defaults.translate_after is a separate setting:
the level (written / established / none) a block's source must reach before
the loop translates it. 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.