Agentic translation engine: an LLM translation pipeline with a review/refine loop, native multi-language output, storage-agnostic structured I/O, and translation memory. Exposed via CLI, HTTP API, and MCP.
Production translation needs more than one-shot LLM calls. yaku runs an agentic loop — draft, deterministic quality gates, an independent LLM reviewer, optional back-translation — and refines until the output passes or a budget is hit. Content fragmented across separate DB fields is assembled for full context, translated together, then returned keyed by stable segment ids so the caller writes each piece back to its own field.
@yaku/core— the engine:translate(), Zod I/O schemas, the agentic refine loop, pluggable LLM providers, pluggable SQLite/Postgres translation memory, deterministic gates.@yaku/cli—yaku translate/yaku tmcommands.@yaku/api— HTTP server:POST /translate,GET /health.@yaku/mcp— MCP server exposingtranslate,tm_lookup, andtm_invalidatetools.@yaku/autotune— autonomous optimizer: tunes config knobs + prompt templates to maximize quality (LLM-as-judge on a held-out gold set) while minimizing cost, saving winners as versioned profiles.
pnpm install
pnpm build# request.json = a TranslationRequest
node packages/cli/dist/index.js translate --in request.json --out response.json --provider openai
# or pipe via stdin/stdout:
echo '{"sourceLang":"en","targetLangs":["ja","ko"],"document":{"segments":[{"id":"title","text":"Welcome"}]}}' \
| OPENAI_API_KEY=sk-... node packages/cli/dist/index.js translate --provider openaiFlags:
--source <lang>/--target <langs>(comma-separated) — override the request's source/target languages without editing the JSON.--base-url <url>— endpoint for--provider openai-compatible(required for that provider). Rejected (throws) foropenai,openrouterandmock, which each have a fixed endpoint — a silently-ignored--base-urlwould send the request, and the key, to the wrong place with no indication.--allow-no-api-key— explicit escape hatch: allow--provider openai-compatibleagainst a keyless endpoint (e.g. a local vLLM) with no key set at all. Without it, a missing key throws.--trace <none|summary|full>— override the trace level.--tm <path>— SQLite translation-memory path (default:memory:).--profile-base <dir>— apply the active autotune profile from this directory as config defaults (request values win).
Exit codes: 0 ok, 1 partial, 2 failed. Use --provider mock to validate wiring without an API key.
--provider |
Key env var | Notes |
|---|---|---|
openai |
OPENAI_API_KEY |
Cost estimated from a local pricing table (OPENAI_PRICING). --base-url is rejected (fixed endpoint). |
openrouter |
OPENROUTER_API_KEY |
Cost reported by OpenRouter per call, when reported (see caveats below). Never falls back to OPENAI_API_KEY. --base-url is rejected (fixed endpoint). |
openai-compatible |
YAKU_API_KEY |
Any other OpenAI-compatible endpoint — Groq, Together, a local vLLM — reachable via --base-url <url> (required), with only configuration, no code changes. Never falls back to OPENAI_API_KEY. For a genuinely keyless endpoint (e.g. a local vLLM), pass --allow-no-api-key to explicitly opt in — otherwise a missing key throws. |
mock |
— | Test/smoke provider; throws on use, useful for validating wiring. --base-url is rejected. |
A provider never reads another provider's key: openrouter and
openai-compatible each require their own env var (or an explicit
--allow-no-api-key opt-in for a keyless endpoint) rather than silently
falling back to OPENAI_API_KEY if it happens to be set in your shell.
config.budget.maxUsd can only be enforced for providers that report a dollar
cost — today that's openai (estimated from a local table) and openrouter
(reported by OpenRouter), and only when a cost actually comes back:
- For
openrouter, a missingusage.cost(e.g. some BYOK routing) leaves the call's cost unknown; a warning is logged once andmaxUsdis not enforced for that call. OpenRouter also reports cost in credits, not USD;creditsToUsd(default1.0) bridges the two, but that default is an unverified assumption, not a confirmed rate — verify it against your own OpenRouter billing before relying onmaxUsdas a real spend cap. - The generic
openai-compatibleprovider reports no cost at all (a warning is logged once per run), somaxUsdis never enforced against those endpoints.
Do not treat maxUsd as a guaranteed spend cap for openrouter or
openai-compatible without confirming cost is actually being reported and
priced correctly for your setup.
A run can report an unmeasured cost. Once any call in a language goes
unpriced, that language's summary.cost.usd is absent (not 0) for the
rest of the run — 0 always means a real, measured zero (e.g. a language
served entirely from translation memory); absence means nothing was ever
measured. If you set maxUsd for that language, it never trips — an
unmeasured total cannot be compared against a ceiling — and the response
marks summary.budgetUnenforceable: true so you can tell "the budget was
never in force" apart from "the budget held". This is reachable today with
openrouter (BYOK routing omitting usage.cost) and, routinely, with
openai-compatible (a local endpoint with no pricing to report at all). A
fully-priced openai run is unaffected: usd stays a number and
budgetUnenforceable never appears. All three surfaces (CLI, HTTP API, MCP)
report this identically, since they all return the same translate()
response.
Manage translation memory:
node packages/cli/dist/index.js tm export --tm yaku-tm.sqlite
node packages/cli/dist/index.js tm import --tm yaku-tm.sqlite --in entries.json
node packages/cli/dist/index.js tm invalidate --tm yaku-tm.sqlite --target ja
# wiping everything requires explicit confirmation:
node packages/cli/dist/index.js tm invalidate --tm yaku-tm.sqlite --allOPENAI_API_KEY=sk-... node packages/api/dist/index.js # listens on PORT (default 3000)
curl -s localhost:3000/translate -H 'content-type: application/json' -d @request.jsonEndpoints: POST /translate, GET /health.
OPENAI_API_KEY=sk-... node packages/mcp/dist/index.js # MCP server over stdioTools: translate, tm_lookup (exact TM lookup), tm_invalidate (an unfiltered
invalidate requires all: true).
Both API and MCP servers honor these env vars:
| Variable | Default | Meaning |
|---|---|---|
OPENAI_API_KEY |
— | OpenAI credential (required with openai provider; never used by openrouter or openai-compatible) |
OPENROUTER_API_KEY |
— | OpenRouter credential (required with openrouter provider) |
YAKU_API_KEY |
— | Credential for openai-compatible (required unless YAKU_ALLOW_NO_API_KEY opts into a keyless endpoint) |
YAKU_PROVIDER |
openai |
LLM provider name: openai|openrouter|openai-compatible|mock |
YAKU_BASE_URL |
unset | base URL for openai-compatible (required for that provider); rejected for openai/openrouter/mock |
YAKU_ALLOW_NO_API_KEY |
unset (false) |
set to 1/true to explicitly allow openai-compatible against a keyless endpoint (e.g. a local vLLM) |
YAKU_TM_PATH |
yaku-tm.sqlite |
SQLite translation-memory path |
YAKU_PROFILE_BASE |
unset | autotune dir whose active profile becomes defaults |
PORT (API only) |
3000 |
HTTP listen port |
@yaku/autotune runs an autonomous hill-climb that tunes engine config knobs and
prompt templates to maximize translation quality (LLM-as-judge on a held-out gold
set) while minimizing cost. Winners are saved as versioned profiles the engine
can load.
# build a held-out gold set from the activities dataset (writes autotune/gold/*.json):
node eval/build-gold.mjs --langs ja,ko --limit 5
# (gold files are TranslationRequest-shaped, no config — autotune injects that), then:
OPENAI_API_KEY=$(cat .openai-api-key) \
node packages/autotune/dist/cli.js run \
--profile activities --floor 85 --max-iter 12 --budget 5 --sample 6 \
--langs ja,ko --judge-model gpt-4o --translator-model gpt-4o-mini
node packages/autotune/dist/cli.js profiles # show the active profile
node packages/autotune/dist/cli.js show <runId> # print a run reportOther run flags: --gold <dir> (gold set directory, default autotune/gold),
--base <dir> (profiles/ledger directory, default autotune), --plateau <k>
(stop after K non-improving iterations, default 3). profiles and show also
accept --base.
Outputs: autotune/profiles/<name>-v<N>.json (winner), autotune/profiles/active.json
(pointer), autotune/ledger.jsonl (append-only audit trail), autotune/out/<runId>.md
(report). Use --dry-run to produce the profile + report without activating it.
A winner that fails to reproduce the quality floor on the full gold set is written
but never activated.
The judge model and gold set are fixed/independent of the search so the quality metric can't be gamed; during the search TM is disabled so the gold set is never memorized across candidates.
Input is a TranslationRequest: sourceLang, targetLangs[], a document with segments
(each with a stable id, text, optional metadata like group/order/maxChars/doNotTranslate/role;
role: "ui-label" marks a terse UI label so the expansion gate keeps it from ballooning into a sentence),
optional context, glossary, and config. Output is a TranslationResponse with one
LanguageResult per target language, each carrying per-segment results keyed by the same ids
(with status, sourceHash, tmMatch, confidence, warnings). Every input id appears exactly
once per language.
See docs/superpowers/specs/2026-06-26-yaku-translation-engine-design.md for the full design
and docs/superpowers/plans/2026-06-26-yaku-translation-engine.md for the implementation plan.
pnpm test # run all tests
pnpm typecheck # typecheck all packages
pnpm lint # lint