AD-022: Brand Voice
Summary
The brand voice subsystem keeps AI-generated and translated content on-brand.
Its core type, brand.VoiceProfile, is a portable YAML document describing
tone, style, vocabulary rules, examples, and locale/channel overrides. Two
registered tools evaluate text against a profile: a deterministic, offline
brand-vocab-check (rule-based vocabulary) and an LLM-based brand-voice-check
(tone/style/clarity). Findings carry a severity and a run-anchored position and
roll up into an MQM-inspired 0–100 compliance score. The kapi brand command
tree (new, guide, check, rewrite, profiles, show, import, pack)
exposes this as a text-first, JSON-first surface that works fully offline
against a starter pack, a standalone YAML file, the local SQLite brand store, or
a profile bound to a .kapi project. A small MCP surface (brand_guide,
brand_check, brand_rewrite) mirrors the deterministic path for AI agents.
Context
neokapi's positioning is to plug into an AI assistant and keep its output on-brand and consistent before publishing it in other languages and formats. A brand voice is the natural unit of that guardrail: a reusable description of how a brand wants to sound, against which a draft can be scored and rewritten. The subsystem must satisfy several constraints:
- Portable and git-shareable. A profile is a YAML document a team can commit
and review, with no backing store required — the same way a
kapi.yamlrecipe is portable (AD-008). - Offline by default, AI-optional. A vocabulary check (forbidden, competitor, and preferred terms; regex patterns) is deterministic and needs no network. An LLM check for the subjective dimensions (tone, style, clarity) is opt-in and credential-gated.
- Composable with the rest of the engine. Brand evaluation runs as registered tools (AD-006) so it composes into flows, reuses the schema/config machinery, and writes findings as block annotations that other tools and the UI can read.
- Multiple surfaces. The same capability must be reachable from the CLI, the MCP server for agents, and the bundled Agent Skill (AD-024).
AD-010 handles terminology consistency at the concept level; brand voice is the broader, prose-level guardrail. The two intersect at vocabulary rules, which the vocab check can optionally cross-reference against a terms store.
Decision
Data model — core/brand
VoiceProfile is the canonical type. It is loaded from YAML by
brand.LoadProfileYAML, the single loader used by standalone files, the
embedded starter packs, and the SQLite store. Its shape:
ToneProfile— personality adjectives, formality, emotion, humor, and free-text guidelines.StyleRules— active voice, sentence length, point-of-view, contractions, andprohibited/requiredregexPatterns, each with a severity.VocabularyRules—preferred,forbidden, andcompetitorTermRules (each with an optional replacement, note, and severity), plus abbreviations.VoiceExamples — before/after rewrites with explanations.LocaleOverride/ChannelOverridemaps — locale- and channel-specific adjustments resolved on top of the base profile.
The profile also carries versioning fields (a ProfileVersion snapshot per
update, named ProfileTag references) for stores that track history.
Findings and scoring
A finding is a brand.BrandVoiceFinding, which is a type alias to
check.Finding from the framework's content-verification core (core/check).
The struct carries a free-form Category string — a brand finding sets it to a
brand dimension (tone, style, vocabulary, clarity, brand_compliance), modeled by
the brand-local Dimension type — a Severity (neutral, minor, major,
critical), a human message, an optional suggestion, the original text, optional
metadata, and a Position model.RunRange — so a finding is anchored to the
runs it concerns, the same run-range model used for overlays and redaction
(AD-002). Severity and SeverityWeight are
re-exported from core/check. Tools attach findings to a block as a
BrandVoiceAnnotation (annotation type brand-voice), which also carries the
profile id, the overall score, and its own Position.
This finding/severity/scoring path is shared across all checkers (terminology,
do-not-translate, placeholder, register, brand), not bespoke to brand voice —
brand voice is one checkset over the generic core, with BrandComplianceScore
providing a dimension-shaped presentation of the generic check.Score.
brand.CalculateScore rolls findings up using the MQM-inspired penalty weights
defined in core/check.SeverityWeight — neutral 0, minor 1, major 5,
critical 25 — per dimension. Each dimension starts at 100 and is reduced by its
penalty (clamped to 0); the overall score is 100 minus the total penalty. The
dimensions are fixed (tone, style, vocabulary, clarity, brand_compliance), so a
BrandComplianceScore always has a consistent shape.
The two tools
Brand evaluation is implemented as two registered tools so it composes into flows and shares the tool schema/config machinery (AD-006):
brand-vocab-check(core/tools) — deterministic and offline. It scans source text for forbidden, competitor, and preferred-term violations and regex pattern hits, emitting findings with positions. It optionally takes a terms store to filter by brand vocabulary. It is anAnnotatetool (read-only; writes the annotation, not the content). This is the fast first pass.brand-voice-check(core/ai/tools) — LLM-backed. It asks an AI provider (AD-011) to score the subjective dimensions (tone, style, clarity) against the rendered voice guide, returning findings. It declaresRequiresCredentialsand an API-call side effect, produces thequality.brand-voiceannotation, and runs with bounded per-block parallelism.
Both resolve their profile eagerly (supplied programmatically) or lazily through
a ProfileResolver against an organizational context hierarchy, so a host can
defer profile selection to runtime.
One resolution chain
profile.ResolveProfileFromContext is the only place a profile's precedence is
decided, most specific first:
- Explicit —
ExplicitProfileID, from tool config or an MCP parameter. - Collection —
CollectionProfile, a profile the caller already loaded, elseCollectionConfig["brand_voice_profile_id"]. - Stream → 4. Project → 5. Workspace.
The tiers below the collection are property maps read off database rows, which
is how a connector- or editor-created project with no recipe is governed. A
recipe-governed project fills the same collection tier: coordinates: and
profiles: select the voice, the host loads it — a recipe binds a profile file
or a starter pack, which a store id cannot name — and hands it over as
CollectionProfile. So the two kinds of project differ in which tiers they
populate, never in how the tiers are ranked, and an explicit per-call profile
outranks a recipe exactly as it outranks a collection row.
Locale, channel and persona overrides are applied once, at the end of that
chain, by ResolveProfile. A channel bound to a scope (a collection's
context.channel, a Collection.ConnectorConfig entry) describes where the
content is published; ResolveContext.Channel is the caller overriding it for
one call, the tier a --channel flag occupies.
The recipe authors; one venue runs
kapi.yaml is an authoring surface, not a second runtime source. It is
git-owned and authoritative over what the governance is; at any moment exactly
one venue applies it — the recipe when the project runs standalone, the
server's rows when it runs connected. Two live sources would mean a voice that
depends on where the loop happened to run, and a platform user quietly editing
over a committed decision.
Until coordinates are synced to the server, a connected project's rows carry no
coordinates, so a run there governs by defaults.brand_voice while a local run
governs by the point. That divergence is real and is reported rather than
hidden: a run over a project that declares coordinates:/profiles: and
carries a server: block prints
host.UnsyncedCoordinatesWarning to stderr (kapi run, kapi up, and the
embedded flow runner). It warns and proceeds — a recipe field that is not yet
readable at the other venue is not a reason to refuse the run.
Profile sources and the kapi brand command tree
NewBrandCmd (cli/brand.go) builds the kapi brand group. A profile is
resolved from one of three mutually exclusive sources:
--profile <name>— a profile in the local SQLite brand store (opened with the standard--name/--local/--fileresource flags, mirroring the terms store and the content memory);--profile-file <path>— a standalone, git-shareable profile YAML;--pack <name>— a built-in starter pack.
With no source flag, resolution falls back to the .kapi project in scope: the
voice governing the content collection that claims the file, else the recipe's
defaults.brand_voice (a BrandVoiceBinding selecting a profile file, store
profile, or pack — resolved relative to the project root), then a convention
file at <root>/brand.yaml or <root>/.kapi/brand.yaml. This lets kapi brand check DRAFT.md work flag-free inside a project. Locale and channel overrides
apply on top via --locale/--channel; an explicit --channel wins over the
channel the recipe declares.
Context is a coordinate space
A project is not always one voice, and a voice does not read the same
everywhere. Which product a page belongs to, which channel it appears on, which
market it addresses — these are coordinates of one thing, the context the
content is written for, and the recipe treats them that way. A project declares
its own axes under coordinates:, binds governance to regions of that space
under profiles:, and each named content collection names the point its content
sits at:
coordinates:
product: [kapi, bowrain]
channel: [docs, landing]
profiles:
- when: {}
voice: context/base-voice.yaml
- when: { product: bowrain }
voice: context/bowrain-voice.yaml
content:
- name: docs
context: { product: kapi, channel: docs }
- name: landing
context: { product: bowrain, channel: landing }
The taxonomy is the project's own: another team would say business unit, client,
or tenant, and the framework reads nothing into an axis except channel. Of the
profiles matching a point, the one matching on the most coordinates governs, so
a broad voice is refined by a narrow one instead of being copied into a second
file that drifts from the first. Two profiles matching equally well is a load
error, not a coin flip.
channel is the one axis the framework interprets: once a profile is selected,
the point's channel selects the Channels override inside that profile — the
same composition --channel has always driven. A landing-page register is
therefore authored once, in the voice it varies, and a channel the profile says
nothing about leaves the base voice in place. The axis may also appear in a
when:, and the two roles compose: matching decides which voice, the override
refines the register within it.
The profile a point selects is not resolved beside the chain above — it enters it at the collection tier, so a step that names its own profile still wins and a server-governed project still ranks its bindings the same way.
Because the tool chain is assembled before any content is read, and bakes the resolved profile into the translate steps, a run cannot switch voice per file: it resolves the governance per collection and executes once per distinct resolution. A recipe where no collection declares a point runs unsplit, exactly as one that has never heard of coordinates. See kapi project file.
The subcommands:
| Command | Purpose |
|---|---|
new | Scaffold a commented, schema-valid profile YAML to fill in (optionally seeded from a --pack). |
guide / show | Render the profile as a markdown voice guide to inject into an assistant's context. |
check | Score text against the profile (vocab always; --ai adds the LLM check). --min-score turns it into a gate. |
rewrite | Substitute forbidden/competitor terms for their approved replacements — deterministic, offline, no model. |
profiles | List profiles (local store + built-in packs). |
import | Import a profile YAML into the local store. |
pack | Install a built-in starter pack into the local store. |
check reads its subject text from --text, a positional file, or stdin.
check --min-score returns the ErrQualityGate sentinel when the score is below
the threshold, which the CLI maps to a distinct exit code
(AD-013) so skills and CI can tell a failed gate from an
operational error.
Fixing off-voice content: rewrite it yourself, apply through kapi apply
kapi does not send content to a model to rewrite it. An on-brand fix is
caller-supplied: the assistant loads the voice guide (kapi brand guide) and the
approved wording (kapi terms lookup) as context, rewrites the off-voice text
itself, and applies the result through the one write verb, kapi apply. The
edits land through the byte-faithful round-trip with no AI provider
(AD-024): structure and inline codes are preserved, each
block is drift-guarded by its content_hash, and an edit that would corrupt
markup is rejected.
kapi brand rewrite is a separate, deterministic helper: it substitutes
forbidden and competitor terms for their approved replacements by rule, offline,
reading text from --input-text or stdin. It does not call a model and does not
touch tone, style, or phrasing — those are the caller's to rewrite.
Brand-vocabulary edits as a change-set entry
Fixing a recurring off-voice term at the source — adding a vocabulary rule so
every future draft is checked against it — is a brand entry in the same
kapi apply change-set, alongside the content fix it justifies (one reviewed
change = one typed entry, AD-024):
{"kind":"brand","op":"add-rule","list":"forbidden","term":"utilize","replacement":"use","severity":"minor"}
The entry adds a TermRule to the named vocabulary list (forbidden,
competitor, or preferred) of the committed brand voice profile YAML the
recipe binds (defaults.brand_voice.profile_file, created and bound if none
exists), then re-imports that profile into the local brand store via the existing
brand.LoadProfileYAML path. The committed YAML is the single source of truth and
git diff is the review surface; the store is a compiled cache, written by the
one importer. The operation is idempotent — a rule already present with the same
term, replacement, and severity is a no-op. A binding that points at a starter
pack or a store profile rather than a profile_file is rejected: apply edits a
committed file, not a pack or a store row.
Built-in starter packs
The framework embeds a small set of starter packs (core/brand/packs, embedded
via //go:embed *.yaml): professional-b2b, friendly-dtc, technical-docs,
marketing-blog, and customer-support. Each is a complete VoiceProfile
YAML, loaded through the same brand.LoadProfileYAML path as any other profile,
so packs are an on-ramp, not a special case — kapi brand new --pack <name>
emits one as an editable base.
MCP surface
cli/mcp_brand.go registers offline brand tools on the shared kapi mcp stdio
server (AD-013) so non-CLI agents get parity:
brand_guide— render a voice guide from a pack or profile YAML;brand_check— score text using the deterministic vocabulary rules;brand_rewrite— substitute forbidden/competitor terms (deterministic).
These are hand-authored because each wraps a resource — a brand profile, a
terms store, or a content-memory file — rather than a single processing tool. The same file
also exposes term_lookup and tm_search so an agent can enforce terminology
and reuse prior translations alongside the brand checks.
The registry's processing tools are exposed over MCP generically rather than
curated by hand (AD-006, cli/mcp_tools.go): each
CLI-visible tool becomes an MCP tool whose input schema is projected straight
from the tool's own schema plus a text field. The set is scoped by mode,
mirroring the desktop's ListTools vs ListProjectTools split — in a kapi
project only the tools the project declares are advertised (with the project's
target language as the default); ad-hoc, the full set is exposed. So the MCP
surface now mirrors the CLI rather than being a deliberately narrowed subset of
it.
Consequences
- A brand voice is a portable YAML document that works with or without a store, reviewable in git and reusable across the CLI, MCP, flows, and skills.
- The deterministic vocabulary check gives an instant, offline, reproducible signal; the LLM check is a clearly bounded, credential-gated opt-in for the subjective dimensions.
- Findings are run-anchored and annotation-shaped, so they compose with the content model and surface uniformly across tools and UIs rather than being a bespoke side channel.
- The MQM-style scoring is a single function over findings, so every surface (CLI, MCP, a flow) computes the same 0–100 score the same way.
Related
- AD-002: Content Model —
RunRangepositions and block annotations - AD-006: Tool System —
brand-vocab-checkandbrand-voice-checkas registered tools - AD-008: Kapi Project Model —
defaults.brand_voicebinding in akapi.yamlrecipe - AD-010: Terminology — concept-level terminology consistency that brand vocabulary intersects
- AD-011: AI Providers — the LLM provider behind the AI brand check
- AD-013: Kapi CLI — the
kapi brandcommand tree, the MCP server, and the gate exit code - AD-024: Agent Skills — the bundled skill that drives the brand commands