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"`
FlowsDir string `yaml:"flows_dir,omitempty"` // one YAML file per flow, relative to the recipe
Profiles map[string]Profile `yaml:"profiles,omitempty"` // profile name → governance (see The context space)
Places map[string]place.Place `yaml:"places,omitempty"` // place id → a place the project declares (core/place)
Axes map[string]axis.Spec `yaml:"axes,omitempty"` // axis name → how a declared axis compares values
// 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"`
EstablishedGate gate.Gate `yaml:"established_gate,omitempty"`
EstablishedGates []ShipGateRule `yaml:"established_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
TranslateAfter string `yaml:"translate_after,omitempty"` // written (default) | established | 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
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`, its own `coordinates` (declared axes refining the
// collection's point for the files it matches), 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 terms store, by name
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]
# no `voice:`; the profile the store holds under "northsea" answers
acme:
channels:
- id: docs
concept: term:9a1c0f42b7 # display only; never resolved
- app
voice:
profile: acme-app # a profile in the project's voice store
termstore: acme-terms # optional, a store name; the project's own terms 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 name the project's voice store answers under when the
profile binds no voice: of its own. A profile that binds neither a voice nor a
vocabulary is still a profile, governed by the project defaults.
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 sets the project's
values on these axes. A collection's own coordinates: overlays the defaults
per axis, and a content item's coordinates: overlays the collection's for
the files the item matches. Every caller computes a point's coordinates with
KapiProject.CoordinatesAt(pt, rc). It merges four layers in order with
project.MergeCoordinates: the defaults, the axes derived from the
governance resolved at the point, the collection's coordinates, and the
coordinates of the item that claims the file. Per axis, the most specific
layer wins, and an empty value leaves the broader layer's value in place. 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).
A declared axis is an open category unless the top-level axes: block types
it. KapiProject.Axes maps an axis name to an axis.Spec (core/axis):
type Spec struct {
Kind axis.Kind `yaml:"kind,omitempty"` // category (default) | geo | range | ordered
Label string `yaml:"label,omitempty"`
Values []string `yaml:"values,omitempty"` // closes a category axis; orders an ordered one; refused on geo and range
Labels map[string]string `yaml:"labels,omitempty"` // display labels per value; refused on geo
Unit string `yaml:"unit,omitempty"` // range: what the numbers count
}
An unknown key under an axis fails the load, and the error names the key. A misspelt key therefore cannot leave an axis untyped.
A geo axis holds place ids. KapiProject.Places declares the project's own
places beside the built-in ones (core/place,
C-12):
type Place struct {
Name string `yaml:"name,omitempty"` // display name; defaults to the id
Names map[string]string `yaml:"names,omitempty"` // display names per BCP 47 locale
Type place.Type `yaml:"type,omitempty"` // subdivision | territory | metro | locality | district | neighbourhood | postal-area | area
Within []string `yaml:"within,omitempty"` // one id or a list
Of []string `yaml:"of,omitempty"` // one id or a list
Refs []string `yaml:"refs,omitempty"` // wikidata:Q585, geonames:3143244, nuts:NO081, osm:relation/406091, wof:101752073
Centroid *place.LatLon `yaml:"centroid,omitempty"` // [lat, lon]
Shape string `yaml:"shape,omitempty"` // project-relative GeoJSON file, for drawing
}
The map key is the place's id. An unknown key under a place fails the load.
An empty type is territory for a place with of: and area for one with
within:.
KapiProject.Gazetteer() returns the built-in places plus the declared ones.
The gazetteer is built when the recipe is validated. KapiProject.AxisSpace()
returns axis.NewSpace(p.Axes, p.Gazetteer()) and is nil-safe. Every scope
match calls Space.Contains(axis, outer, inner). A rule scoped to
market: nordics therefore holds at market: no, and one scoped to
market: no holds at a city the recipe declares within no-03.
KapiProject.DeclaredAxisNames() lists every declared axis the recipe names,
whether axes: types it or a coordinate uses it, including an item's
coordinates.
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 names a store on this
machine, and a local store 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, and the
run proceeds.
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 a recipe operation in
kapi apply: defaults.coordinates.<axis> (one axis per operation; 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, and keeps a held-back locale's drafts in the workspace home (core/workhome) until a delivery writes them.jobs(int): how many target languages onekapi uppass converges concurrently;up --jobsoverrides per run.translate_after(written|established|none): the source status a block must reach before its translations are produced;writtenis the default applied when unset. Any other value fails the load. A recipe that still writesdefaults.source_gatefails to load and namesdefaults.translate_after; the top-levelsource_gatecoverage bar is a separate key.merge(MergeDefaults.ConflictPolicy): whatkapi mergedoes with a returned unit whose translation changed in the project since the extraction, which the change service refuses stale namingif_match(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 as standing project context.profilenames one in the project's voice store andpacka built-in starter pack; a scalar that is not a path is the short form ofprofile. A binding that names a file (profile_file:, or a scalar path) is rejected at load with the import that reads the file.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.- No key names a context file.
terms_sourceandmemory_sourceare rejected at load (retiredDefaultsKeys) with the import that reads the file.kapi store importreads a layout'sterms.json, itsmemory/bundles and its voice profiles into the stores, and binds the imported voice underdefaults.voicewhen the recipe binds none (project.BindVoice, a text insertion that leaves the rest of the file as written).
The project store
A project keeps two databases. .kapi/work/store.db is the checkout's
projection of its working tree: the block cache, the overlays a flow wrote, the
extraction stamps, all derived from the content files. The project's context
store, in the user's workspace and shared by every checkout, holds the terms,
the content memory, the voice profiles and the decision ledger, and nothing
reproduces a row in it. The property graph sits in the workspace database
beside the project registry, the operation log, the widened rules and the agent
sessions. See
C-03 for the stores'
shape and their rebuild guarantees, and
Workspace storage for the files and tables.
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.placesbuilds the project's gazetteer (place.New). Each id is a slug that no built-in place uses. Each place sets exactly one ofwithin:andof:, and every id listed there is a place. Thewithin:andof:links contain no cycle.typeis a declarable type, andnamesare keyed by locale. Each entry inrefsisscheme:value, with a known scheme and a value in that scheme's format.centroidis a valid latitude and longitude.shapeis a.geojsonor.jsonpath relative to the recipe, stays inside the project, and names an existing file. Each error names the key:places.oslo.within: "no-3" is not a place. Did you mean no-31 (Østfold, Norway), ....axeskeys are slugs other thanproductandchannel, each with a known kind. An ordered axis declares its values, andlabelskeys are declared values. A geo axis takes neithervaluesnorlabels(axis.Space.Check).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.- Each value under
coordinates,defaults.coordinatesand an item'scoordinatesmust be valid for its axis asaxes:types it (axis.Space.Validate). On a closed axis it is a declared value, on a geo axis a place in the project's gazetteer, and on a range axis an interval. The error names the key, such ascollections[4]: collection "Oslo youth campaign": content[0]: coordinates.market. An item'scoordinatesmay not nameproductorchanneleither. - 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 ofprofileorpack, and no file), atermstoreis a store name rather than a 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(and thedetectorsof a collection's or an item'sredaction:),defaults.materializeanddefaults.voiceare each shape-checked.defaults.translate_aftermust namewritten,establishedornone; the runner applieswrittenwhen it is unset.- Each flow must have at least one step
- Each step must have a non-empty
toolfield (unless it usesparallel) - A step naming a renamed tool id (
source-gate) fails with the id that replaced it (translate-after) - 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 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 recipe flow runs in place of a built-in flow of the same
name. The porcelain verbs (kapi translate, kapi pseudo-translate) always run
the built-in they are named for.
With -p:
- The flow name is looked up in the project's
flowsmap first, then as<flows_dir>/<name>.yamlwhen the recipe names aflows_dir(core/project.LoadDirFlow), then among the built-in flows (host.ResolveProjectFlow, whichkapi upresolvesdefaults.flowthrough as well) 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
market: world
voice:
profile: acme-docs
places:
dach:
name: DACH
of: [de, at, ch]
axes:
market:
kind: geo
label: Market
profiles:
acme:
channels: [app, marketing]
voice:
profile: acme-app
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/dach/**/*.md"
target: "{lang}/docs/dach"
coordinates:
market: dach # an item refines the collection's point
- 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