kapi.yaml Project File Format
Implementation notes for the kapi.yaml project file format. See C-01 for the architectural decision and the project file reference for every key with its type.
Schema
The kapi.yaml recipe is a YAML document parsed by core/project.KapiProject:
type KapiProject struct {
Version string `yaml:"version"`
ID string `yaml:"id,omitempty"` // the stable identity; see Project identity
Name string `yaml:"name,omitempty"` // the label; free to change
Plugins map[string]PluginSpec `yaml:"plugins,omitempty"` // name → spec (scalar = version short form)
Defaults Defaults `yaml:"defaults,omitempty"` // project-wide defaults (locales live here)
Collections []Collection `yaml:"collections,omitempty"`
Preset string `yaml:"preset,omitempty"`
Flows map[string]*flow.StepsSpec `yaml:"flows,omitempty"`
Profiles map[string]Profile `yaml:"profiles,omitempty"` // profile name → governance (see The context space)
// Convergence gates (see the reference page, "Ship gates").
ShipGate gate.Gate `yaml:"ship_gate,omitempty"`
ShipGates []ShipGateRule `yaml:"ship_gates,omitempty"`
Gates map[string]gate.Gate `yaml:"gates,omitempty"`
VerifiedGate gate.Gate `yaml:"verified_gate,omitempty"`
VerifiedGates []ShipGateRule `yaml:"verified_gates,omitempty"`
SourceGate gate.Gate `yaml:"source_gate,omitempty"`
Requires RequiresMap `yaml:"requires,omitempty"` // plugin name → semver constraint
Extras map[string]yaml.Node `yaml:",inline"` // unknown keys (extensions)
}
// Defaults holds project-wide processing defaults, including locales.
type Defaults struct {
SourceLanguage model.LocaleID `yaml:"source_language,omitempty"`
TargetLanguages []model.LocaleID `yaml:"target_languages,omitempty"`
Flow string `yaml:"flow,omitempty"` // the flow `kapi up` runs
Materialize string `yaml:"materialize,omitempty"` // "manual" (default) or "on-converge"
Jobs int `yaml:"jobs,omitempty"` // target languages converged concurrently
SourceGate string `yaml:"source_gate,omitempty"` // authored | checked (default) | approved | none
LocaleFormat string `yaml:"locale_format,omitempty"`
Concurrency int `yaml:"concurrency,omitempty"`
ParallelBlocks int `yaml:"parallel_blocks,omitempty"`
Encoding string `yaml:"encoding,omitempty"`
Formats map[string]FormatDefaults `yaml:"formats,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
Merge MergeDefaults `yaml:"merge,omitempty"`
Memory MemoryDefaults `yaml:"memory,omitempty"`
Segmentation SegmentationDefaults `yaml:"segmentation,omitempty"`
Annotations AnnotationDefaults `yaml:"annotations,omitempty"`
Redaction *RedactionSpec `yaml:"redaction,omitempty"`
Comments CommentDefaults `yaml:"comments,omitempty"` // directives and channel for every declared comment
Voice *VoiceBinding `yaml:"voice,omitempty"`
Coordinates map[string]string `yaml:"coordinates,omitempty"` // the declared axes of the default point
TermsSource string `yaml:"terms_source,omitempty"`
MemorySource string `yaml:"memory_source,omitempty"`
Tools map[string]map[string]any `yaml:"tools,omitempty"` // per-tool presets
Locales map[string]LocaleDefaults `yaml:"locales,omitempty"` // per-target-language presets
Extras map[string]yaml.Node `yaml:",inline"`
}
// Collection is either a bare entry (path/format/target) or a named collection
// (name + content), and can carry its own source/target languages.
type Collection struct {
Name string `yaml:"name,omitempty"`
SourceLanguage model.LocaleID `yaml:"source_language,omitempty"`
TargetLanguages []model.LocaleID `yaml:"target_languages,omitempty"`
Content []ContentItem `yaml:"content,omitempty"`
Base string `yaml:"base,omitempty"` // the directory this collection lives in
Channel string `yaml:"channel,omitempty"` // `profile/channel` (named collections only)
Coordinates map[string]string `yaml:"coordinates,omitempty"` // declared axes; overlays defaults.coordinates per axis
SourceOnly bool `yaml:"source_only,omitempty"` // no target language; read and checked, never written
// Bare-entry fields (short form):
Path string `yaml:"path,omitempty"` // doublestar glob for source files
Format *FormatSpec `yaml:"format,omitempty"` // format ID; auto-detect per file if empty
Target string `yaml:"target,omitempty"` // output path template (tokens below)
Extras map[string]yaml.Node `yaml:",inline"`
}
// ContentItem additionally carries its own `base` (yaml:"base,omitempty"), the
// directory its matched paths are made relative to for target-token expansion,
// its own `channel`, a per-item `redaction`, and `comments` (ContentComments:
// `true`, or a mapping carrying `directives`, `channel` and `only`).
// Profile binds governance to one product and declares its channels.
type Profile struct {
Channels []Channel `yaml:"channels,omitempty"` // a slug, or {id, concept}
Voice *VoiceBinding `yaml:"voice,omitempty"` // same forms as defaults.voice
TermStore string `yaml:"termstore,omitempty"` // a standalone terms store, project-relative
Concept string `yaml:"concept,omitempty"` // display only; never resolved
ValidFrom string `yaml:"valid_from,omitempty"`
ValidTo string `yaml:"valid_to,omitempty"`
}
Flow definitions reuse core/flow.StepsSpec and core/flow.FlowStep (see flow-steps-format).
Content model
Collections is a list of Collection values. Each entry is one of two
shapes, distinguished by Collection.IsBareEntry():
- Bare entry: has a
pathand nocontent. Thepath,format, andtargetfields are promoted onto the collection directly. Use this for a single glob with no grouping. - Named collection: has a
nameand a non-emptycontentlist ofContentItem, and may set its ownsource_language/target_languages. Use this to group related patterns and scope languages per group.
A collection with source_only: true declares that it has no target language:
a run reads it, checks it, and writes nothing back. Naming no target already
makes a collection source-only; the flag is the difference between meaning it
and forgetting. Validate rejects a collection that sets it and also carries a
target, in either spelling (collection or item, target: or
target_languages:).
Where a collection lives
Collection.Base is the directory the collection lives in. EffectiveItems
folds it in: every item's Path, Target and own Base is joined onto it
(JoinBase), so every consumer downstream sees project-relative paths and never
has to know a base was declared. An absolute path is left alone, so the escape
check downstream still sees it; an empty one stays empty.
An item that declares no Base of its own keeps none; the target tokens then
relativize against the joined pattern's own fixed prefix. That is what makes
base: a location rather than a second relativization root.
The context space
Content is written for a point in the context space. Two 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 lists are the
channels that product ships on, and a named collection names the point its
content sits at with one channel: reference:
profiles:
northsea:
channels: [cli, docs]
voice: .kapi/voice.yaml # the project's default voice
acme:
channels:
- id: docs
concept: term:9a1c0f42b7 # display only; never resolved
- app
voice: .kapi/profiles/acme/voice.yaml # == the conventional location
termstore: .kapi/profiles/acme/terms.json # optional; the project's own store otherwise
collections:
- name: acme-app
channel: acme/app
- name: northsea-docs
channel: northsea/docs # both declare `docs`, so qualify it
The map key under profiles: is the profile's name: the product-axis value its
collections carry, and the directory under .kapi/profiles/<name>/ holding the
files it overrides. A profile that binds neither a voice nor a vocabulary is
still a profile: that directory is the binding, and a project keeping its
overrides there should not have to restate every one of them in the recipe.
Profile names and channels are slugs (^[a-z0-9][a-z0-9-]*$): stable
machine identifiers, never translated, comparable byte for byte. A profile and a
channel may each carry a concept for display, but resolution never looks at
it: concepts are designed to be renamed and deprecated as vocabulary is revised,
and governance that moved when someone edited a term would be governance nobody
could rely on.
Resolution is by declaration, not by matching. KapiProject.ResolveChannel
reads one channel: reference, always the qualified profile/channel. A bare
channel name is an error that spells out the qualified form(s). The result
is a ChannelRef{Profile, Channel}, whose zero value is the project's default
point.
After a profile is selected, the collection's channel selects the override
inside that profile's voice (profile.VoiceProfile.Channels,
C-07), so a landing-page register
is authored once beside the voice it varies rather than duplicated into a voice
file per product-and-channel pair. A channel the profile declares no override
for is not an error: the base voice applies, which is the right answer for a
voice that reads the same everywhere.
A voice profile can declare shared constraints beside tone and style:
constraints:
- id: service/no-unsupported-assurance
version: 1
source: service-facts.md#assurances
statement: Do not promise a risk-free service.
kind: prohibited_pattern
regex: '(?i)\b(risk-free)\b'
- id: service/recording
version: 1
source: service-facts.md#recording
statement: Appointments are not recorded.
kind: guidance
The critical pattern remains applicable when a channel replaces its style
section. The guidance statement is passed to writers and is explicitly outside
deterministic semantic verification. kapi context exposes resolved constraint
records and the full declared coordinate point in JSON. The text form includes
the same constraint guidance and coordinates.
scope can name exact locale, channel and persona values; populated values
must all match. An exceptions entry needs a nonempty scope, a reason,
approved_by and approval_ref. Approval provenance in a local file is asserted
by the author. Custom recipe coordinates are shown in context answers but do
not act as constraint predicates. See C-07
for resolution, validation and store-update semantics.
The remaining axes are declared. defaults.coordinates names the axes the
project's content sits at unless a collection says otherwise, and a collection's
own coordinates: overlays it per axis. project.MergeCoordinates(defaults, derived, declared) is the rule: most specific wins, per axis, and an empty
value never erases a broader layer. product and channel are refused under
coordinates: by project.DeclarableAxis, because they are derived from
channel:. project.BrandAxis (brand) and project.ModeAxis (mode, with
the Diátaxis values tutorial, how-to, reference, explanation as
conventions) are the two declared axes the framework spells; any other name is
valid (C-02).
KapiProject.ResolveGovernance(collection) resolves a collection name into a
ResolvedGovernance (channel, voice binding, TermStore, the profile's name,
and the recipe key the voice came from), falling back to the project defaults
for an empty or unknown collection name, and for a collection that binds no
channel; ItemForPath(relPath) names the content item that claims a file, the
first in recipe order whose pattern matches it, and CollectionForPath(relPath)
the collection that item sits in. ProjectContext.ResolveContent applies the
same rule when it expands the recipe into files, so a file resolves to one item
whichever direction the question is asked from. The name keeps its
distance from profile.ResolveContext, which is a different thing in a package
used alongside this one: the input to profile resolution, not the recipe's
answer.
That is the recipe half, and it is an authoring half: the voice it names is
loaded by the host and then handed to profile.ResolveProfileFromContext as
CollectionProfile, the collection tier of the framework's single precedence
chain (C-07), so an
explicit per-call profile still outranks the recipe and a project governed from
a venue ranks its bindings identically. The point's channel goes in beside it as
CollectionConfig[PropertyChannel], and ResolveProfile applies the override.
ChannelRef.Coordinates() renders the structural point as the two axes that
travel on the sync wire, project.ProductAxis ("product") carrying the
profile name and project.ChannelAxis ("channel") carrying the channel, and
the default point renders as nil. The entry a push carries for a collection is
the merged point (structural plus declared axes), its voice binding, and its
preview host if the venue extension declares one, so both venues resolve the
same voice for the same content.
What does not cross is a profile's termstore:. That is a path into the local
project, and a path means nothing to a venue that governs terminology from a
shared vocabulary. A recipe that binds a terms store per profile
(KapiProject.BindsTermsByProfile) and also binds a venue is warned at run time
(host.WarnUnsyncedCoordinates, called by kapi run, kapi up and
RunFlowAllLocales) that the binding applies to local runs only. The run
proceeds; this is a caveat, not a fault.
One function resolves governance for every surface:
KapiProject.ResolveGovernanceFor(GovernancePoint{Profile, Collection, Path, Comments, At}).
It walks the declared bindings finest-first (a content item's own channel:,
then its collection's, then the project default), skipping any whose profile is
outside its validity window at At, and records the skip on
ResolvedGovernance.Fallback so the caller can report it. A point with
Comments set names the comments in the file at Path, and its walk starts
with two rungs of its own: the claiming item's comments.channel, then
defaults.comments.channel. ResolveGovernance
and ResolveGovernanceForPath are the as-declared views over the same walk (a
zero At applies no window); ResolveGovernanceAt is the as-of view.
A run resolves per FILE and executes once per distinct resolution:
groupInputsByBinding (host) partitions the input set through that function,
and each group gets its own bindings and its own tool chain. The chain is built
before any content is seen, so the partition is what makes per-file governance
possible. Grouping keys on what the points resolve to, not on the points
themselves, so two collections governed by one profile share a group, and a
recipe where nothing binds a channel produces exactly one group: the single,
unsplit run.
The instant is fixed once per run (App.GovernanceInstant, shared by every
converge worker), so a long pass cannot cross a validity boundary halfway
through. The first fall-through per run is printed on stderr as
governance: profile "x" expired <date>; governing with …, deduplicated by
App.NoteGovernance; kapi context search carries the same sentence in its
result notes.
Every failure is caught at load, because a silent fall-back would translate that content in a plausible-looking wrong voice: a non-slug profile name or channel, a channel declared twice by one profile, a profile voice binding that names no source or more than one, a malformed concept reference, a channel reference naming an undeclared profile or channel, and any bare (unqualified) reference. Bare entries cannot carry a channel at all: resolution is by collection name, so a point on an unnamed entry could never be read.
The recipe's coordinate surface is writable through kapi apply with
kind: "recipe": defaults.coordinates.<axis> (one axis per entry; an empty
value withdraws it) and collections.<name>.channel go through
project.SetField, which applies the same refusals and preserves the recipe's
formatting.
KapiProject.IterateContent walks both shapes uniformly, yielding each
ContentItem, base already folded in, paired with its parent collection so
callers can resolve fall-through fields. Language resolution falls through item →
collection → project defaults via ContentItem.ResolvedSourceLanguage /
ResolvedTargetLanguages. A bare entry's promoted fields are wrapped as a
single-item slice by Collection.EffectiveItems, carrying its Extras
through so per-item extension fields survive.
Defaults-scoped settings
Defaults holds project-wide processing settings that individual content items
can override. Beyond locales and the parallelism/encoding knobs shown above:
flow(string): the flowkapi upruns, a built-in name or a key inflows:. Empty meanskapi runrequires an explicit flow.materialize(manual|on-converge): whether the convergence loop owns delivery of the target-language files.manual, the default, leaves delivery tokapi mergeorkapi up --materialize;on-convergewrites a locale's files only when its gated scopes are all shippable.jobs(int): how many target languages onekapi uppass converges concurrently;up --jobsoverrides per run.source_gate(authored|checked|approved|none): the source status a block must reach before its translations are produced;checkedis the default applied when unset.merge(MergeDefaults.ConflictPolicy): howkapi mergeresolves a translator's target against an existing on-disk target or content-memory entry (translator-winsdefault,existing-wins,newest-wins). See M-01.memory(MemoryDefaults): the project's content memory:fuzzy_threshold, the pre-fill cutoff onkapi extract(defaultDefaultFuzzyThreshold= 75).segmentation(SegmentationDefaults): opt-in SRX sentence segmentation overlay on extract (source, optionalsrxrules file).annotations(AnnotationDefaults): which of a block's stand-off annotations a writer draws into the document as inline marks; zero leaves each format's own declaration standing.redaction(*RedactionSpec): replace sensitive content with protected placeholders before processing and restore it afterwards. Overridable perContentItem.Redaction.voice(*VoiceBinding): bind a voice profile (one ofprofile_file,profile, orpack, or a bare path) as standing project context.coordinates(map): the declared axes of the project's default point (see above).tools(map of tool name to config): project-level tool presets, applied wherever the tool runs in a project flow; a flow step's own config overrides per key.locales(map of locale to{tools}): per-target-language presets that merge on top oftoolsand under a step's own config.terms_source/memory_source(string): committed, git-tracked native source bundles (.terms.json/.memory.json) the project's terms store and content memory are indexed from.kapi applyedits the source and reindexes it, so the source is written by exactly one path andgit diffis the review surface. Both keys bind any path; the conventional homes are inside the committed context graph.terms_sourceleft unset falls back to<root>/.kapi/terms.json, then<root>/terms.json;memory_sourcehas no such fallback, because a project has one terms source but many memory bundles (one per content surface), leaving nothing single for a convention to name.
The project store
The recipe binds sources; the sources are the truth. A project keeps one local
database, .kapi/work/store.db: a derived index over the committed sources
(terms_source, memory_source, the voice profiles), the unit-state record
under .kapi/state/, and the content files themselves, plus the working set of
unit state staged since the last kapi commit. Every subsystem's tables live in
that one file: block cache, terms store, content memory, voice store, working
set, and the property graph. See
C-03 for the store's
shape and its rebuild guarantees.
Extensions and the venue
The framework knows nothing about a plugin's keys. Unknown YAML keys land in
Extras map[string]yaml.Node (with yaml:",inline") on KapiProject,
Defaults, Collection, and ContentItem. A plugin decodes its own typed
schema from these maps via GetExtra and re-encodes on SetExtra;
round-tripping a recipe through the framework alone preserves the keys verbatim.
A recipe with no such extension is a pure local project. The kapi CLI tolerates
unknown blocks but ignores them; the owning plugin decodes them from Extras.
An unknown key that is one edit away from a known field of the same struct is
reported by KapiProject.KeyWarnings with the field it resembles, so a typo
such as source: for source_language: does not load silently as a
monolingual project. requires: (a map of plugin name → semver constraint)
gates loading: a recipe declaring requires: { myplugin: "^1.0" } refuses to
load in a binary that has not registered the myplugin extension.
One of those keys may be the project's convergence venue: a server that
holds the content memory, runs the loop on organisation keys, and carries a
review queue. The framework's interest in it is exactly two fields, url: and
converge:, so it asks the registry which extension is the venue rather than
looking for a key by name: an Extension registered with Venue: true claims
that role, and a plugin manifest declares it as "venue": true on a schema
extension. KapiProject.Venue() returns the VenueBinding{Key, URL, Converge}
of the first registered venue extension the recipe carries, VenueKey() names
the registered venue key with no recipe in hand (so a message about an absent
block can still name the block to add), and IsVenueKey(name) tests one key. An
unregistered key of the same name (a recipe loaded by a binary without the
plugin) reports no venue and no opinion. The venue client (host/venue/schema)
also registers preview at collection scope, decoded only when the venue key
is present. See C-01 for
the full extension model.
Validation Rules
versionis required, must be"v1". A top-level key the recipe does not have (content:,coordinates:) is rejected by name with a hint at the key that carries the intent, rather than captured as an unknown extension.idis optional. When set,ValidateIDrequiresprj_followed by 20 to 64 characters froma-z2-7, which is whatNewIDmints (128 random bits as lowercase RFC 4648 base32, 26 characters).- For each
collections[]entry:- Bare entry:
pathis required andcontentmust be empty. - Named collection:
pathmust be empty (usecontent) andcontentmust be non-empty; each item requires a non-emptypath. channelrequires aname, and must resolve against the declared profiles as qualifiedprofile/channel.source_only: trueis rejected when the collection or any item also carries atargetortarget_languages.coordinatesmay not nameproductorchannel.- An item's
commentsistrue,false, or a mapping whose keys aredirectives,channelandonly. Each directive, there and underdefaults.comments.directives, is a marker that is not empty, starts with no whitespace, holds no line break and is declared once; an item's list may not repeat one the defaults declare. The error names the key, such ascollections[0].content[1].comments.directives[0]. - An item's
comments.channelanddefaults.comments.channelresolve at load as a collection'schanneldoes, and the error names the key. - An item that sets
comments.onlymay not also carry atarget,target_languages, aredaction, or aformat.configorformat.preset(ContentItem.validateCommentsOnly). The error names the item, such ascollections[1].content[0]: comments.only is set, so the item cannot have a target.
- Bare entry:
- Every
profiles:key is a slug, and so is every channel it declares; a channel is declared at most once per profile. A profile'svoiceis shape-checked exactly likedefaults.voice(one ofprofile_file,profile,pack, or a bare path), aconcepton the profile or on a channel must be whitespace-free, andvalid_from/valid_tomust parse as a date or an RFC3339 instant. defaults.merge.conflict_policy,defaults.memory.fuzzy_threshold(0..100),defaults.redaction.detectors,defaults.materializeanddefaults.voiceare each shape-checked.defaults.source_gateis read by the runner, which appliescheckedwhen it is unset.- Each flow must have at least one step
- Each step must have a non-empty
toolfield (unless it usesparallel) - Steps with
parallelcan omittool(the parallel branches provide tools) - Each
requires:entry must have a non-empty plugin name and a well-formed semver constraint (^1.0,>=1.4.0,1.4.0,~1.4.2, or*). UnlessSkipRequiresCheckis set, every named plugin must have a registered extension group, else loading fails with an install hint. - Extras at each scope are validated against any registered extension schema.
Note: name is optional (yaml:"name,omitempty"); the framework does not
require it. KapiProject.Identity() reads id first and falls back to name,
and that is the value every project-scoped key is derived from, starting with
the context graph's scope (host.ProjectScope). project.SetField(proj, "id", …) is the one writer: it refuses an empty value and refuses to replace an id
the recipe already carries, so an applied change-set cannot re-key a project.
File Paths
- Content patterns are expanded via
core/project.ExpandGlob, backed bygithub.com/bmatcuk/doublestar/v4; recursive**directory matching is supported (e.g.src/**/*.json).ExpandGlobfilters out any match that matches one of thedefaults.excludeglob patterns (matched withdoublestar.Match). A lookup that starts from a path applies the same patterns:KapiProject.ItemForPathclaims no excluded path, so a named excluded file sits at the project's default point - Content resolution also skips a file the project's ignore rules match:
.kapiignore,KAPI_IGNOREand the default rules (core/ignore). AKapiProjectholds no project directory, so the hosts apply those rules where they turn a named path into a point (host.ProjectIgnores): a named ignored file sits at the project's default point and in no collection, forkapi check,kapi voice guide,kapi context, a run's bindings and the desktop context panes - Patterns are resolved relative to the project root (the recipe's parent directory)
targetis expanded per source file and target language bycore/project.ResolveTargetPath(itemPath, base, target, source, lang):basehere is the item's own base, the directory the source path is made relative to, after the collection'sbasehas been folded in. When empty it defaults toGlobFixedPrefix(path), the literal prefix of the glob before the first*/?/[/{(soinput/docs/*.mdmirrors just filenames whileinput/**/*.md, or an explicitbase, mirrors the subtree).- Tokens:
{lang},{relpath}(rel path with extension),{path}(rel path without extension),{dir},{filename},{name}(alias{basename}),{ext}; a bare*is shorthand for{name}.{lang}is handled byResolvePathPattern; the rest byExpandTemplate. - Directory-mirror form: when the target (after
{lang}expansion) ends with/, is empty, or its final segment has no extension and no wildcard/token, it denotes a directory: the source's{relpath}(underbase) is appended. Sotarget: output/{lang}mirrors the source tree under each per-language root with no token and no doubled extension. SeeisDirectoryTargetincore/project/path.go.
Credential Resolution
The kapi.yaml recipe references AI providers by type (e.g., provider: anthropic), not by key. API keys are resolved at runtime:
- OS keychain via
host/credentials.Store(non-secret config atproviders.jsonunder the user config directory:~/.config/kapion Linux,~/Library/Application Support/kapion macOS;kapi config pathprints the resolved location. Keys live under the keychain service"kapi") - Environment variables (
ANTHROPIC_API_KEY,OPENAI_API_KEY) or the--api-keyflag - The
--providerand--modelCLI flags override project defaults
CLI Integration
# One-shot (no project)
kapi translate -i file.json --target-lang fr
# With project file: run a built-in flow with project defaults
kapi run translate-qa -p kapi.yaml --target-lang de
# Or run a flow defined in the recipe's flows: map (here named "translate")
kapi run translate -p kapi.yaml
Built-in flows are registered in host/flowdef.BuiltInFlows; the
kapi run reference lists them. A recipe's flows:
map adds flows, and a built-in name wins over a recipe flow of the same name in
kapi run, so a recipe flow that should not shadow a built-in takes a name of
its own.
With -p:
- The flow name is matched against the built-in flows first; if it is not one of
those, it is looked up in the project's
flowsmap (and finally the plugin fallback) defaults.source_languageanddefaults.target_languages[0]provide defaults (CLI flags override)- For single-file flows,
--inputselects the file. The project'scollectionsdescribe which fileskapi extract/kapi mergeoperate on across the project
Desktop Integration
Kapi Desktop at apps/kapi-desktop/:
- Opens a project by its folder (which contains
kapi.yaml): File > Open, drag-and-drop - Edits flows inline (steps editor)
- Resolves content patterns against the filesystem via
App.MatchContent(tabID), using the samecore/projectglob expansion the CLI relies on forextract/merge; pattern resolution is shared framework code, not a desktop-only feature - Stores recent files (
recent.json) and settings (settings.json) in its own config root (~/.config/kapi-desktopon Linux,~/Library/Application Support/kapi-desktopon macOS), overridable withKAPI_DESKTOP_CONFIG_DIR(apps/kapi-desktop/backend/paths.go)
Example Files
Minimal
version: v1
name: Quick Translate
Full
version: v1
name: Acme App
defaults:
source_language: en
target_languages: [fr, de, ja]
concurrency: 4
parallel_blocks: 3
encoding: utf-8
exclude:
- "**/*.generated.json"
merge:
conflict_policy: translator-wins
memory:
fuzzy_threshold: 75
segmentation:
source: true
coordinates:
brand: acme
terms_source: .kapi/terms.json
memory_source: .kapi/memory/memory.json
profiles:
acme:
channels: [app, marketing]
voice: .kapi/voice.yaml
collections:
# Bare entry: single glob, languages inherited from defaults.
# Directory-mirror target: src/i18n/en/app.json → src/i18n/{lang}/app.json.
- path: "src/i18n/en/*.json"
target: "src/i18n/{lang}"
# Named collection: groups patterns, scopes languages, binds a channel, and
# names the directory it lives in. Paths and targets below are relative to it:
# marketing/en/docs/api.md → marketing/fr/docs/api.md.
- name: Marketing
channel: acme/marketing
coordinates:
mode: how-to
target_languages: [fr, de]
base: marketing
content:
- path: "en/docs/**/*.md"
target: "{lang}/docs"
- path: "en/site/**/*.html"
target: "{lang}/site"
# Source-only: read and checked, never translated.
- name: packaging
channel: acme/app
source_only: true
content:
- path: "packaging/nfpm.yaml"
preset: nextjs
requires:
okapi-bridge: ">=1.47.0"
flows:
translate:
steps:
- tool: translate
config:
provider: anthropic
full-pipeline:
steps:
- tool: recycle
config:
fuzzyThreshold: 75
- tool: translate
config:
provider: anthropic
- tool: qa
pseudo:
steps:
- tool: pseudo-translate
config:
expansionPercent: 30