Skip to main content

Scripting & JSON contract

kapi's core verbs speak a documented, golden-tested machine contract so scripts, CI pipelines, and foreign-language callers never have to parse prose. This page covers the output flags, the structured results of run, extract, and merge, the JSON error envelope, exit codes, and streaming progress events. For driving the engine over gRPC instead of the CLI, see the Engine service; for the AI-agent surface, see the MCP server.

Compatibility: the JSON documents below are a stable contract. Fields may be added in a release; existing field names and types do not change. The shapes are locked by golden tests (cli/contract_golden_test.go).

Human-readable text output is not a contract. It is presentation: column widths adapt to your terminal, values are truncated to fit, and it carries ANSI styling when stdout is a TTY. It is restyled whenever the CLI's presentation improves. Scripts must use --json (or --jq), which is stable, unstyled, and never truncated.

Output flags

Every command accepts the persistent output flags:

FlagEffect
--jsonMachine-readable JSON on stdout
--textHuman text (the default)
--output-format <json|text>Explicit format selection
--jq <expr>Filter JSON output through a jq expression (implies --json)
--color <auto|always|never>Colorize output. auto colorizes only when stdout is a terminal

Precedence: --jq > --json > --text > --output-format.

Color

Color is off whenever stdout is not a terminal, so piping or redirecting always yields plain text, with no ANSI stripping required. NO_COLOR disables color and CLICOLOR_FORCE forces it, both overridden by an explicit --color.

There is no light/dark setting, because kapi does not have a light and a dark theme. It has one palette, chosen so that every color clears the WCAG contrast bar for UI text on any terminal background: white, black, Solarized, One Dark, Dracula alike. Nothing to configure and nothing to get wrong.

That also means kapi never queries the terminal for its background color. Terminals answer such a query on stdin, which kapi's own commands read (kcat -), and a terminal that does not answer leaves the raw escape sequence in the output, corrupting piped output, CI logs, and recorded sessions.

Result documents

With --json, the core verbs print one JSON document on stdout when they finish. Without it, they print a human report, which is presentation rather than a contract (see above).

kapi run

A single-file run reports the flow and the paths involved; a batch run reports the file count. An in-project run with no -o is process-only (projects): it commits target overlays to the project store instead of writing a file, and reports process_only.

{
"flow_name": "pseudo-translate",
"input_path": "src/messages.json",
"output_path": "src/messages_qps.json"
}
{
"flow_name": "translate",
"input_path": "src/messages.json",
"process_only": true
}

kapi extract

One document per batch: identity (batch_id, manifest), the extraction inputs (format, targets, sources), one entry per target-locale pass under pairs (file/block counts and content-memory leverage), the aggregate leverage, incremental reused count, and failures when source/target pairs failed (details stream to stderr as they happen).

{
"batch_id": "0b6be731-3a5c-4a02-9e04-2f79e4c2d1aa",
"format": "xliff2",
"targets": ["fr", "de"],
"sources": 2,
"manifest": ".kapi/work/cache/extractions/0b6be731/manifest.yaml",
"pairs": [
{
"target_locale": "fr",
"files": 2,
"blocks": 10,
"leverage": { "exact": 4, "fuzzy": 1, "new": 5 }
}
],
"leverage": { "exact": 4, "fuzzy": 1, "new": 15 }
}

kapi merge

Applying returned bilingual files (merge -i) reports one entry per input plus totals and the resolved conflict policy; a failed input carries an error instead of counts.

{
"files": [
{
"input": "out/app.en-to-fr.xliff",
"applied": 8,
"stale": 1,
"skipped": 0,
"tm_new": 6,
"tm_updated": 2
}
],
"applied": 8,
"stale": 1,
"skipped": 0,
"tm_new": 6,
"tm_updated": 2,
"conflict_policy": "translator-wins"
}

Materializing from the project store (kapi merge with no -i in a project) reports the written-file count:

{ "written": 4, "from_project_store": true }

Error envelope

Under --json (or --jq / --output-format=json), a failing command prints a structured envelope on stderr instead of the plain Error: line:

