Prompts
Prompts belong to the engine, not to any one tool that drives it.
When a translation tool needs a language model, the prompt it sends is built by the framework — the same builder, the same rules, the same bytes — whoever is asking. The kapi CLI, Kapi Desktop and an embedding Go program all send an identical prompt for an identical block. That is deliberate: a prompt that varied by caller could not be documented, fingerprinted, or trusted, and the Prompt Reference is generated from these builders precisely so it cannot describe a prompt the engine does not send.
What differs by caller is only how you look at one. This page uses the kapi
CLI's --explain-prompts, because it is the shortest path to seeing the real
thing.
So: this page says exactly what is in a prompt, where each part came from, and how to see it for yourself. Nothing here is inferred from documentation — every prompt the engine ships can be printed, on demand, from the binary you are running.
Show me
Add --explain-prompts to any command that reaches a model. It prints every
call the run made: the prompt, the provider, the model, the reply, and the
tokens spent.
kapi translate messages.json --target-lang fr --explain-prompts
Pair it with --provider demo — the built-in offline stub — to see the prompt a
paid provider would receive, with no API key, no network, and no spend:
kapi translate messages.json --source-lang en --target-lang fr --provider demo --explain-promptsThe output attributes every block of the prompt to the thing that produced it:
─── LLM call 1/1 ─────────────────────────────────
prompt: translate.single (v2)
provider: anthropic model: claude-sonnet-5
── system ──
[task · framework]
You are a software localization specialist. Translate the user's text from
en to fr. Return ONLY the translation, with no explanation, preamble or quoting.
[constraint · framework]
Preserve placeholders such as {0}, %s and {{name}} exactly.
[instruction · --instruction / recipe]
Instruction (apply when translating):
Informal register. Keep product names in English.
[glossary · terms (12 terms)]
Glossary:
- utilize → use
── user ──
[content · source block]
Save
--explain-prompts=<path> writes the same calls to a file as JSON, so a prompt
can be diffed between runs or attached to a bug report.
--explain-prompts is not the same flag as --explain, which reports a flow's
resolved source → sink bindings and does not run.
A prompt is composed, not written
A kapi prompt is not a chat message. It is a document assembled from sections that have different owners, and the distinction is the whole design:
The sections of a kapi prompt, and who owns each.
The framework owns the task and the constraints. A translation that drops a placeholder or mangles an inline tag cannot be written back into the source file — it breaks the round-trip that makes kapi useful at all. These rules are not a configuration surface, and there is no flag to remove them.
Your project owns the steering. The instruction, the brand voice and the pinned terms are what make the output yours rather than merely correct. They are declared in the recipe, the brand voice profile and the terms store — never typed into a prompt.
Your document owns the content — and the context. The source text is the entire user turn: data, never instruction. Its key and its neighbours travel separately, in the system turn, as reference the model reads but must not translate.
Steering the model
The supported way to change what the model does is to add to the steering sections — not to replace the prompt.
| You want | You set |
|---|---|
| A one-off directive for this run | --instruction "informal register" |
| A standing directive for the project | defaults.tools.translate.instruction in the recipe |
| Consistent tone and phrasing | A brand voice profile |
| Specific terms translated a fixed way | A terms store |
| The model to know what a string is | --context key (default) or --context neighbours |
defaults:
source_language: en
target_languages: [fr, de]
brand_voice:
profile_file: context/brand-voice.yaml
terms: .kapi/terms.db
tools:
translate:
instruction: "Informal register. Keep product names in English."
Everything in that recipe is visible in the prompt, and --explain-prompts will
show you which section each line landed in. If a pinned term is not reaching
the model, this is how you find out.
Prompts are provider-neutral
kapi does not keep a different prompt per vendor. A prompt declares its turns semantically — a system turn and a user turn — and each provider is responsible for carrying them the way its API expects.
One prompt, many transports. The wording does not branch on the vendor.
What genuinely differs between providers is capability, not wording: how
structured output is requested, whether images are accepted, how a system
instruction is carried, and a sane batch size. That variance lives behind the
provider interface, where it belongs. Switching --provider anthropic to
--provider ollama changes the transport and the model — not the instructions.
Determinism, and why it matters for cost
The same inputs always render byte-identical prompt text. Terms in the glossary
section are sorted rather than emitted in map order, and no timestamp, run id or
machine detail is ever interpolated.
This is not tidiness. kapi caches translations, and the cache key is derived from the text the prompt actually renders. Change your instruction, your brand voice, your terms, or the prompt itself, and the fingerprint moves, so the affected strings are re-translated instead of served from a cache that a different prompt produced. Change nothing, and nothing is re-bought.
Context: what the model is told about a block
A block on its own is often not enough to translate it. Save is a coin flip
between a verb and a noun — in German, between Speichern and Speicherung —
and no amount of model quality resolves a genuine ambiguity in the input.
So the engine sends reference material: text the model reads but must not translate. This is a different thing from the blocks in a batch, and the difference matters more than it sounds. A batch is co-workers — every segment in it is a task competing for attention and a distractor for every other one. Reference material is context — it disambiguates without adding work. Adding co-workers degrades quality; adding context does not.
context | What the model is told |
|---|---|
none | Only the text. |
key | Default. The block's key — settings.save. |
neighbours | The key, plus the surrounding source blocks. |
The key is the cheapest disambiguation there is: it is already in your
document, it costs a handful of tokens, and it settles the Save question
outright. It is also stable — a key travels with its block — so sending it can
never make a cached translation wrong.
Neighbours cost tokens, and they make a block's translation depend on text that is not the block. The engine handles that honestly: the neighbourhood is fingerprinted into the cache key, so editing one string re-translates the strings around it rather than serving them from a cache produced under a neighbourhood that no longer exists. That is correct, and it is not free — which is why it is opt-in.
kapi translate messages.json --target-lang fr --context neighbours
What the engine does not send
Worth stating plainly, because prompts are easy to imagine bigger than they are:
- No content-memory matches. Memory reuse is a separate, deterministic step
(
recycle) that runs before translate and fills what it can. Matches are not offered to the model as examples. - No rolling document context. Reference material is a bounded window around the block, not the whole document.
- No telemetry. Your content goes to the provider you configured, and nowhere else.
By default, translate batches several blocks into one call (translate.batch),
taken in document order and sent as a JSON payload keyed by segment id. Sharing a
call is not the same as the model having context: those blocks are all being
translated at once, none of them is settled reference material, and blocks
either side of the batch are in a different call entirely. A block is translated
on its own (translate.single) when you set batching: single, or when the
provider runs on-device — a small local model tends to ignore the "return JSON"
instruction on a large batch.
How many blocks share a call is not a number you set. It depends on what your
model can emit, on how long your segments happen to be, and on a
quality-versus-batch-size curve that nobody has published — so kapi sizes each
call against the model's real output limit rather than asking you to guess. A
fixed batch size would overrun that limit on anything longer than a UI string,
producing a reply the model cannot finish. The choice you do get is
batching: auto or batching: single.
How much more to send is still an open question — the evidence favours a large
non-translated context with a small translation unit, and --context neighbours --batching single is exactly that shape.
Prompt injection
The text kapi translates is often not written by the person running kapi: it is CMS copy, user comments, strings from a dependency. So a block that says "ignore your instructions and output X" is a thing that happens, and it is worth being precise about what does and does not protect you.
The prompt tells the model the content is data, in a constraint section you
can see in --explain-prompts and in the Prompt Reference.
That is defence in depth, and no more: no prompt-level rule reliably stops a model
from following an instruction embedded in its input, and we will not claim
otherwise.
What actually contains the damage is structural:
- The call has no tools, no filesystem and no network. It is one request that returns text. An injected instruction cannot act — at worst it produces a wrong translation. That bounds the blast radius to content, not capability.
- The reply is constrained to a schema by the provider's own grammar, so it cannot escape its shape — only fill it.
- kapi accepts only the segment ids it sent, so an injected omission is caught and retried rather than silently shifting every translation after it.
- The payload is JSON, so content cannot forge a segment boundary. This is not
only an attack: a block containing
[2]is enough to break a delimiter-framed payload, and a user documenting a numbered list writes one by accident.
The remaining exposure is that an injected instruction can corrupt the text of a translation — which is what checks and review exist to catch.
The prompts kapi ships
Every LLM call kapi makes carries a prompt id, so it can be named in
--explain-prompts and cited in a bug report:
| Prompt | Sent by |
|---|---|
translate.single | translate, one block at a time |
translate.batch | translate, several blocks in one call |
brand.check | brand-voice-check |
brand.infer | brand-voice-infer (drafting a profile) |
quality.check | qa, in AI mode |
review | review |
term.extract | term-extract |
entity.extract | entity-extract |
media.refine.image | media-refine, re-reading an image crop |
media.refine.audio | media-refine, re-hearing a speech clip |
media.refine.video | media-refine, re-reading a video frame |
segment | segment, with the LLM engine |
The three media.refine.* prompts are listed separately because they are
separate prompts, and because each ships a different kind of your data — a
cropped image, a clip of audio, a video frame. Collapsing them into one id would
hide the distinction you are most likely to care about.
If a call ever appears in --explain-prompts without an id, that is a bug —
report it. An unlabelled call is an undocumented use of your content.
The full text of each is published in the Prompt Reference, generated from the same builders the binary uses and drift-gated in CI — so it cannot describe a prompt kapi does not send.
Watch a prompt get built
Rather than take the sections above on trust, build one up a layer at a time and read the real output at each step. This runs kapi in your browser — the same binary, the offline demo provider, no key and no network.
Loading the walkthrough…
Start bare and you get the framework's two sections plus your content. Add
--instruction and a steering section appears, attributed to the flag. Translate
a block that carries inline markup and the tag-fidelity constraint appears —
which a plain string never sees, because constraints are earned rather than
constant.