Gå til hovedinnhold

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​

FieldTypeDescription
versionstringSchema version, must be "v1"
namestringProject display name

Optional​

FieldTypeDescription
idstringThe project's stable identity (see Project identity)
collectionsCollection[]The content this project governs (see Collections)
profilesmapGovernance bound per product, keyed by profile name (see Profiles and channels)
placesmapThe 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)
axesmapThe kind of each declared axis, keyed by axis name: geo, range, ordered or a closed category (see Typed axes)
presetstringA framework preset name (see kapi init --list-presets) (e.g., nextjs, react-intl) applied to formats
pluginsmapPlugin requirements keyed by name (e.g., okapi: "^1.47.0"). The long form takes version, framework_version and format_priority (see Format engines)
requiresmapPlugin name → semver constraint that gates loading; the recipe refuses to load without a registered plugin
flowsmapNamed flow definitions (see Flow Steps)
flows_dirstringA directory, relative to the recipe, holding one YAML file per flow (see Flow files). No default
defaultsDefaultsProcessing defaults (see Defaults; source/target languages live here)
contextContextBackendWhere the project's context is shared: local (the default), file, git or s3 (see Context backend)
ship_gateGateA single catch-all ship gate (see Ship gates)
ship_gatesShipGateRule[]Scoped ship-gate rules; the most specific matching rule wins (see Ship gates)
gatesmapNamed gate registry a ship_gates rule can reference by name (see Ship gates)
source_gateGateCoverage gate over the source authoring ladder (see source_gate)
established_gateGateA single catch-all approval gate: the bar a locale must clear to ship approved rather than translated (see Ship gates)
established_gatesShipGateRule[]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"
FieldTypeDescription
namestringCollection name. Required to bind a channel:, and what a ship-gate rule's collections: selector matches
basestringThe directory this collection lives in; every path, target and item base below is written relative to it
channelstringThe point in the context space this collection's content sits at, always profile/channel (see Profiles and channels)
coordinatesmapDeclared 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)
contentContentItem[]The collection's content items
source_languagestringOverrides defaults.source_language for this collection
target_languagesstring[]Overrides defaults.target_languages for this collection
source_onlyboolThe 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
redactionRedactionSpecThe 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)
pathstringBare entries only: the glob, written instead of content:
formatstring or mapBare entries only
targetstringBare 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​

FieldTypeDescription
pathstringGlob pattern for source files, relative to the collection's base (required)
basestringDirectory a matched file's path is made relative to; defaults to the glob's fixed prefix
formatstring or mapFormat ID; auto-detected per file if omitted
targetstringOutput path template (see Source and target paths)
source_languagestringOverrides the collection's and the project's source language
target_languagesstring[]Overrides the collection's and the project's target languages
channelstringOverrides the collection's channel: for the files this item matches (see Profiles and channels)
coordinatesmapThe 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)
redactionRedactionSpecThe redaction policy for the files this item matches. It replaces the collection's redaction: and defaults.redaction for those files (see redaction)
commentsbool or mapDeclares 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:

TokenMeaningExample (base input/, source input/docs/api.md)
{lang}Target languagefr
{relpath}Source path relative to base, with extensiondocs/api.md
{path}Source path relative to base, without extensiondocs/api
{dir}Directory portion of {relpath} (empty at the root)docs
{filename}Filename with extensionapi.md
{name}Filename without extension (alias {basename})api
{ext}Extension without the dotmd

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
FieldTypeDescription
channelsChannel[]The surfaces this product ships on. A slug, or {id, concept} in long form
voiceVoiceBindingThe voice profile governing this product's content, in the same forms as defaults.voice. Unset keeps defaults.voice
termstorestringThe 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
conceptstringThe concept that names this product, for display. Carried and shape-checked, never resolved during matching
valid_fromstringDate (YYYY-MM-DD) or RFC3339 instant this profile starts governing, inclusive. Unset is unbounded (see Profile validity)
valid_tostringDate 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:

StoreOrder
voiceprofiles.<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"
AxisConventional valuesMeaning
branda slugThe brand a point sits under, coarser than product
modetutorial, how-to, reference, explanationThe 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
FieldTypeDescription
kindstringcategory (the default), geo, range or ordered
labelstringThe axis's display name
valuesstring[]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
labelsmapDisplay labels per value. A geo axis takes none, because each place declares its own name
unitstringA range axis only: what the numbers count, such as years

An axis takes no other key. A misspelt key fails the load.

KindValuesA value holds at
categoryany slug, or the declared valuesthe same value
orderedthe declared values, in orderthe same value
geoa 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
rangean interval 13-17, an open upper bound 65-, or one number 18itself 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]
FieldTypeDescription
namestringThe display name. Defaults to the id
namesmapDisplay names keyed by locale (nb, en-GB); a lookup by name matches these too
typestringsubdivision, territory, metro, locality, district, neighbourhood, postal-area or area. Defaults to territory for a place with of: and area for a place with within:
withinstring or string[]The places this one sits inside. A place inside several places lists each of them
ofstring or string[]The places this one is made of, such as the countries of a sales territory
refsstring[]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
shapestringA 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:

