The kapi loop
Your source content changed. Its translations, its terminology, its voice
conformance are now behind, and that is normal. Kapi treats
catching up as the everyday state of a shipping project, and gives it one
verb: kapi up.
kapi up runs the kapi loop, and the loop has two halves with a gate
between them. First get the source right (the voice and terminology checks on
what you wrote) and hold there until it clears its bar. Only then get every
language caught up to it: reuse what you've translated before, AI-translate the
rest, check everything produced. Settling the source first is deliberate: every
defect in the source would otherwise multiply across each target language. What
a machine cannot decide parks for a person; the loop never blocks the
author. A monolingual project stops after the first half.
The recipe declares what caught up means: the languages the project targets, the flow that produces them, the gates that decide shippable. Every run measures the working tree against that declaration and closes what it can.
Three roles, kept separate:
- The loop produces:
kapi updrives each unit as far up its lifecycle as a flow can take it unaided (typically to translated). - Review promotes: a person advances a unit past what a machine can decide (translated → reviewed).
- The gate releases: it drives nothing and answers one question, is this scope shippable?
The model behind the loop (derived state, the lifecycle ladders, coverage
gates) is called convergence: each kapi up pass converges the working
tree on the state the recipe declares, the way terraform apply converges
infrastructure on a program. The
architecture pages, the
run's event stream, and the --json output keep that vocabulary; these pages
say the loop.
State is derived
Kapi stores no workflow state: no "in progress" flags, no cursors. Every command
recomputes a unit's state from the content on disk and the project's assets,
the way git status recomputes from the working tree. Run a command twice with
no changes and you get the same answer; change a source file and the affected
units fall back to pending on their own.
The unit of state is a target: one translatable block in one locale.
The lifecycle ladders
A target climbs a monotone ladder: each rung implies the ones below it. An absent target sits below the lowest rung; a present, non-empty target is at least translated.
Target lifecycle: how far a translation has progressed.
Source content has its own, symmetric ladder, what keeps the author in check before anything is translated:
Source authoring readiness: the source-side counterpart.
Where state is recorded depends on the carrier. A plain target file (JSON, an
XLIFF, …) records presence only, so a file-based project reads a present target
as translated. The higher rungs ride a carrier that can hold them: reviewed
comes from an approval recorded in the project's
unit-state record (below), and a
.kpz parcel carries the full status across a hand-off.
The gate: coverage thresholds
A gate is a set of coverage thresholds over the ladder: a state name to a minimum percent. A scope clears a gate when, for every threshold, at least that percent of its units have reached that rung or higher.
# kapi.yaml
ship_gate: { translated: 100, reviewed: 80 }
This reads "every unit translated, and the important 80% reviewed; ship the long
tail machine-translated." A bare 100% reviewed is the degenerate case.
Gates can be selected by rule, so different collections or locales answer to different bars (the most specific matching rule wins):
ship_gates:
- when: { locales: [de] } # German is contractually reviewed
gate: { translated: 100, reviewed: 100 }
- gate: { translated: 100, reviewed: 80 } # the default
The source ladder has its own optional bar, source_gate (e.g.
{ checked: 100 }), the source-side counterpart of ship_gate.
A gate never fails an ordinary build. Target-language drift is expected work, and a gate is a release check you opt into (see below).
Three ship states
Every scope reads as one of three states against the ship gates:
| State | What it means |
|---|---|
| shippable | A ship gate matches the scope and the scope clears it. |
| withheld | The scope does not ship. It is short of its gate, or it holds stale wording, a translation a reviewer turned down, a failing check, or terms that were not checked. These hold a scope back whether or not a gate matches it. |
| not gated | No ship gate matches the scope, and nothing withholds it. |
A project with no ship_gate or ship_gates has only withheld and not-gated
scopes. kapi status and kapi up name those scopes not gated rather than
shippable, and a kapi up run that converges says that no ship gates are
declared. Nothing withholds a not-gated scope, so a language picker offers it by
default.
Approver classes: who counts as a reviewer
Reviewed and signed-off are approval rungs: someone approved this exact
translation. A threshold's approver class (by, in the extended form
reviewed: { pct: 100, by: human }) decides whose approvals count toward it.
The default is human: only an approval made by a person, or an agent acting
for one, satisfies the threshold, and the short form (reviewed: 80) always
means by: human. by: any also counts autonomous AI approvals, those
recorded under an ai/… identity, such as an AI pre-review that auto-approves
what it scores clean. An AI-approved unit reads as reviewed in kapi status
(with an (ai) qualifier) but does not satisfy a human threshold: an
autonomous AI approving its own work never silently ships a gate that was
written when reviewed could only mean a person. The default is a policy a
recipe can override per scope, so tiers coexist:
gates:
contractual: { translated: 100, reviewed: { pct: 100, by: human } }
standard: { translated: 100, reviewed: 80 } # short form: by: human implied
machine: { translated: 100, reviewed: { pct: 100, by: any } }
ship_gates:
- when: { locales: [de] } # contractual market: a person approves every unit
gate: contractual
- when: { locales: [fr, es] }
gate: standard
- gate: machine # the long tail: autonomous AI review counts
The full schema (threshold forms, rule selection, the named registry) is in the project-file reference; a worked per-market strategy is Tier gates per market.
Source first: settle, gate, then translate
The two ladders run in order, with the source ship-gate between them. Source content is shared across every locale, so a term left unresolved or a sentence left off-brand would be paid for once per language, then paid for again when the source is fixed and everything retranslates. The loop settles the source once and holds the fan-out until it is ready.
Settle the source, clear its gate, then translate the approved source per locale; each gate holds work that is not ready rather than shipping it.
Read the loop left to right:
- Settle the source. Check terminology and mark the terms that must not be translated, check voice profile, run the source checks. This lifts source units up the authoring ladder (authored → checked → approved).
- Hold at the source ship-gate. The optional
source_gate(for example,{ checked: 100 }) is the source-side counterpart ofship_gate. Source that has not cleared its bar holds: settling the source first is cheaper than a machine translation of content you are about to change. - Translate the approved source. Only settled source fans out per locale, memory-first: recycle what you have already translated, AI-translate the remainder, check what was produced. Where a source was edited, its previously approved translation goes into the prompt as reference, under the same governing context, so the redraft keeps wording a person already accepted where it still fits. The protected terms stay protected.
- Hold at the target ship-gate. A locale that clears its
ship_gateis shippable; whatever a machine cannot decide parks for review rather than shipping under-reviewed.
A gate never fails: it holds work and routes it to a person (a source-review worklist, or the target review queue) and the command still exits zero. Editing a source unit re-opens the source gate for only the changed segments, so a fix re-settles and re-translates just those, never the whole corpus.
Seeing it: status and check
kapi status is the derived dashboard: coverage per locale and each scope's
standing against its gate, recomputed on every run:
kapi status
# source 2 units · authored 100% · checked 100% · approved 0%
#
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 100% 80% 0% ██████████ 100% ready
# nb 42 100% 60% 0% 0% ███░░░░░░░ 30% blocked: translate
#
# 1 of 2 scopes ready to ship
A project connected to a convergence venue gets two more lines, and they are two
different questions. Content is a position (what is here and what is there)
and a transfer resolves it. Governance is an identity: which context, terms
and decisions are in force. Two identity hashes that differ carry no distance
and no direction, so the line says a component moved and never how far behind
you are; resolving what that means for work already written is a judgement, and
kapi status does not make it.
kapi status
# content https://venue.example.com · 12 to push · 0 to pull
# governance context in sync · terms moved · decisions in sync
# run `kapi pull` to take down the governance that moved
kapi check --ship is the enforcing counterpart, the pre-release bar. It runs
the project's bound quality gates (voice, terminology, rule-based checks,
staleness) together with the ship and source coverage gates over the project's
content, and exits non-zero when any gate is unmet (for a release tag or a main check). Target drift never blocks an
ordinary build; --ship is the explicit, opt-in enforcement point. (A bare
kapi check <files> runs a checkset over the files you name, the content-first
counterpart to a test runner.) See
Check translations in CI.
Running the loop: kapi up
kapi up runs the loop. It reads what the recipe declares, then runs the
project's default flow (defaults.flow) over all content across every target
language, looping until every gated scope is shippable or parks for a
human:
defaults:
flow: translate-and-check # a flow defined under flows:
Without defaults.flow, up runs the built-in default flow (content-memory
reuse, then AI translate) so a recipe needs no flow YAML at all to catch up.
The translate step reads the content memory too: with reuse: prior (its
default), a block whose source was edited gets its previously approved
translation as reference, offered only when the same governing context is in
force. reuse: none turns that read off for a cheap run.
kapi up # loop the default flow until every gated scope ships or parks
kapi up --plan # dry run: pending work, content-memory leverage, and a token estimate per locale
kapi up --passes 1 # a single pass over every locale that needs work
kapi up --materialize # also write the translated files for the shippable locales
Produce loops to the gate; review promotes what a machine can't decide; the gate releases.
Each pass re-derives coverage from the working tree, runs the flow only for the
locales still short of their gate, then runs the project's bound checks over what
was produced: a unit with failing findings (dropped placeholders, terminology
violations) still counts as translated, because it is, and the finding holds
its locale out of shipping until it is fixed. The percentages describe the
content; the verdict is what a guardrail withholds, and kapi status and
kapi status --ship run the same checks and reach the same verdict. The loop
stops when every gated scope is shippable, a
pass makes no progress, or the pass cap (default 5, --passes N) is reached.
Whatever cannot be advanced unaided parks: it is reported as awaiting a human
and the command still exits zero. up never blocks the author, and it never
fails the build on target drift; parked work is the review queue.
Before each pass, up re-syncs the project block store with the working tree:
edited source files, a store written by another kapi version, or a missing store
trigger a re-extraction. --no-extract opts out; --no-checks skips the bound
checks in the loop.
The run is live
up starts by printing a one-line plan (the units the pass will work on,
exact content-memory leverage, remaining AI work, a token estimate, and the
provider it will use) so cost is visible before any tokens burn (preview before
apply; --plan alone is the full dry-run table). It then renders progress as the
loop runs: one line per locale, repainted in place on a terminal, a line per
event on a plain stream.
The plan asks what the pass asks, of the flow the pass would run. A pass reads the source documents, fills from the content memory where the flow recycles, and drafts the remainder where the flow drafts, so a unit costs a provider call when the corpus has no answer for its source and the configured flow reaches a provider, whatever a target file happens to hold. A flow that recycles and nothing else leaves the units the corpus cannot answer out of reach: they are reported and not priced, because that run will not touch them and will call nobody. Missing units, stale ones (a decision's source moved) and unanswered ones (a target the record does not pair with its source) are named apart, because they mean different things to you, and priced together, because the run does not treat them differently.
A unit the project store already holds a draft for costs nothing further. The drafting step asks the store before it calls out, and serves a stored translation of the same source when it was made under the configuration and governing context the step would use now, so the plan counts such a unit under stored drafts and quotes no tokens for it. A language parked short of its gate reads this way on every run after the one that drafted it: its drafts are paid for once. A changed model, prompt, voice profile or term rule, or an edited sentence, puts the unit back under drafted by flow.
kapi up · KapiMart · flow "default (built-in)" · anthropic/claude-sonnet-4-6 (your subscription)
plan: 288 unit(s) missing · 54 exact-content memory · 234 AI · ≈61k tokens
pass 1/5
fr-FR ████████████░░░░░░ 62/96 (content memory 18 · AI 44) 2 checks ✗
de-DE █████░░░░░░░░░░░░░ 27/96 (content memory 18 · AI 9)
ja-JP queued
nb-NO ready 96/96
Up to date in 2 passes · 3 locales shippable · de-DE parked: 2 units need review
→ kapi status --review
Locales are independent within a pass, so up catches them up concurrently:
--jobs N sets how many run at once (default 4; recipe override defaults.jobs;
--jobs 1 restores serial execution). The pass barrier stays: coverage is
re-derived, checks run, and parking is decided once per pass, over all locales
together.
Within one pass, kapi up fans out per locale (--jobs); the pass barrier re-derives coverage and decides parking once all workers finish.
For agents and CI, --json streams the run as NDJSON, one convergence event
per line (plan, pass, per-locale progress, checks, parked/converged), with the
structured result as the final record. The same events feed the Kapi Desktop
passes view.
kapi run <flow> remains the escape hatch for a custom pipeline: one named
flow, one pass, no gate loop. Reach for it when you want a specific composition;
reach for kapi up for the daily catch-up.
Drift, watched end to end
Because state is derived, recovery from a source edit is observable at every
step. Take a German scope fully caught up under
ship_gate: { translated: 100, reviewed: 80 }:
kapi status
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 100% 100% 0% ██████████ 100% ready
Edit one source string. Nothing flags the change and no state is written; the next derivation reads the working tree and compares each decision against the source it blessed. The edited unit's basis no longer matches, so it reads as stale: it holds a target, so it sits at draft, but that target translates a sentence the project no longer has, and the approval bound to the old pairing does not apply:
kapi status
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 98% 98% 0% █████████░ 99% blocked: stale
#
# 1 unit(s) stale: the source changed since the translation was decided.
# They do not ship until you re-review them with `kapi status --review`.
One pass closes what a machine can close. A stale unit is work: the loop re-drafts it against the source the project has now (content memory first, then AI for the remainder, exactly as it produces a unit that has no translation at all) and the plan quotes that work before any tokens burn:
kapi up --plan
# scope missing stale unanswered content memory exact stored drafts drafted by flow out of reach ~tokens
# de 0 1 0 0 0 1 0 9
#
# 1 unit(s) stale: their source changed since the translation was decided.
# They are re-drafted against the current source (priced above) and return
# to review un-approved. See `kapi status --review`.
kapi up --passes 1
# Re-drafted 1 stale unit(s) against the current source. They return to
# review un-approved (the earlier approval is not restored).
kapi status
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 100% 98% 0% █████████░ 99% blocked: stale
The re-draft holds however the unit is identified. Where the edit re-keys the unit (a prose paragraph, whose identity is its wording), the old translation no longer pairs with it at all. Where the unit keeps its key (a catalog entry, whose identity is its name), the old translation is still sitting beside the rewritten sentence, and the loop declines to read that adjacency as a translation of it: the earlier wording is kept in the content memory under the sentence it does translate, where the redraft reads it as reference, and the entry itself is drafted afresh.
That holds in every language, whether or not someone had reviewed the unit.
A rewrite is a fact about the source, so every locale's translation of the old
sentence is a translation of a sentence the project no longer has. Each is kept
under its own wording and each is drafted again; the languages with a decision
say so on the stale axis above, and the rest come back as pending work.
A translation edited in the same commit as its source is the exception it should
be: that pairing was authored rather than left behind, so it is kept as it stands.
The loop produces and never decides. Producing a translation is a different act from approving one, so the re-drafted unit comes back at draft, in the review worklist, with the earlier approval not restored; the scope does not ship until a person reviews the new pairing. Drift is never converged over, and it is never left un-worked either.
One verb, two venues
The loop's compute has to run somewhere. That venue is a property of the project, not a different verb. Three concerns stay orthogonal, each owned by one surface:
Transport moves state, the loop produces work, venue is where it runs: three orthogonal concerns.
- Transport:
kapi pushandkapi pullmake a local checkout and a server replica consistent. Pure data movement (Merkle diff, conflicts, terminology hand-off); they never produce translations. The analogy isgit push/git fetch. - The loop:
kapi up, the one verb that produces work toward the gates. - Venue: where
up's compute executes: your machine (your AI subscription or keys) or a connected server (the org's keys, shared content memory and terminology, always-on).
In a file-only project the loop runs locally; this is the whole story
for the open-source kapi binary, which carries no server code. In a
server-connected project (a recipe that declares a server venue) the same
kapi up runs on the server by default: it pushes the drift, the server runs
the loop on the org's keys and shared assets, progress streams back live into
the same terminal view, results pull down, and parked units land in the team's
review queue. The run's event stream is identical in either venue, and the venue
itself is never implicit: whenever a declared venue makes the choice ambiguous,
kapi up prints the resolved venue as its first line, and kapi status reports
the effective venue and server.converge policy alongside the coverage grid.
kapi up --local overrides the venue for one run, running the loop on your
machine and then pushing the results so the server is never left stale;
kapi up --server requires the server venue, failing rather than falling back
to a local run (for CI that must never spend local keys).
Transport is pure: kapi push does not translate behind your back. A
server that keeps the project continuously caught up (server.converge) is an
explicit, per-project policy the way git push triggering CI is a repository
policy, not a property of git.
The server venue (the platform's recipe block, kapi push/pull/up --local, the
continuous catch-up policy, and the server-side Runs surface) is provided by
the connected platform's plugin rather than the open-source kapi binary. See the platform
documentation for setting up a connected project and convergence as a service.
One default flow can still vary by locale. defaults.locales.<lang>.tools
overrides a tool's config for that target language only (merged on top of the
project-wide defaults.tools and under a flow step's own config) so a single
flow turns an advanced step on where a locale needs it:
defaults:
flow: translate-and-check
locales:
de:
tools:
redact: { enabled: true } # German redacts PII; other locales don't
Review: the state record
A machine takes a unit to translated; a person takes it to reviewed. That
approval is authored unit state (who signed off on which exact translation),
so it lands in the project store's working set, and
kapi commit publishes it to the committed unit-state record under .kapi/state/.
The content memory holds none of it.
kapi status --review is the worklist: one queue of the units awaiting a
person, each belonging to a language, and the source language is one of them
(its rows are marked source). Translated units not yet approved are the
derived counterpart of the loop's parked outcome. You approve one with the one
write verb, kapi apply, as a review change-set addressed by the unit's
file / id / locale exactly as the worklist lists them:
kapi status --review # the worklist: file, id, locale per pending unit
kapi status --review --lang nb # one language (repeat or comma-separate for several)
kapi apply <<<'{"kind":"review","file":"src/nb.json","id":"save.label","locale":"nb","status":"reviewed"}'
An approval is about a pairing: this rendering, of this source. The
record binds both halves by content hash: the
translation it blesses, and the basis, the source wording it blessed that
translation for. Editing either afterwards drops the unit back below reviewed,
so an approval can't silently outlive the text it approved on either side. Once
recorded, coverage reads the unit as reviewed, reviewed% climbs, and a
{ reviewed: … } gate flips to shippable.
A unit whose source moved reads as stale: it holds a target, so it sits at
draft rather than below the ladder, but it is not a translation of the source
the project has now. kapi status counts it, the review worklist takes it back,
and the scope does not ship, whether or not a ship gate was declared, because no
coverage threshold makes a translation of a rewritten sentence shippable. The
next kapi up re-drafts it against the current source (priced in --plan, and
reported as a re-draft in the run summary) and leaves it un-approved for review.
Restore the source before that run and it converges back on the decision
already recorded; nobody re-reviews anything.
Decisions recorded before the basis was tracked carry none. Such a unit is
unknown rather than stale: it keeps its rung and ships as before, and kapi status
reports how many there are so the assumption is visible. Deciding the unit again
records a basis.
An approval binds to the pairing it blesses (the translation, and the source it was approved for): edit either and the unit falls below reviewed until someone approves the new pair.
The shards are committed with your sources, so the record travels with the project (a server-connected project pushes it to the remote instead, where parked units enter the team's assignment and review queue).
This is distinct from the content memory: kapi apply with kind:"memory" adds a
source→target pair to the recycle corpus (defaults.memory_source) for future
leverage and never promotes a unit to reviewed.
Run the loop yourself: this is the real kapi compiled to WebAssembly, working on
an in-memory project. status shows the translated-but-unreviewed state,
status --review lists the worklist, apply records the unit state in the
project store, and the closing status shows reviewed climb:
Loading the walkthrough…
A second, optional human gate sits above review: signed-off, a final approval
for content that needs it (a contractual locale, a regulated string). It is the
same correction with a signed-off review state, the top rung a
{ signed-off: … } gate measures:
kapi apply <<<'{"kind":"review","file":"src/locales/nb/messages.json","id":"app.save","locale":"nb","status":"signed-off"}'
See also Where translations & terms live for the memory and terms model, and Keep source on brand for the source-side checks that drive the authoring ladder.