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--jsonOne successful record carries
translateyesone RunSummary
watchyesone RunSummary per run, as NDJSON
checkyesone CheckSummary
diffyesone DiffSummary
doctoryesone DoctorResult
exportyesthe written path and the per-locale row counts
importyesone RunSummary
initno
studiono

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

This needs verbatra 0.9.0 or newer. Earlier releases do not have it, so check your installed version with verbatra --version and upgrade if it is older.

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/null

Keep 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
    ;;
esac

On 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 undefined

Do 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 $status

The three outcomes on a project with one pending key in de:

de: 1 pending
succeeded: de
de: 1 pending
failed: de
nothing pending

The 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 result shape 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, and diff directly from TypeScript.
Edit on GitHub