Skip to main content

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.yaml recipe 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, and prohibited/required regex Patterns, each with a severity.
  • VocabularyRulespreferred, forbidden, and competitor TermRules (each with an optional replacement, note, and severity), plus abbreviations.
  • VoiceExamples — before/after rewrites with explanations.
  • LocaleOverride / ChannelOverride maps — 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 an Annotate tool (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 declares RequiresCredentials and an API-call side effect, produces the quality.brand-voice annotation, 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:

  1. ExplicitExplicitProfileID, from tool config or an MCP parameter.
  2. CollectionCollectionProfile, a profile the caller already loaded, else CollectionConfig["brand_voice_profile_id"].
  3. 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/--file resource 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:

CommandPurpose
newScaffold a commented, schema-valid profile YAML to fill in (optionally seeded from a --pack).
guide / showRender the profile as a markdown voice guide to inject into an assistant's context.
checkScore text against the profile (vocab always; --ai adds the LLM check). --min-score turns it into a gate.
rewriteSubstitute forbidden/competitor terms for their approved replacements — deterministic, offline, no model.
profilesList profiles (local store + built-in packs).
importImport a profile YAML into the local store.
packInstall 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.