Gå til hovedinnhold

The kapi loop

Your source content changed. Its translations, its terminology, its brand conformance are now behind — and that is normal, not an error. 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 brand 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 simply 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 produceskapi up drives 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; it answers one question, is this scope shippable?
The formal name

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, not tracked

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.

1draftmachine placeholder or fuzzy leverage2translateda real translation exists3revieweda person approved this exact pair4signed-offfinal

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:

1authoredsource content exists2checkedclears its brand & terminology checks3approveda person signed off on the source

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 approved decision in the project's state store (below), and a .kbf.json 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, not a break; a gate is a release check you opt into (see below).

Approver classes: who counts as a reviewer

Reviewed and signed-off are decision rungs — someone approved this exact translation. A threshold's approver class (by, in the extended form reviewed: { pct: 100, by: human }) decides whose decisions count toward it. The default is human: only a decision 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 — decisions 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 policy, not a fixed rule — a recipe opts a scope in explicitly, 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 are not run in parallel — they 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 sourceterm-check + protectbrand-checksource QAsource ship-gatesource_gatehold — settle your source firsttranslate approved sourcerecycle (memory-first)AI remaindertarget QAtarget ship-gateship_gatepark — needs reviewconverged

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:

  1. Settle the source. Check terminology and mark the terms that must not be translated, check brand voice, run source QA. This lifts source units up the authoring ladder (authored → checked → approved).
  2. Hold at the source ship-gate. The optional source_gate (for example, { checked: 100 }) is the source-side counterpart of ship_gate. Source that has not cleared its bar holds — the honest, cheap outcome is settle your source first, not a machine translation of content you are about to change.
  3. 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. The protected terms stay protected.
  4. Hold at the target ship-gate. A locale that clears its ship_gate is 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 0% · 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

kapi check --ship is the enforcing counterpart — the pre-release bar. It runs the project's bound quality gates (brand, terminology, QA) 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 (recycle) followed by AI translate — so a recipe needs no flow YAML at all to catch up.

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
producekapi up — default flow over pending unitseach pass re-derivescoverage and repeatsa person / agentpromotekapi apply — review approves a unitgate metreleasekapi check --ship — the gate

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) counts as drafted, not translated, so it cannot lift its locale over the gate until fixed. 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, not an error.

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 — units missing a target, 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.

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.

fr-FRworkerrecycle from memoryAI translatebound checksde-DEworkerrecycle from memoryAI translatebound checksja-JPworkerrecycle from memoryAI translatebound checks

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 simply reads the working tree: the affected unit's translation no longer corresponds to its source, so the unit falls back to pending and the approval bound to the old pair no longer applies:

kapi status
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 98% 98% 0% █████████░ 99% blocked: translate

One pass closes what a machine can close — the changed unit is re-translated, memory first, then AI:

kapi up --passes 1
kapi status
# scope units draft translated reviewed signed-off pipeline ship
# de 42 100% 100% 98% 0% ██████████ 100% ready

Translated recovers to 100% and the scope clears its gate again — the 80% reviewed threshold left room for the re-opened unit, which now sits in the review worklist (kapi status --review). Under reviewed: 100 the scope would instead stay pending until a person re-approves that unit.

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:

Transportkapi push / pullmove project stateMerkle diffno translationThe loopkapi updrift → planproduce → checkloop or parkVenuelocal | serveryour machine + keysor a connected serversame events either way

Transport moves state, the loop produces work, venue is where it runs — three orthogonal concerns.

  • Transportkapi push and kapi pull make a local checkout and a server replica consistent. Pure data movement (Merkle diff, conflicts, terminology hand-off); they never produce translations. The analogy is git push / git fetch.
  • The loopkapi up, the one verb that produces work toward the gates.
  • Venuewhere 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 with a server: block — 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 server: block 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.

merknad

The server venue — the server: block, kapi push/pull/up --local, the continuous catch-up policy, and the server-side Runs surface — is provided by the connected platform, not 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: a decision in the project state store

A machine takes a unit to translated; a person takes it to reviewed. That approval is a workflow decision — who signed off on which exact translation — so it lands in the project's state store, the committed carrier of decisions (defaults.state, default .kapi-state.json), not the content memory.

kapi status --review is the worklist — every translated unit not yet approved, the derived counterpart of the loop's parked outcome — and you approve a unit 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 apply <<<'{"kind":"review","file":"src/nb.json","id":"save.label","locale":"nb","status":"reviewed"}'

The decision is bound to the content hash of the translation it blesses, so editing that translation afterwards drops the unit back below reviewed — an approval can't silently outlive the text it approved. Once recorded, coverage reads the unit as reviewed, reviewed% climbs, and a { reviewed: … } gate flips to shippable.

approvekapi apply · kind: reviewrecords hash(translation)decision + content hash.kapi-state.jsonthe stored hash no longer matchestranslation editedre-derived on the next rununit reads below reviewedre-approve with kapi applybinds to the new hash

An approval binds to the content hash of the translation it blesses: edit the text and the unit falls below reviewed until someone approves the new pair.

The state file is committed with your sources, so the decision 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:"tm" adds a source→target pair to the recycle corpus (defaults.memory_source) for future leverage — it does not promote 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 decision in the state 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":"tm","source":"Save","target":"Lagre","source_locale":"en","target_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.