RecipeError
an id that a built-in place usesplaces.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
neitherplaces.grunerlokka: a place names the places it sits within (`within:`) or is made of (`of:`)
an id that is not a placeplaces.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 typeplaces.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 referenceplaces.oslo.refs: "wikidata:585" does not follow the wikidata scheme (write it as wikidata:Q585)
a centroid off the globeplaces.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 projectplaces.oslo.shape: "../oslo.geojson": the path leaves the project
a shape file that is missingplaces.oslo.shape: places/oslo.geojson: no such file beside the recipe
an unknown key under a placeline 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.

FieldTypeDescription
source_languagestringBCP-47 source locale (e.g., en)
target_languagesstring[]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_formatstringLocale style for the {lang} placeholder: bcp-47 (default) or posix
concurrencyintNumber of files to process in parallel
parallel_blocksintNumber of blocks to process in parallel within a file
encodingstringCharset 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
excludestring[]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)
enginestringThe 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)
formatsmapPer-format preset, config, engine and detection priority overrides (see formats)
mergeMergeDefaultsHow kapi merge resolves a translator's target against an existing target or content-memory entry (see merge)
memoryMemoryDefaultsContent-memory pre-fill on kapi extract and write-back on kapi merge (see memory)
segmentationSegmentationDefaultsOpt-in sentence-level segmentation overlay applied on extract (see segmentation)
redactionRedactionSpecReplace sensitive content with protected placeholders before processing and restore it afterwards (see redaction)
annotationsAnnotationDefaultswrite: 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
commentsmapdirectives: 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)
voiceVoiceBindingBind a voice profile as standing project context (see voice). A profile's own voice: overrides it for that product's content
coordinatesmapThe 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)
toolsmapProject-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)
localesmapPer-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
flowstringThe 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)
materializestringWho 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
jobsintHow many target languages one kapi up pass runs concurrently. 0 (default) leaves it to the runner (4); --jobs overrides per run
translate_afterstringThe 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
ValueMeaning
(unset)Runner applies the default (written)
writtenDefault: written source translates once its checks pass
establishedA person approved the source (voice-critical or regulated content)
noneDeliberate 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:

FieldTypeDescription
presetstringA named format preset applied to this format (see kapi formats)
configmapReader option overrides for this format (merged under, and overridable by, a content item's format.config)
enginestringThe 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)
priorityintDetection 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:

  1. The engine defaults.formats.<name>.engine pins for the extension.
  2. The engine --engine names on the run.
  3. The engine defaults.engine names.
  4. The engine formats.engine names in the user config.
  5. native.
  6. 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​

FieldTypeDescription
conflict_policystringWhat 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​

FieldTypeDescription
fuzzy_thresholdintMinimum fuzzy match score (0–100) to pre-fill the target on extract (default: 75)

segmentation​

FieldTypeDescription
sourceboolToggle sentence-level segmentation of source text on extract
srxstringOptional 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.

FieldTypeDescription
enabledboolTurn redaction on for the files the declaration covers
rulesstringPath to a redaction rules YAML file
detectorsstring[]Detection backends: rules and/or entities
placeholderstringOverride the visible stand-in template, e.g. [REDACTED:{category}]

voice​

Bind a voice profile under defaults.voice, by name. Exactly one source is expected:

FieldTypeDescription
profilestringThe id (or name) of a profile in the project's voice store, as kapi voice profiles lists it
packstringName 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
FieldBackendDescription
backendalllocal keeps the context on each machine; file, git and s3 share it
refgitThe ref the context is kept on. Default refs/kapi/context; a branch or a tag is refused
remotegitThe git remote fetched from and pushed to. Default origin
pathfileThe directory the context is kept in, such as a mounted team share. A relative path is read against the recipe's directory
buckets3The bucket. Required
prefixs3A key prefix, so one bucket holds several projects
regions3The bucket's region, when the AWS configuration names another
endpoints3The 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.

FieldTypeDescription
ship_gateGateA single catch-all gate: state name → threshold
ship_gatesShipGateRule[]Scoped rules, each a selector plus a gate; when present it replaces ship_gate
gatesmapNamed gate registry; a ship_gates rule references an entry with gate: <name>
source_gateGateA gate over the source ladder (written → established)
established_gateGateA single catch-all approval gate, in the same threshold form as ship_gate
established_gatesShipGateRule[]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:

StateWhen
shippableA ship gate matches the scope and the scope clears it, with nothing withholding it
withheldThe scope is short of a matching gate, or stale, rejected, failing or unchecked content holds it back
not_gatedNo 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:

FieldTypeDescription
whenSelectorOptional match: locales and/or collections (string lists). Omitted = catch-all default
gateGate or stringAn 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.yaml per 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.yaml recipe and its extension mechanism.
  • kapi.yaml Project File Format: the implementation note; the KapiProject, Defaults, Collection and Profile struct 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.