Recipes for agents and scripts
Drive verbatra from an agent loop or a shell script: which commands emit JSON, what the payloads really look like, and how to branch on them.
An agent or a script needs three things from a CLI: a stable way to ask, a parseable answer, and an unambiguous signal about what happened. verbatra gives you all three through --json and the exit code. This page is the worked version of that: real captured payloads, and short recipes that run, parse, decide, and act.
It does not restate the contract. The --json envelope and every result shape are documented in the CI guide, the CLI overview states the convention, and each command page carries its own exit-code table. Read those for what a field means; read this for what to do with it.
If you want an agent that drives the dashboard in a browser rather than a process in a shell, that is a different surface: see Operate Studio with a browser agent.
The recipes below use jq to read stdout, because that is the smallest thing that reads a JSON stream from a shell. Any JSON parser works; nothing in the output depends on jq.
Which commands emit JSON
| Command | --json | One successful record carries |
|---|---|---|
translate | yes | one RunSummary |
watch | yes | one RunSummary per run, as NDJSON |
check | yes | one CheckSummary |
diff | yes | one DiffSummary |
doctor | yes | one DoctorResult |
export | yes | the written path and the per-locale row counts |
import | yes | one RunSummary |
init | no | |
studio | no |
init is an interactive scaffolder and studio is a long-running server, so neither has a machine-readable mode. Everything else an agent needs from a project is reachable from the other seven.
doctor is the most recent addition to that list.
Available from 0.9.0
stdout carries envelopes and nothing else
Progress records, lock-wait records, and the human-readable error line all go to stderr, in both modes. Redirect stderr and stdout is a clean stream of JSON records:
verbatra check --json 2>/dev/nullKeep stderr when you want a log of what the run was doing while it was doing it:
verbatra translate --json 2>run.log | jq .Three payloads, captured
Every payload below is real output from a small project: source locale en, one target locale de, and a de file that already has app.title but not app.greeting.
verbatra check --json answers "is anything out of sync", in counts:
verbatra check --json 2>/dev/null{"ok":true,"version":1,"command":"check","result":{"inSync":false,"locales":[{"locale":"de","missing":1,"stale":0,"upToDate":1,"inSync":false}]}}The command exits 1, because inSync is false. That is the cheapest gate there is: the exit code alone tells you whether to read the payload at all.
verbatra diff --json answers the same question by key name instead of by count:
verbatra diff --json 2>/dev/null{"ok":true,"version":1,"command":"diff","result":{"hasPendingChanges":true,"locales":[{"locale":"de","missing":["app.greeting"],"changed":[],"orphaned":[],"hasPendingChanges":true}]}}Also exit 1. Use diff when the agent has to name the keys (to write a pull request comment, or to decide whether the work is worth spending on); use check when a yes or no is enough.
verbatra translate --dry-run --json answers "what would a run do", with no provider call, no API key, and nothing written. It is pretty-printed here for reading; on the wire it is a single line like the two above:
verbatra translate --dry-run --json 2>/dev/null | jq .{
"ok": true,
"version": 1,
"command": "translate",
"result": {
"dryRun": true,
"locales": [
{
"locale": "de",
"status": "succeeded",
"translated": [
"app.greeting"
],
"unchanged": [
"app.title"
],
"orphaned": [],
"pruned": [],
"invalidIcuSource": [],
"cacheHits": [],
"integrityMismatches": [],
"providerFailures": [],
"budgetWithheld": [],
"generated": [],
"notices": [],
"needsReview": [],
"unfilled": [],
"malformedRows": [],
"duplicateKeys": []
}
],
"succeeded": [
"de"
],
"partial": [],
"failed": []
}
}Exit 0. Every per-locale list is present even when it is empty, so an agent can index into it without probing first. A live run adds a usage object to each locale and to the summary when the provider reports usage; a dry run never does, because it makes no call. The SDK reference has the full anatomy of a LocaleSummary.
When a run cannot happen at all, the payload is an error record instead and the command exits 2:
{"ok":false,"version":1,"command":"check","code":"CONFIG_INVALID","message":"The verbatra configuration is invalid: provider.options.maxOutputTokens: Invalid input: expected number, received undefined"}code is the stable part. Branch on it, not on message.
ok: true does not mean everything was translated
This is the one that catches scripts out. A locale can fail inside a run that itself completed. The envelope stays ok: true, because the command ran and produced a summary; the failure shows up in result.failed and result.partial, and the exit code is 1.
Here is translate against an unreachable provider endpoint:
verbatra translate --json 2>/dev/null | jq -c '{ok, succeeded: .result.succeeded, partial: .result.partial, failed: .result.failed}'{"ok":true,"succeeded":[],"partial":[],"failed":["de"]}ok answers "did the command run". succeeded, partial, and failed answer "did the work land". The exit code already combines both, which is why an agent that only reads the exit code is never wrong, and an agent that only reads ok is wrong the first time a provider has a bad day.
Branching on the exit code
Three codes cover every one-shot command. This gate reads check, prints the drifted locales when there are any, and treats a whole-run error as a different kind of problem:
#!/usr/bin/env bash
set -uo pipefail
report=$(verbatra check --json 2>/dev/null)
status=$?
case $status in
0)
# in sync: nothing to do
echo "every locale is in sync"
;;
1)
# it ran, the result is not clean: the payload says what drifted
echo "$report" | jq -r '.result.locales[] | select(.inSync | not) | "\(.locale): \(.missing) missing, \(.stale) stale"'
;;
2)
# it could not run: the payload is an error envelope
echo "$report" | jq -r '"cannot run [\(.code)] \(.message)"' >&2
exit 2
;;
esacOn the same project, the three branches produce:
de: 1 missing, 0 stale
every locale is in sync
cannot run [CONFIG_INVALID] The verbatra configuration is invalid: provider.options.maxOutputTokens: Invalid input: expected number, received undefinedDo not run this under set -e: a non-zero exit is the signal you came for, not a crash. Code 130 never appears here, because only watch and studio can be force-stopped by a second interrupt. See CI and exit codes for the full table.
watch is a stream, not a payload
watch --json is different in kind from the other six. It prints one envelope per run for the life of the process, as NDJSON: one JSON object per line, no wrapping array, no terminator. A consumer reads it line by line and keeps reading.
Raw, two runs of one session (the second after the source file was saved with a syntax error):
{"ok":true,"version":1,"command":"watch","result":{"dryRun":false,"locales":[{"locale":"de","status":"succeeded","translated":["app.greeting"],"unchanged":["app.title"],"orphaned":[],"pruned":[],"invalidIcuSource":[],"cacheHits":[],"integrityMismatches":[],"providerFailures":[],"budgetWithheld":[],"generated":[],"notices":[],"needsReview":[],"unfilled":[],"malformedRows":[],"duplicateKeys":[],"usage":{"inputTokens":120,"outputTokens":40}}],"succeeded":["de"],"partial":[],"failed":[],"usage":{"inputTokens":120,"outputTokens":40}}}
{"ok":false,"version":1,"command":"watch","code":"SOURCE_INVALID","message":"The source locale file at /home/dev/app/locales/en.json could not be read: The file is not valid JSON."}Both record kinds appear on the same stream, so reduce each line to the fields you act on. --unbuffered makes jq flush per line, which is what turns the pipe into something you can react to rather than something you read at the end:
verbatra watch --json 2>/dev/null \
| jq -c --unbuffered '{ok, code, failed: (.result.failed // null), translated: [(.result.locales // [])[].translated[]]}'Three runs of one session (edit, edit, then a broken source file):
{"ok":true,"code":null,"failed":[],"translated":["app.greeting"]}
{"ok":true,"code":null,"failed":[],"translated":["app.logout"]}
{"ok":false,"code":"SOURCE_INVALID","failed":null,"translated":[]}Two things follow from that last line. A failed run is a record on the stream, not the end of the session: the watcher stayed up and kept translating after it. And it never changes the exit code, which is decided only by how the session stops. A long-lived agent therefore treats an ok: false line as an event to report, not as a reason to restart the process.
A worked loop: gate, decide, translate, report
Putting it together. This runs unattended, refuses to spend on a job that is too big to trust to a robot, and reports what actually landed rather than what it asked for:
#!/usr/bin/env bash
set -uo pipefail
# 1. Run: ask what is pending. Read-only, no provider call, no API key.
pending=$(verbatra diff --json 2>/dev/null)
case $? in
0) echo "nothing pending"; exit 0 ;;
2) echo "$pending" | jq -r '"cannot run [\(.code)] \(.message)"' >&2; exit 2 ;;
esac
# 2. Parse: which locales have work, and how much.
echo "$pending" | jq -r '.result.locales[] | select(.hasPendingChanges)
| "\(.locale): \((.missing + .changed) | length) pending"'
# 3. Decide: only spend unattended when the job is small.
keys=$(echo "$pending" | jq '[.result.locales[] | .missing + .changed] | flatten | length')
if [ "$keys" -gt 200 ]; then
echo "$keys pending keys is above the unattended limit; run this by hand" >&2
exit 1
fi
# 4. Act: translate, then report what landed, not what was asked for.
summary=$(verbatra translate --json 2>/dev/null)
status=$?
echo "$summary" | jq -r 'if .ok | not then "run error [\(.code)] \(.message)"
else [ { label: "succeeded", locales: .result.succeeded },
{ label: "partial", locales: .result.partial },
{ label: "failed", locales: .result.failed } ]
| map(select(.locales | length > 0) | "\(.label): \(.locales | join(", "))")
| join(" | ")
end'
exit $statusThe three outcomes on a project with one pending key in de:
de: 1 pending
succeeded: dede: 1 pending
failed: denothing pendingThe first exits 0, the second exits 1 (the provider endpoint was unreachable), the third exits 0 without spending anything. Step 1 is deliberately diff rather than translate --dry-run: both are read-only and neither needs a key, but diff is the question built for exactly this, and its exit code alone already answers whether there is work to do.
The provider key for step 4 comes from the environment, the way it always does. Put it in your CI secret store or your shell environment and let the process inherit it; the CLI takes no key argument and reads none from the config file. See Providers for which variable your provider reads.
Next
- CI and exit codes: the envelope, the exit-code table, and every
resultshape in full. - CLI overview: the shared flags and how the binary reads your environment.
verbatra doctor: the cheapest preflight before an unattended run.- Operate Studio with a browser agent: the same project, driven from an authenticated dashboard tab instead of a shell.
- The SDK: skip the process boundary and call
translate,check, anddiffdirectly from TypeScript.
Review translations in Studio
Start Verbatra Studio, work the needs-review queue, fix entries in place, and retranslate flagged keys from the local dashboard.
Operate Studio with a browser agent
Expose Verbatra Studio's review actions as WebMCP tools so a browser AI agent can drive the same operations from your open, authenticated dashboard tab.