The kapi loop
Your source content changed, so its translations, terminology checks and voice
checks are behind. In a project that ships, this is the everyday state, and kapi
has one command for catching up: kapi up.
kapi up runs the kapi loop. The loop has two halves with a gate between
them. The first half gets the source right: it runs the voice and terminology
checks on what you wrote and holds until the source clears its bar. The second
half catches every language up to that source. It reuses what you've translated
before, translates the rest with AI and checks what it produced. The source comes
first because a defect in the source is copied into every target language. What
a machine cannot decide waits for review by a person, and kapi up still
finishes. A monolingual project stops after the first half.
You declare what caught up means in the recipe: the target languages, the flow that produces them and the gates a language must clear to ship. Every run compares the working tree with that declaration and closes what it can.
Three roles, kept separate:
- The loop produces:
kapi updrives each block as far up its lifecycle as a flow can take it unaided (typically to translated). - Review promotes: a person advances a block past what a machine can decide (translated → approved).
- The gate releases: it changes no content. It answers one question: is this locale 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, such as "in progress" flags or cursors. Every command
recomputes a block'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
blocks fall back to pending.
State is kept per edition: one translatable block in one language.
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, shorter ladder:
Source authoring readiness: the source-side counterpart.
The recipe and the JSON reports spell the approved rung established, so a
gate threshold on it reads established: 80.
Where state is recorded depends on the file that carries it. A plain target file
(JSON, an XLIFF, …) records only that a target is present, so a file-based project
reads a present target as translated. The higher rungs need a carrier that can
hold them. Approved comes from an approval recorded in the project's
block-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 locale clears a gate when, for every threshold, at least that percent of its blocks have reached that rung or higher.
# kapi.yaml
ship_gate: { translated: 100, established: 80 }
This reads "every block translated, and the important 80% approved; ship the long
tail machine-translated." A bare 100% approved is the strictest form.
Gates can be selected by rule, so different collections or locales answer to different bars (the most specific matching rule wins). Where a rule names a collection, each collection in a locale is judged on its own:
ship_gates:
- when: { locales: [de] } # German is contractually reviewed
gate: { translated: 100, established: 100 }
- gate: { translated: 100, established: 80 } # the default
The source ladder has its own optional bar, source_gate (e.g.
{ written: 100 }), the source-side counterpart of ship_gate.
A gate never fails an ordinary build. Target-language drift is expected work. A gate is a release check you opt into (see below).
Ship states
A locale that ships is named by the ladder rung it ships at. A locale that does not ship is withheld:
| State | What it means |
|---|---|
| approved | The locale clears its approval gate (established_gate / established_gates): a person approved the content. |
| translated | The locale clears its ship gate and no approval gate: translated, with its checks green, and shipped as AI translation. |
| withheld | The locale 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 locale back even when no gate matches it. |
| not gated | No gate matches the locale, and nothing withholds it. |
You choose what a project delivers with its ship gate: { translated: 100 }
delivers translated content, { established: 100 } only approved content.
A project with no gates has only withheld and not-gated locales, and a kapi up run that converges reports that no ship gates are declared. A language
picker offers a not-gated locale by default.
Who approves a block
Approved is the one human rung: a person reviewed or edited this exact
translation. An agent can pre-review a block with a score and its reasons, and
the review queue shows them. An agent's judgement never counts as a person's, so
every established threshold counts people. Gates can still differ by locale and
collection, so you can run several tiers side by side:
gates:
contractual: { translated: 100, established: 100 }
standard: { translated: 100, established: 80 }
machine: { translated: 100 }
ship_gates:
- when: { locales: [de] } # contractual market: a person approves every block
gate: contractual
- when: { locales: [fr, es] }
gate: standard
- gate: machine # the long tail: translated, with its checks green
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 hold between them. Every locale translates the same source. A term left unresolved or a sentence left off-brand is paid for once per language, then again when you fix the source and everything is retranslated. The loop settles the source once and starts the per-language work only when the source is ready.
Settle the source, hold it until it reaches translate_after, then translate the approved source per locale; each gate holds back work that is not ready.
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 blocks onto their ladder (written → approved) and marks any that fail.
- Hold the source.
defaults.translate_after(writtenby default, orestablishedfor approved source) is the level each block's source must reach before it is translated. A block below it holds. Machine-translating content you are about to change costs more than settling it first. - Translate the approved source. Only settled source is translated, per locale and from the content memory first: recycle what you have already translated, AI-translate the remainder, check what was produced. When a source was edited, the translation a person approved for the earlier wording goes into the prompt as reference, under the same governing context. The redraft keeps that accepted wording where it still fits. Protected terms stay protected.
- Hold at the target ship-gate. A locale that clears its
ship_gateis shippable. Whatever a machine cannot decide waits for review and does not ship.
A gate never fails. It holds work and routes it to a person, through the source-review worklist or the target review queue, and the command still exits zero. Editing a source block re-opens the hold for the changed segments only, so a fix re-settles and re-translates those segments and leaves the rest of the corpus alone.
Seeing it: status and check
kapi status is the derived dashboard: coverage per locale and each locale's
standing against its gate, recomputed on every run:
kapi status
# source 2 blocks · written 100% · approved 0%
#
# locale blocks draft translated approved progress ship
# de 42 100% 100% 80% ██████████ 100% translated
# nb 42 100% 60% 0% ███░░░░░░░ 30% blocked: translate
#
# 1 of 2 ready to ship
A project connected to a convergence venue gets two more lines, and they answer different questions. Content is a position: what is here and what is there. A transfer resolves it. Governance is an identity: which context, terms and decisions are in force. When two identity hashes differ, they say nothing about distance or direction. The line reports that a component moved, and you judge what that means for work already written.
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 enforces the gates before a release. 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. It exits
non-zero when any gate is unmet, so you can run it for a release tag or a main
check. Target drift never blocks an ordinary build; --ship is where you opt in
to enforcement. A bare kapi check <files> runs a checkset over the files you
name, the way a test runner runs tests. 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 locale is shippable or waits for
review:
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 to catch up.
The translate step reads the content memory too. With reuse: prior (its
default), a block whose source was edited gets the translation approved for its
earlier wording as reference, when the same governing context is in force.
reuse: none turns that read off for a cheap run.
kapi up # run the default flow; stop where a gate holds content back
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 block with failing findings (dropped placeholders, terminology
violations) still counts as translated, and the finding keeps its locale from
shipping until it is fixed. The percentages describe the content. The verdict
says what a check withholds, and kapi status and kapi status --ship run the
same checks and reach the same verdict. The loop stops when every gated locale
is shippable, when a pass makes no progress, or when it reaches the pass cap
(default 5, --passes N). Whatever cannot be advanced unaided waits for
review: it is reported as awaiting a person and the command still exits zero.
up never fails the build on target drift. The work waiting for review is the
review queue.
Under materialize: on-converge a language waiting for review gets no files.
Its drafts are kept in the project's workspace, outside the
checkout. kapi status, the review worklist, kapi inspect, kapi apply and
Kapi Desktop's review pane all read and edit them there. An edit to a language
that has no file yet is kept there too, so no edit delivers a file the gate
withholds. The drafts reach the language's files when the language is delivered:
by the run in which it clears its gate, or by kapi merge. Deleting
.kapi/work/ loses none of them, and a project that
shares its context shares them too.
A language's file can also appear another way: you write it, kapi pull or a
checkout brings it, or you switch the recipe to materialize: manual and a pass
writes it. From then on, the file holds the language. The next kapi up drops
the kept drafts the file supersedes and keeps any wording a person or an agent
wrote that the file does not hold; kapi status lists that wording, and
kapi merge writes it into the file.
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 blocks the pass will work on,
exact content-memory leverage, remaining AI work, a token estimate and the
provider it will use. You see the cost before any tokens are spent. --plan
alone prints the full dry-run table. While the loop runs, up prints one line
per locale, repainted in place on a terminal, or one line per event on a plain
stream.
The plan prices 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. A block costs a provider call when the corpus has no answer for its source and the configured flow reaches a provider, whatever a target file holds. A flow that only recycles leaves the blocks the corpus cannot answer out of reach. The plan reports them without a price, because that run will not touch them or call a provider. The plan lists three kinds of work separately: missing blocks, stale ones (a decision's source moved) and unanswered ones (a target the record does not pair with its source). They mean different things to you, and the run prices them the same way.
The plan prices only the languages a pass works on. The loop passes over a language while it holds work: a block with no translation, a stale or rejected block, a block failing the bound checks, or a gate the language does not meet. A language with none of these keeps what it holds, so its unanswered blocks cost nothing this run and the plan reports nothing to draft for it.
A block the project store already holds a draft for costs nothing further. The
drafting step checks the store before it calls a provider. It reuses a stored
translation of the same source when that translation was made under the
configuration and governing context the step would use now. The plan counts such
a block under stored drafts and quotes no tokens for it. A language held back
by its gate reads this way on every run after the one that drafted it, so you pay
for its drafts once. The workspace keeps each draft waiting for review together
with the details the drafting step matches on, so a run in a checkout whose
.kapi/work/ was deleted reuses them at no cost. A changed model, prompt, voice
profile or term rule, or an edited sentence, puts the block back under drafted
by flow.
kapi up · KapiMart · flow "default (built-in)" · anthropic/claude-sonnet-4-6 (your subscription)
plan: 288 block(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
Not yet up to date: some languages wait for review (never a build failure).
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 runs them one at a time). At the end of each pass, kapi waits for
every locale, then re-derives coverage, runs the checks and decides which blocks
wait for review, once for all locales together.
Within one pass, kapi up fans out per locale (--jobs); the pass barrier re-derives coverage and decides which blocks wait for review 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, waiting for review or converged), with the
structured result as the final record. The same events feed the Kapi Desktop
passes view.
kapi run <flow> runs a custom pipeline: one named flow, one pass, no gate
loop. Use it when you want a specific composition, and use kapi up for the
daily catch-up.
Drift, watched end to end
Because state is derived, you can watch each step of the recovery from a source
edit. Take German, fully caught up under
ship_gate: { translated: 100, established: 80 }:
kapi status
# locale blocks draft translated approved progress ship
# de 42 100% 100% 100% ██████████ 100% translated
Edit one source string. No flag is set and no state is written. The next derivation reads the working tree and compares each decision with the source it approved. The edited block's basis differs from its current source, so it reads as stale. It holds a target, so it sits at draft, but that target translates a sentence that is gone from the project, and the approval bound to the old pairing does not apply:
kapi status
# locale blocks draft translated approved progress ship
# de 42 100% 98% 98% █████████░ 99% blocked: stale
#
# 1 block(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 block is work. The loop re-drafts it against the current source, content memory first and AI for the remainder, the same way it produces a block that has no translation. The plan quotes that work before any tokens are spent:
kapi up --plan
# locale 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 block(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 block(s) against the current source. They return to
# review un-approved (the earlier approval is not restored).
kapi status
# locale blocks draft translated approved progress ship
# de 42 100% 100% 98% █████████░ 99% blocked: stale
The re-draft happens however the block is identified. When the edit re-keys the block (a prose paragraph, whose identity is its wording), the old translation stops pairing with it. When the block keeps its key (a catalog entry, whose identity is its name), the old translation still sits beside the rewritten sentence. The loop does not count it as a translation of the new sentence. It keeps the earlier wording in the content memory under the sentence it does translate, where the redraft reads it as reference, and drafts the entry afresh.
This applies in every language, reviewed or not. A rewrite changes the
source, so every locale's translation of the old sentence translates a sentence
that is gone. Each is kept under its own wording and each is drafted again. The
languages with a decision show the block 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. Someone wrote that pairing on purpose, so kapi keeps it as it
stands.
For a translation nobody has reviewed, the source it was made from is in the
record of the run that wrote it, which the project's context keeps beside its
decisions. In a project that shares its context through a backend, a fresh
clone reads that context with
kapi context sync.
Until it does, kapi up and kapi status say so in one line, and a source
edited before the clone's first pass is not graded stale there:
kapi status
# ...
# This checkout holds no record of the source its translations were made from; run `kapi context sync` to read the project's context before a pass grades them.
The loop produces translations and never approves them. The re-drafted block comes back at draft, in the review worklist, and the earlier approval is not restored. The locale does not ship until a person reviews the new pairing.
One verb, two venues
The loop's compute runs somewhere. That place is the venue, a property of
the project, and kapi up is the same command in either venue. Three concerns
stay separate, each with its own command or setting:
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. They move data only (a diff, conflicts, terminology hand-off) and never produce translations, likegit pushandgit 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. The open-source kapi
binary carries no server code, so this is the only venue it has. 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 blocks waiting for review land in the team's
review queue. The run's event stream is identical in either venue. 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 and fails if it is unavailable,
for CI that must never spend local keys.
kapi push only moves data; it never translates. A server that keeps the
project continuously caught up (server.converge) is an explicit per-project
policy, in the way that CI running on git push is a repository policy.
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, which you install beside 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 block to translated; a person takes it to approved. That approval is an authored decision (who approved which exact translation), so kapi records it in the project store. It is durable at once, and every checkout of the project reads it. The content memory holds no approvals.
kapi status --review is the worklist: one queue of the blocks awaiting a
person, each belonging to a language, and the source language is one of them
(its rows are marked source). Translated blocks not yet approved are the
derived counterpart of the loop's waiting for review outcome. You approve one with
kapi apply, as a decide operation that names the source document, the block
and the edition the worklist lists, and the revision kapi inspect shows for
that edition:
kapi status --review # the worklist: file, key, locale per pending block
kapi status --review --lang nb # one language (repeat or comma-separate for several)
kapi inspect src/en.json --jsonl # each block's editions, with their revisions
kapi apply <<<'{"ops":[{"op":"decide","at":{"doc":"src/en.json","block":"save.label","edition":"nb"},"if_match":"r:74dbfac2ed1c9d6e","outcome":"establish"}]}'
An approval is about a pairing: this rendering, of this source. The
record binds both halves by revision, the same token kapi inspect shows: the
translation it blesses, and the basis, the source it blessed that
translation for. Editing either side afterwards drops the block back below
approved. A changed link or other inline code counts as much as a changed word. An approval
recorded by an earlier release binds the two texts alone, and keeps answering
that way until the block is decided again. Once recorded, coverage reads the
block as approved, approved% climbs, and a { established: … } gate
flips to shippable.
A block whose source moved reads as stale. It holds a target, so it sits
at draft, but the target translates the old source. kapi status counts it,
the review worklist takes it back, and the locale does not ship, with or without
a declared ship gate. 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.
A decision recorded without a basis is unknown. Such a block keeps its rung
and ships, and kapi status reports how many there are. Deciding the block 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 block falls below approved until someone approves the new pair.
The record travels with the project through the project's context backend with
kapi context sync, or in one file with kapi store export. A
server-connected project pushes it to the remote instead, where blocks waiting for review
enter the team's assignment and review queue.
The content memory is separate. A memory operation through kapi apply adds
a source→target pair to the recycle corpus for future reuse and never
promotes a block to approved.
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 block state in the
project store, and the closing status shows approved climb:
Loading the walkthrough…
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.