Bowrain Project Model
A connected project keeps its configuration in the repository and its context on Bowrain, where every checkout and every teammate reads the same terms, voice profiles and decisions. On disk it is a kapi project with a bowrain: block in its recipe: a single kapi.yaml recipe file at the project root and a sibling .kapi/ directory, the same project model the kapi CLI uses.
Directory Structure
my-app/
├── kapi.yaml # the recipe (committed); fixed, conventional filename
├── .mcp.json # agent wiring written by kapi init (committed)
├── .kapi/ # this checkout's cache, ignored as a whole
│ ├── .gitignore # `*`, written by kapi init
│ ├── filters.local.json # personal saved reader filters
│ ├── vault/ # withheld redaction originals (local-only)
│ ├── sync/ # the tree last declared to the server, and the refs
│ └── work/ # free to delete: rebuilt on the next run
│ ├── store.db # this checkout's projection of its working tree
│ └── cache/
│ ├── extractions/
│ └── collections/
├── flows/ # file-per-flow definitions, named by flows_dir (optional)
└── src/
└── locales/
├── en/
│ └── messages.json
└── fr/
└── messages.json
Ownership zones at the project root:
kapi.yaml: hand-edited and committed to git. The recipe holds the project configuration. An axis approved on the server also reaches it: the nextkapi pulleditsdefaults.coordinates, for review in git..kapi/: this checkout's cache, kept out of git.work/store.dbholds the checkout's copy of its working tree: the block cache, the overlays a run wrote and the extraction stamps. It rebuilds from the content files.work/cache/holds files kapi can regenerate cheaply, and you can delete it at any time. You author nothing there: file-per-flow definitions sit in the committed directory the recipe names withflows_dir:, and kapi reads nothing from.kapi/flows/.- The project's context store: kept by kapi in a workspace under your data directory, outside the checkout. It holds the terms, the content memory, the voice profiles and the decision ledger. Every checkout of the project shares it, and every gate and lookup reads from it.
kapi context syncshares it through the backend the recipe declares undercontext:, andkapi store exportwrites it to one file.
A project can answer the same questions as Bowrain over its own context, for one project and one stream. For example, kapi can list which blocks use a given term, by collection and coordinate, with no server.
Recipe schema
The recipe is a YAML document. Bowrain projects layer a bowrain: block (and the optional top-level venue keys hooks, automations, assets and brand_voice) onto the framework's KapiProject schema.
version: v1
name: My App
defaults:
source_language: en
target_languages: [fr, de, ja]
collection: ui/strings
exclude:
- "**/*.test.json"
- "node_modules/**"
voice:
profile: acme-docs
coordinates:
brand: acme # a declared axis, inherited by every collection
collections:
- path: src/locales/**/*.json
format: json
- path: content/docs/**/*.md
format: markdown
target: i18n/{lang}/docs/{path}/{filename}
- name: legal
channel: docs
coordinates:
mode: reference # overrides the project on one axis, inherits the rest
source_only: true # checked, never translated
content:
- path: legal/**/*.md
format: markdown
plugins:
okapi-bridge: "^1.47.0" # map form: name → version constraint
flows:
pseudo:
steps:
- tool: pseudo-translate
config: { method: extended }
# The bowrain: block depends on the bowrain plugin. init declares the
# requirement so a plain kapi binary (without the plugin) fails fast instead of
# silently ignoring the connection.
requires:
bowrain: "*"
# Optional bowrain-server connection. Presence enables push/pull and makes the
# server the default venue for `kapi up`.
bowrain:
url: https://app.bowrain.cloud/my-team/abc123
stream: $auto # auto-detect from git branch / CI
converge: on-push # on-push (default) | manual
# Top-level lifecycle policy:
automations:
- name: pull-after-push
trigger: post-push
actions:
- type: wait_translate
- type: pull
# Top-level asset policy:
assets:
enabled: true
max_size: 100MB
Top-level fields
| Field | Type | Description |
|---|---|---|
version | string | Schema version (currently v1) |
name | string | Project display name |
defaults | object | Project-wide language, context and execution defaults |
collections | list | Content collections (see Content Collections) |
profiles | map | Governance bound per product, keyed by profile name (see Profiles and channels) |
plugins | map | Plugin dependencies as name: version-constraint |
requires | map | Plugin name → version constraint that gates loading; a bowrain: block adds bowrain so a plain kapi binary refuses the recipe |
flows | map | Inline flow definitions |
flows_dir | string | A directory, relative to the recipe, holding one YAML file per flow. No default |
ship_gate | gate | The bar a locale must clear to be shippable (see Gates) |
ship_gates | list | Per-scope ship gates, each a when: selector plus a gate |
source_gate | gate | The coverage bar the source must clear for kapi check --ship |
established_gate, established_gates | gate, list | The bar for a locale to ship approved: a person approved the content |
gates | map | Named gates the rules above may reference |
bowrain | object | Bowrain-server connection coordinates (venue key) |
automations | list | Local automation rules (see Automations) |
assets | object | Asset (image/binary) policy |
brand_voice | object | A venue key the server also accepts for a profile and channel binding; bind the voice with defaults.voice or a profile's voice: instead |
defaults block
| Field | Type | Description |
|---|---|---|
source_language | string | BCP-47 source language (for example en) |
target_languages | list | BCP-47 target languages; empty for a source-only project |
collection | string | Default collection name for organizing content |
exclude | list | Glob patterns to skip during scanning |
formats | map | Per-format default presets and config overrides |
voice | binding | The voice profile every collection is governed by unless a profile binds another. profile: names one in the project's voice store |
coordinates | map | The declared axes every collection inherits, brand and mode among them; the structural axes product and channel are derived from channel: and never written here |
materialize | string | When target files are written from the project store; kapi up --materialize forces on-converge |
bowrain block
Only the connection coordinates sit under bowrain::
| Field | Description |
|---|---|
url | Compound URL: <server>/<workspace>/<project-id> or <server>/projects/<id> |
stream | Server-side stream to sync against; $auto auto-detects from CI / git branch |
converge | Server-side convergence policy: on-push (default) or manual |
Sharing the project's context on Bowrain
When the project's context lives on Bowrain, every checkout and every teammate
reads the same terms, kept and dropped suggestions, and decisions. In the
browser and in Kapi Desktop, Rules shows that record (see
Map, Reader and Rules). From the command line,
kapi context sync keeps it in step with the Bowrain project the bowrain:
block connects to.
kapi share --via bowrain connects the project and leaves the context where
it is, so name the bowrain backend in the recipe's context: block
yourself:
context:
backend: bowrain
bowrain:
url: https://app.bowrain.cloud/my-team/abc123
The context: block takes no other field for this backend. The project comes
from bowrain.url, and the URL must name a workspace project
(<server>/<workspace>/<project-id>). A project has one context, so every
stream reads the same one. A recipe that names the bowrain backend without a
bowrain: block is refused, with the block to add.
The sync signs in as the person kapi auth login stored, or in CI with
BOWRAIN_AUTH_TOKEN and BOWRAIN_SERVER_URL together. The sign-in reaches
only the server it names: a recipe whose bowrain.url names another server
is refused before any request, and so is a CI token with no server URL. The
URL must use https://, except for a server on localhost. Reading the
context needs view access to the
project; sharing changes back needs permission to manage terms.
BOWRAIN_PROJECT_URL points the context at another project, as it does for
kapi push. kapi share names this place Managed on Bowrain.
Settings a push carries
Three recipe settings govern what the server does with the project:
bowrain.converge, defaults.translate_after and the term_rules: the recipe
declares on its flow steps and tool presets. Every push carries all three, at
their effective values, so a key the recipe leaves unset is sent as its default
(on-push, written and no term rules). The push compares them with the values
the server holds and sends only a setting that differs, so an unchanged recipe
costs no extra request and prints nothing.
The server holds its terminology gate and its translations to the recipe's term
rules beside the workspace terms, as kapi check --ship does, so a language
the rules name is governed on both sides and a translation that misses a rule
holds the language back unless the rule is advisory.
The server applies these settings only from a push to the project's default
stream (main unless the project records another). A push to any other stream
carries them, and the server keeps its own values and says so in the push
output.
On the default stream, what a push may change depends on the direction:
| Setting | Strictest to loosest |
|---|---|
translate_after | established, written, none |
converge | manual, on-push |
term_rules | every rule the server holds, kept unchanged, with any added; a rule dropped or changed |
A change toward the strict end applies for anyone who may push the project,
including a workspace member or a CI token with the contribute scope. A
change toward the loose end applies only when the pusher may manage the project
(a workspace owner or admin). For anyone else the server keeps its value, and
kapi push and kapi up print a line such as:
Server setting translate_after stays established: the recipe asks for none, which allows more than the project does. A workspace owner or admin (manage_project) applies it by pushing the recipe.
Every push repeats the line until the recipe and the server agree. The server records each change it applies in the audit log, with the pusher and the value before and after. Its runs read the value it holds.
Lifecycle (hooks, automations) and asset policy (assets) sit at the top level of the recipe, beside bowrain:, because they are project policy.
bowrain:, automations:, assets: and brand_voice: are bowrain recipe extensions. kapi reads them only when the kapi-bowrain plugin is installed, and keeps them unchanged otherwise. kapi init, kapi init-connect and kapi config server.url … declare requires: { bowrain: "*" } whenever they write a bowrain: block. A plain kapi binary without the plugin then refuses the recipe with a "requires the bowrain plugin" error. See C-01: The project model.
Content Collections
Each entry under collections: is a content collection. Bare entries are single-pattern collections; named collections group multiple items together.
You can edit collections: by hand, or with the core kapi commands (no bowrain plugin required; they only touch the local recipe):
kapi add "src/**/*.json" # append a content pattern (format auto-detected)
kapi rm "src/legacy/*.json" # remove the mapping, or add to the exclude list
kapi ls # list the files the content tracks
kapi add "src/**/*.md" --format markdown # pass --format only to override detection
kapi ls --stats # with per-file block and word counts
add/rm/ls are framework commands; sync state (changed-vs-server) is kapi status.
collections:
# Bare entry: single source pattern
- path: src/locales/**/*.json
format: json
# With output path template
- path: content/docs/**/*.md
format: markdown
target: i18n/{lang}/docs/{path}/{filename}
# Per-entry overrides
- path: legacy/**/*.properties
format: java-properties
source_language: en-GB
collection: legacy
# Named collection: its items live under content:, relative to base:
- name: ui
channel: app
base: src
preview:
kind: storybook
url: https://storybook.example.com
content:
- path: "**/*.tsx"
format:
name: exec
config:
command: "vp neokapi-i18n extract --stream"
- path: "i18n/en/*.json"
format: json
Collection fields
| Field | Type | Description |
|---|---|---|
name | string | Collection name; required to bind a channel: |
base | string | The directory this collection lives in; every path, target and item base below is written relative to it |
channel | string | The point in the context space this content sits at: profile/channel, or a bare channel (see Profiles and channels) |
coordinates | map | Declared axes this collection sets, overlaid on defaults.coordinates per axis: a collection moves on the one axis it differs on and inherits the rest |
source_only | bool | This collection has no target language and is never translated: a run reads it, checks it, and writes nothing back |
preview | object | Where this collection's strings can be read in place: kind (storybook) and url (see Review in place) |
content | list | The collection's content items |
collection | string | Collection routing override |
source_language | string | Source language override |
target_languages | list | Target language override |
Content item fields
| Field | Type | Description |
|---|---|---|
path | string | Glob pattern for source files (supports {lang} placeholder) |
format | string / object | File format ID (for example json, html) or object with name/config/preset |
target | string | Output path pattern for target files (supports {lang} and {path}) |
base | string | Directory a matched file's path is made relative to for target-token expansion; defaults to the glob's fixed prefix |
collection | string | Collection routing override for this entry |
source_language | string | Source language override for this entry |
target_languages | list | Target language override for this entry |
assets | object | Per-entry asset policy override |
asset_max_size | string | Per-entry asset max size override |
A bare entry carries path, format and target directly on the collection and has no content: list.
Profiles and channels
Content is written for a point in the context space. Two of its axes are structural: the product it belongs to and the channel it ships on. A key under profiles: is a product, the channels that profile declares are the channels that product ships on, and a named collection names its point with one channel: reference. The declared axes (brand, mode, and any the project adds) come from defaults.coordinates and a collection's own coordinates:.
profiles:
acme:
channels: [app, docs]
voice:
profile: acme-docs
acme-labs:
channels: [app]
voice:
profile: acme-labs
termstore: acme-labs-terms
valid_from: 2026-09-01
collections:
- name: acme-docs
channel: docs # only acme declares it; the bare form resolves
content:
- path: docs/**/*.md
- name: labs-app
channel: acme-labs/app # both declare `app`; qualify it
content:
- path: labs/src/i18n/**/*.kbf.json
| Field | Description |
|---|---|
channels | The channels this product ships on |
voice | The voice profile that governs this product, overriding defaults.voice |
termstore | A terms store, by name, bound for this product's local runs |
concept | A concept reference (term:<id>) the profile carries for display |
valid_from, valid_to | The profile's validity window |
A profile that binds no voice: of its own uses the voice profile the store holds under its name. Profile names and channels are slugs (lowercase letters, digits and hyphens). They are stable identifiers, sent with each push as the content's product and channel coordinates. A bare channel: that two profiles declare is a load error naming both qualified spellings. A collection that binds no channel uses defaults.voice and the project's own terms.
A profile's termstore: applies to local runs only. The server applies the workspace terms instead, and a connected project that binds a terms store per profile warns about this on every run.
Gates
A gate names the rung of the target ladder a scope must reach, and who may have approved it, for the scope to count as shippable.
ship_gatesets the bar for every locale.ship_gatesrefines it per scope with awhen:selector, so a legal collection can wait for a person while a help collection ships on checks.source_gateis the source-side coverage barkapi check --shipevaluates.defaults.translate_afteris the per-block hold before a run translates, andtranslate_after: noneturns it off.
Ship states describes the state the server derives from these.
Tool configuration
A flow step's config: keys are the tool's own schema keys, listed in the
tool reference. The
wording constraint is one key everywhere it applies: term-check, translate
and recycle take term_rules:, a list of rules naming a term, what to use
instead, and whether a violation fails a check. The voice profile carries the same rules
under its vocabulary: section, and a run resolves the terms in force at a
block's point from the profile and the bound terms store.
Format object form
When you need to configure a format (apply a preset, pass options, run a subprocess extractor) use the object form:
collections:
- path: "src/**/*.tsx"
format:
name: exec
config:
command: "vp neokapi-i18n extract --stream"
- path: "docs/**/*.html"
format:
name: html
preset: strict-extraction
Automations
Automations are rules that run automatically at lifecycle points, declared at the top level of the recipe:
automations:
- name: checks-before-push
trigger: pre-push
actions:
- type: run_flow
config:
flow: qa
- type: wait_translate
- name: auto-pull-after-push
trigger: post-push
actions:
- type: pull
Automation fields
| Field | Description |
|---|---|
name | Rule name |
trigger | Lifecycle point: pre-push, post-push, pre-pull, post-pull, pre-flow, post-flow |
actions | List of actions (run_flow, wait_translate, pull, push) |
enabled | Optional boolean (defaults to true) |
Project Discovery
kapi searches for a kapi.yaml recipe by walking up the directory tree (like git):
cd my-app/src/locales/fr/
kapi status # finds kapi.yaml at ../../../kapi.yaml
All commands work from any subdirectory within the project. A directory holds at most one kapi.yaml, so discovery is unambiguous; an explicit -p <path> still overrides it.
Version Control
Commit to git
kapi.yaml: the recipe (single source of truth for configuration)- the agent wiring
kapi initwrote (.mcp.json,.claude/skills/kapi/SKILL.md, and the other hosts' files where it wrote them)
Do NOT commit
.kapi/: kapi init writes .kapi/.gitignore with one line, *, so none of the cache reaches git.
Deleting .kapi/work/ costs a re-extraction and nothing else. Deleting .kapi/ also loses the sync state in .kapi/sync/ and, if the project uses redaction, the withheld originals in .kapi/vault/. Those originals are local-only and cannot be rebuilt. Your context is in the workspace and survives. Deleting the workspace loses every project's terms, voice profiles, content memory and decisions, so before you remove it, push each project's context to its backend or take a copy with kapi store export -o <file>.kpz.
Initialization
Create a new bowrain project:
cd my-app/
kapi init
In interactive mode (default when stdin is a terminal), kapi init presents a guided setup wizard where you can sign in, choose a workspace, and configure your project.
For non-interactive usage (for example CI/CD), use flags:
# Local-only project (no bowrain: block written)
kapi init --source en --targets fr,de,ja
# Connect to a server (anonymous claim)
kapi init --server https://app.bowrain.cloud --anonymous
# Apply a framework preset
kapi init --preset nextjs
# Connect to an existing project
kapi init --server https://app.bowrain.cloud --project abc123
Init flags
| Flag | Description |
|---|---|
--server | Server URL |
--workspace | Create the project in this workspace (slug) |
--project | Connect to an existing project by ID |
--name | Project name (default: current directory name) |
--source | Source locale (default: en) |
--targets | Target locales, comma-separated (for example nb,fr) |
--anonymous | Create a project without signing in |
--email | Create a project and email a link to claim it |
--preset | Apply a framework preset (for example nextjs, react-intl, angular) |
kapi init writes:
kapi.yamlrecipe at the project root (with abowrain:block when a server was supplied).kapi/, the checkout's cache, with a.gitignoreof*that keeps the whole directory out of version control
Server Connection
The block's url field is a compound URL that encodes the server address, workspace, and project ID:
bowrain:
# Workspace project
url: https://app.bowrain.cloud/my-team/abc123
# Direct project (no workspace)
# url: https://app.bowrain.cloud/projects/abc123
stream: $auto
Once connected, you can sync with the server:
kapi push # Upload local changes to the server
kapi pull # Fetch results and recipe changes from the server
kapi status # Coverage, ship standing, and the server delta
The active server URL is resolved from (first match wins):
url:under the recipe'sbowrain:block--serverflagBOWRAIN_SERVER_URLenvironment variable /server.urlin the per-machine bowrain config- Existing auth state (from
kapi auth login) - The hosted service (
https://app.bowrain.cloud): commands that contact a server fall back to it; self-hosted deployments configure one of the above