Gå til hovedinnhold

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 up drives 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 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, 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.

1drafta machine placeholder, or a target whose source has moved2translateda real translation exists and passes its checks3approveda person reviewed or edited this exact pair

Target lifecycle: how far a translation has progressed.

Source content has its own, shorter ladder:

1writtensource content exists2approveda person reviewed the source

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:

StateWhat it means
approvedThe locale clears its approval gate (established_gate / established_gates): a person approved the content.
translatedThe locale clears its ship gate and no approval gate: translated, with its checks green, and shipped as AI translation.
withheldThe 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 gatedNo 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 sourceterm-check + protectvoice checksource checkssource holdtranslate_afterhold: settle your source firsttranslate approved sourcerecycle (memory-first)AI remaindertarget checkstarget ship-gateship_gatewait: needs reviewconverged

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:

  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 blocks onto their ladder (written → approved) and marks any that fail.
  2. Hold the source. defaults.translate_after (written by default, or established for 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.
  3. 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.
  4. Hold at the target ship-gate. A locale that clears its ship_gate is 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
producekapi up: default flow over pending blockseach pass re-derivescoverage and repeatsa person / agentpromotekapi apply: decide approves a blockgate 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 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.

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

Transportkapi push / pullmove project stateMerkle diffno translationThe loopkapi updrift → planproduce → checkloop or wait for reviewVenuelocal | 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. They move data only (a diff, conflicts, terminology hand-off) and never produce translations, like git push and 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. 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.

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, 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.

approvekapi apply · deciderecords the translation and the source it was approved fordecision + both revisionsthe project's storea recorded revision differseither side editedre-derived on the next runblock reads below approvedre-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 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.