{ "error": "quality gate failed", "code": "gate" }

code is the symbolic form of the process exit code (below). Exit codes are unchanged by --json; in text mode the error is still reported as an Error: <message> line.

Exit codes

CodeSymbolMeaning
0(none)Success
1errorOperational error
2usageUsage / invocation error (also grep-style "trouble" for the toolbox utilities)
3gateA quality or voice gate failed (e.g. kapi voice check --min-score), distinct from an operational error so CI can tell "the content isn't good enough" from "the tool broke"
4did_not_runkapi check reached no verdict: it checked no content, or one of its checks reported nothing on the known-bad sample it runs beside the content. Never a pass
130signalInterrupted (SIGINT/SIGTERM); no error line is printed

The toolbox utilities (kgrep) additionally use grep-parity semantics: exit 1 with no message when nothing matched.

Streaming progress: --progress jsonl

run, extract, and merge accept --progress jsonl, which streams progress events to stderr as NDJSON (one JSON object per line) while stdout stays reserved for the final result. The events use the flow-run event vocabulary (the same shapes the Kapi Desktop run sink receives), with flow naming the verb or flow:

typeMeaningKey fields
stateRun-state transitionmessage
progressAbout to process one file (or source→locale pair)file_index, file_count, file_path, locale
file_doneOne unit completedfile_path, output_path, locale
pipeline_metricsPer-step throughput snapshot (multi-locale project runs)steps
completeRun finishedduration_ms, files_processed, message
kapi extract -p kapi.yaml --progress jsonl 2> >(jq -c 'select(.type=="file_done")')
{"type":"progress","flow":"extract","locale":"fr","file_count":4,"file_path":"src/messages.json"}
{"type":"file_done","flow":"extract","locale":"fr","file_path":"src/messages.json","output_path":"out/src-messages.en-to-fr.xliff"}
{"type":"complete","flow":"extract","duration_ms":420,"files_processed":4}

Stream integrity: a truncated NDJSON stream is never silent

Every NDJSON stream kapi writes (kapi up --json, --progress jsonl) is a contract with a machine reader, so a stream that stops early must not look like one that finished. The two ways a write can fail are treated differently, because they mean opposite things:

What happenedkapi's behaviour
You stopped reading: kapi up --json | head, a jq filter that exits, a watching UI that disconnectsThe stream stops, quietly, and the run is not failed. Failing a run because its reader walked away would be worse than the silence. On a shell pipe the process is normally terminated by SIGPIPE before the write even returns, giving the conventional 141 exit; either way nothing is printed.
The write failed: a full disk, a closed file, an unwritable destinationOne message on stderr (or the JSON error envelope) naming the stream and counting what got through, and a non-zero exit. A consumer must never believe a truncated stream.
$ kapi up --json > /mnt/full/out.ndjson
Error: kapi up --json: the event stream truncated: 6 record(s) written, 12 lost: write /dev/stdout: no space left on device
$ echo $?
1

The distinction is drawn structurally (errors.Is against syscall.EPIPE and io.ErrClosedPipe, plus the Windows broken-pipe errnos), never by matching the message text. Under --progress jsonl the exit code is the signal to read: when the failing writer is stderr, the message has nowhere to land.

Streaming inspection: kapi inspect --jsonl

For block-level content streaming (rather than run progress), kapi inspect --jsonl emits one JSON object per block; run kapi inspect --help for the block fields.

MCP surface stability

The MCP server (kapi mcp) is part of the same contract: its tool names and input schemas are a stable surface for agent integrations, locked by a snapshot test (kapi/cmd/kapi/mcp_snapshot_test.go). New tools and new optional fields may be added; existing tools are not renamed or removed, and existing fields do not change type, without an explicit, documented decision.

check_text accepts an optional context_path, a project-relative destination whose voice and terms apply to the supplied draft. It requires a bound project and cannot be combined with profile_file or profile_pack. The result keeps target.kind: "text" and records the destination in target.context_path; the destination need not exist. Unscoped snippet checks retain their explicit profile options. See Checks for report coverage.

