Gå til hovedinnhold

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 path and no content. The path, format, and target fields are promoted onto the collection directly. Use this for a single glob with no grouping.
  • Named collection: has a name and a non-empty content list of ContentItem, and may set its own source_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 flow kapi up runs, a built-in name or a key in flows:. Empty means kapi run requires an explicit flow.
  • materialize (manual | on-converge): whether the convergence loop owns delivery of the target-language files. manual, the default, leaves delivery to kapi merge or kapi up --materialize; on-converge writes 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 one kapi up pass converges concurrently; up --jobs overrides per run.
  • translate_after (written | established | none): the source status a block must reach before its translations are produced; written is the default applied when unset. Any other value fails the load. A recipe that still writes defaults.source_gate fails to load and names defaults.translate_after; the top-level source_gate coverage bar is a separate key.
  • merge (MergeDefaults.ConflictPolicy): what kapi merge does with a returned unit whose translation changed in the project since the extraction, which the change service refuses stale naming if_match (translator-wins default, existing-wins, newest-wins). See M-01.
  • memory (MemoryDefaults): the project's content memory: fuzzy_threshold, the pre-fill cutoff on kapi extract (default DefaultFuzzyThreshold = 75).
  • segmentation (SegmentationDefaults): opt-in SRX sentence segmentation overlay on extract (source, optional srx rules 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 per ContentItem.Redaction.
  • voice (*VoiceBinding): bind a voice profile as standing project context. profile names one in the project's voice store and pack a built-in starter pack; a scalar that is not a path is the short form of profile. 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 of tools and under a step's own config.
  • No key names a context file. terms_source and memory_source are rejected at load (retiredDefaultsKeys) with the import that reads the file. kapi store import reads a layout's terms.json, its memory/ bundles and its voice profiles into the stores, and binds the imported voice under defaults.voice when 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​

  • version is 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.
  • places builds the project's gazetteer (place.New). Each id is a slug that no built-in place uses. Each place sets exactly one of within: and of:, and every id listed there is a place. The within: and of: links contain no cycle. type is a declarable type, and names are keyed by locale. Each entry in refs is scheme:value, with a known scheme and a value in that scheme's format. centroid is a valid latitude and longitude. shape is a .geojson or .json path 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), ....
  • axes keys are slugs other than product and channel, each with a known kind. An ordered axis declares its values, and labels keys are declared values. A geo axis takes neither values nor labels (axis.Space.Check).
  • id is optional. When set, ValidateID requires prj_ followed by 20 to 64 characters from a-z2-7, which is what NewID mints (128 random bits as lowercase RFC 4648 base32, 26 characters).
  • For each collections[] entry:
    • Bare entry: path is required and content must be empty.
    • Named collection: path must be empty (use content) and content must be non-empty; each item requires a non-empty path.
    • channel requires a name, and must resolve against the declared profiles as qualified profile/channel.
    • source_only: true is rejected when the collection or any item also carries a target or target_languages.
    • coordinates may not name product or channel.
    • Each value under coordinates, defaults.coordinates and an item's coordinates must be valid for its axis as axes: 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 as collections[4]: collection "Oslo youth campaign": content[0]: coordinates.market. An item's coordinates may not name product or channel either.
    • An item's comments is true, false, or a mapping whose keys are directives, channel and only. Each directive, there and under defaults.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 as collections[0].content[1].comments.directives[0].
    • An item's comments.channel and defaults.comments.channel resolve at load as a collection's channel does, and the error names the key.
    • An item that sets comments.only may not also carry a target, target_languages, a redaction, or a format.config or format.preset (ContentItem.validateCommentsOnly). The error names the item, such as collections[1].content[0]: comments.only is set, so the item cannot have a target.
  • Every profiles: key is a slug, and so is every channel it declares; a channel is declared at most once per profile. A profile's voice is shape-checked exactly like defaults.voice (one of profile or pack, and no file), a termstore is a store name rather than a path, a concept on the profile or on a channel must be whitespace-free, and valid_from/valid_to must parse as a date or an RFC3339 instant.
  • defaults.merge.conflict_policy, defaults.memory.fuzzy_threshold (0..100), defaults.redaction.detectors (and the detectors of a collection's or an item's redaction:), defaults.materialize and defaults.voice are each shape-checked. defaults.translate_after must name written, established or none; the runner applies written when it is unset.
  • Each flow must have at least one step
  • Each step must have a non-empty tool field (unless it uses parallel)
  • A step naming a renamed tool id (source-gate) fails with the id that replaced it (translate-after)
  • Steps with parallel can omit tool (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 *). Unless SkipRequiresCheck is 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 by github.com/bmatcuk/doublestar/v4; recursive ** directory matching is supported (e.g. src/**/*.json). ExpandGlob filters out any match that matches one of the defaults.exclude glob patterns (matched with doublestar.Match). A lookup that starts from a path applies the same patterns: KapiProject.ItemForPath claims 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_IGNORE and the default rules (core/ignore). A KapiProject holds 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, for kapi 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)
  • target is expanded per source file and target language by core/project.ResolveTargetPath(itemPath, base, target, source, lang):
    • base here is the item's own base, the directory the source path is made relative to, after the collection's base has been folded in. When empty it defaults to GlobFixedPrefix(path), the literal prefix of the glob before the first */?/[/{ (so input/docs/*.md mirrors just filenames while input/**/*.md, or an explicit base, 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 by ResolvePathPattern; the rest by ExpandTemplate.
    • 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} (under base) is appended. So target: output/{lang} mirrors the source tree under each per-language root with no token and no doubled extension. See isDirectoryTarget in core/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:

  1. OS keychain via host/credentials.Store (non-secret config at providers.json under the user config directory: ~/.config/kapi on Linux, ~/Library/Application Support/kapi on macOS; kapi config path prints the resolved location. Keys live under the keychain service "kapi")
  2. Environment variables (ANTHROPIC_API_KEY, OPENAI_API_KEY) or the --api-key flag
  3. The --provider and --model CLI 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 flows map first, then as <flows_dir>/<name>.yaml when the recipe names a flows_dir (core/project.LoadDirFlow), then among the built-in flows (host.ResolveProjectFlow, which kapi up resolves defaults.flow through as well)
  • defaults.source_language and defaults.target_languages[0] provide defaults (CLI flags override)
  • For single-file flows, --input selects the file. The project's collections describe which files kapi extract / kapi merge operate 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 same core/project glob expansion the CLI relies on for extract / 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-desktop on Linux, ~/Library/Application Support/kapi-desktop on macOS), overridable with KAPI_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