Gå til hovedinnhold

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 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 and 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

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.

1drafta machine placeholder, or a target whose source has moved2translateda 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 voice & 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 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:

StateWhat it means
shippableA ship gate matches the scope and the scope clears it.
withheldThe 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 gatedNo 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 sourceterm-check + protectvoice checksource checkssource ship-gatesource_gatehold: settle your source firsttranslate approved sourcerecycle (memory-first)AI remaindertarget checkstarget 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 voice profile, run the source checks. 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: settling the source first is cheaper than 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. 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.
  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 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
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) 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.

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 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:

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.

  • Transport: kapi 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 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.

merknad

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.

approvekapi apply · kind: reviewrecords hash(translation) + hash(source)state record + both hashes.kapi/state/*.jsonla stored hash no longer matcheseither side editedre-derived on the next rununit reads below reviewedre-approve with kapi applybinds to the new pairing

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.