The optional execution.contexts array adds per-input guidance selection to kapi.check/v1 without changing existing report fields. Entries contain file or context_path, voice (selection, applied, optional name, source, profile and channel), terms_applied, and, when a project resolved the guidance, point (profile, channel, and comments for the point a file's comments sit at). Findings, including those of kapi check --ship, carry the same optional point. voice.selection is project, override or none. Missing context metadata means unreported selection. CLI file checks, MCP file/draft checks and source-content release checks supply it. Explicit voice overrides retain their behavior and are identified as override; MCP field descriptions state that omitting overrides preserves file-scoped project guidance. See Checks for interpretation.

The optional warnings array is also additive to kapi.check/v1, and the kapi check --ship result carries the same array at its top level. Each entry contains code, message, source and, when the warning concerns one key, key. The array is omitted when a run has none. Warnings describe the configuration a check ran under, and they never change pass, verdict, summary, gate or the exit code. The MCP check_text and check_file tools return them in the same report, and the check_report_warnings golden in cli/contract_golden_test.go pins the shape. See Checks for the codes.

For apply_edits, content entries put new wording in text; replacement is a voice-rule field. A nonempty replacement on a content entry is rejected before applying the change-set. The input field descriptions state this distinction without changing field names or types.

The registry tools on that surface are exactly the CLI-visible ones: a built-in tool appears under kapi exec, in kapi tools list, and as an MCP tool when it registers a config factory and does not declare itself internal (registry.ToolRegistry.CLITools). Wiring a factory for a tool that lacked one is therefore an additive surface change: it adds the tool to all three at once, and the snapshot moves. whitespace-correct gained one this way, and dnt-check, placeholder-check, xml-validation, create-target, remove-target, inline-codes-remove and external-command followed.

The up tool takes an optional local field, mirroring kapi up --local: in a project connected to a server the run happens at that venue by default (the same decision the command makes) and local keeps the loop on this machine, pushing the results afterwards.

review_queue takes an optional language field, mirroring kapi status --review --lang: the queue holds every language the project has work in, so language narrows it to one, the project's source language included. The rows carry language, isSource, status and held, and the result carries languages with the pending count per language.

Running commands a recipe names

external-command and script run code the configuration chooses. They stay available on every surface where the argv is the user's own: kapi exec external-command --command … runs what it was told to, unchanged.

What a recipe does with them is gated. A project whose recipe names either tool prompts once, showing the command it would run, and the answer is remembered under the kapi config directory against a fingerprint of what was approved, so an unrelated recipe edit keeps the approval and a changed command asks again. With no terminal attached kapi refuses rather than assuming consent; KAPI_TRUST_EXEC=1 is the opt-in for automation, and the general --yes flag deliberately does not grant it. The engine gRPC API and the MCP tool surface refuse these tools outright. See E-06.

Tool registration invariants

Three properties of a built-in tool's registration are asserted over the populated registry, in core/tools/registration_invariants_test.go, because each one fails silently when it is left to review:

  • A tool that declares settable schema fields registers a config factory. Without one, NewToolWithConfig calls the zero-arg factory and discards the step's config: map without a word, and the tool's documented parameters do nothing.
  • A bilingual tool's target locale comes from the run. --target-lang outranks any locale written into a step's config, so one flow serves every locale it is run for. A factory that pins a locale leaves the tool processing content the run never asked for, and reporting success.
  • A CLI-visible tool that rewrites content declares writesOutput. kapi exec grows -o / --output-dir only for tools that do; without it an exec run rewrites the content in memory, exits 0, and writes nothing.

Withholding a tool from the CLI is a separate decision, declared with internal: true on its ToolMeta and never expressed by omitting a config factory, which would make a forgotten tool indistinguishable from one deliberately withheld. An internal tool is still configurable: a flow may name it as a step.

Step config keys are the config struct's JSON names, which are camelCase (normalizeSpaces, flagExtra, textUnitIDs). Application is a json.Unmarshal, so an unrecognized key (a snake_case spelling, or a field the tool does not have) is silently ignored.