trans-mgm
Documentation

AI-first translation management

trans-mgm keeps a codebase's UI copy translated across many languages without hand-managing files. You push the English source; a model translates and self-reviews each string; finished translations come back to your repo as a pull request. This page explains how it works and how to use it.

01

How it works

The design rests on one rule: the source language is owned by your repo, translations are owned by the platform, and data flows one way. You never hand-edit a translated JSON file in the repo, and you never edit the English source inside the platform — so the two sides can never conflict.

Data flow
  your repo (web-ui, docs-site, …)              trans-mgm platform
┌──────────────────────────┐   push source  ┌────────────────────────────┐
│ messages/en.json         │ ─────────────▶ │ diff → translate queue     │
│   (source · repo-owned)  │  webhook / CLI │   1. translate  (model A)  │
│                          │                │   2. ICU placeholder gate  │
│ messages/zh.json  …      │ ◀───────────── │   3. AI review  (model B)  │
│   (translations ·        │  auto pull-req │   4. final / needs_attention│
│    platform-owned)       │                │ web UI: browse · edit · retry│
└──────────────────────────┘                └────────────────────────────┘
The pipeline, per string
1
Ingest & diff
A push to your base branch delivers the source automatically (the GitHub App's webhook); the CLI can upload it too. The platform hashes each string and compares against what it last saw, producing added / changed / removed. Only new and changed strings enter the queue — unchanged ones are skipped, so a push is cheap and idempotent. A changed source string resets all of its translations (even human-edited ones) back to pending.
2
Translate
Pending strings are batched and sent to your configured translator model with the key name, your glossary, and a style prompt as context. Output is requested as structured JSON.
3
ICU placeholder gate
Every translation is parsed and its ICU placeholders ({name}, {count, plural, …}, <b>…</b>) must exactly match the source. This is a hard, mechanical check — a translation that drops or renames a placeholder is rejected and retried before any model ever sees it.
4
AI review (optional)
If a reviewer model is configured, a second model judges each translation and, on failure, feeds its note back into a re-translation (up to a few attempts). Strings that still fail land in needs_attention for a human.
5
Deliver
Finished translations are exported and opened as a pull request against your repo (a fixed branch per project, reused across runs). You review and merge — translations ship as plain JSON bundled at build time, so there is zero runtime translation cost or latency.
Durable job runner

Translation runs as a background job that processes one batch, then chains itself to the next, with a lease so two runners never touch the same job. A cron sweep resumes anything that stalls. This keeps long runs alive within serverless time limits.

Manual edits are locked

When you fix a translation by hand it is marked manual and the automated pipeline will never overwrite it — unless the English source itself changes, which resets everything for that key so nothing goes stale.

02

Core concepts

Project

One codebase / message catalog. Holds its source and target locales, the translator & reviewer model config, a glossary, a style prompt, the target repo for PRs, and its CLI tokens.

Key & translation

A key is one source string (dashboard.apiKeys.copy). Each key has one translation row per target locale, each moving through its own status.

Translation status
pendingNot yet processed.
translatingThe model is generating it right now.
finalPassed the ICU gate (and review, if enabled) — ready to ship.
needs_attentionExhausted retries — a human should review the note and fix it.
Job status
queuedWaiting for a runner to claim it.
runningProcessing batches.
doneAll items resolved.
failedErrored — inspect the message and Retry.
03

Quickstart

Onboarding is a joint effort: steps marked operator are done by a platform operator (model config and GitHub wiring involve credentials); everything after that is self-service for your team.

1
Sign in
Open the app and sign in with GitHub. Anyone who belongs to an org (or personal account) the trans-mgm App is installed on gets in; platform operators are env-allowlisted.
2
Grant the App your repo (org admin)
Org → Settings → GitHub Apps → trans-mgm → Repository access → add the repo. This one grant powers both automatic ingestion (push events) and the translation PRs back.
3
Create the project (operator)
From Projects → New project: slug, name, source locale (e.g. en), target locales (e.g. zh, ko, ja), the GitHub owner/repo/base branch/file path template, and the translator model (an OpenAI-compatible base URL, a model name, and the name of the environment variable holding its API key — the raw key is never stored). Optionally a Reviewer model, a glossary (one term = translation per line), and a style prompt. Your team can refine locales, glossary, and style later — those fields are self-service.
4
Import existing translations, then push
Repo already has translated files? Import from repo first so they finalize as-is instead of being re-translated. From here everything is automatic: merge source changes to the base branch and translations come back as a PR. The CLI token (Settings tab, shown once) is only needed for the CLI/CI alternative below.
04

The CLI

Install with npm i -g @0gfoundation/trans-mgm-cli (or run ad-hoc via npx @0gfoundation/trans-mgm-cli …), then add a trans-mgm.config.json to your repo pointing at the platform and your source file:

{
  "apiUrl": "https://trans-mgm.vercel.app",
  "project": "web-ui",
  "sourcePath": "messages/en.json",
  "outputTemplate": "messages/{locale}.json"
}

Provide the token via TRANS_MGM_TOKEN (never commit it), then run one of three commands:

export TRANS_MGM_TOKEN=tm_…

trans-mgm push     # upload the source file → diff → start translating
trans-mgm status   # per-locale progress (final / pending / needs_attention)
trans-mgm pull     # download finished translations → write {locale}.json

push and pull operate on flat or nested JSON, including arrays (e.g. FAQ lists). The source file is the single source of truth for keys — removing a key from it archives its translations.

05

Automate ingestion

Recommended: the GitHub App — your repo carries zero configuration. Once the trans-mgm App can see your repo, every push to the project's base branch is ingested automatically: the platform reads the source, translates only the diff, and opens (or updates) a pull request with the new translations. No repo webhook, no CI job, no secrets on your side.

One-time App access (org admin)

Org → Settings → GitHub Apps → trans-mgm→ Repository access → add your repo. That single grant covers everything: push events reach the platform through the App's own webhook, and the platform reads sources and opens PRs with a short-lived, repo-scoped installation token — no personal tokens involved.

The project's Settings must have the GitHub owner/repo, base branch, and locale file path template filled in — deliveries are matched to projects by repo + branch.

Source layout: one file, or a shards directory

By default the source is the single file at the path template (e.g. messages/{locale}.json with the source locale filled in). Large catalogs can instead set Source shards directory in Settings (e.g. messages/en): the platform reads every *.json in it and merges them with filename = top-level namespace — so developers edit small per-namespace files and no merged file needs to exist in the repo at all.

When ingestion fails

A failed ingest (malformed JSON, refused mass-archive) never advances the change cursor — the next push or a manual Sync from reporetries the same content. The failure shows as a red banner on the Keys tab, appears in the Jobs tab's ingest history, and pings the ops Slack channel if configured.

Alternative: push from CI with the CLI — useful when the App can't be granted the repo or the source needs a build step first (e.g. merging shards).

# .github/workflows/i18n.yml
name: i18n sync
on:
  push:
    branches: [main]
    paths: ['messages/en.json']
jobs:
  push-source:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npx -y @0gfoundation/trans-mgm-cli push
        env:
          TRANS_MGM_TOKEN: ${{ secrets.TRANS_MGM_TOKEN }}

For the platform to open the PR back, the project must have its GitHub repo configured and the App must have access to it (the installation token carries Contents + Pull-requests permissions). Deployments without the App fall back to a server-side GITHUB_PAT.

06

Working in the web UI

Key browser

Filter by locale, status, namespace, or free-text search — including archived keys. Each row shows the key, the English source, and the translation with its status badge. Edit a translation inline (Save marks it manual), Re-translate a single row, or Pin it so a source change never auto-queues it for retranslation.

Repo actions

Import from repobackfills existing translations from the repo's locale files at onboarding (never overwrites platform finals; idempotent). Sync from repo re-ingests the source right now — the manual counterpart of the webhook. Sync now opens or updates the translation PR with the current finals.

Needs-attention queue

The key browser pre-filtered to needs_attentionrows. Each shows the reviewer's note so you know why it was flagged before fixing it.

Jobs

Every push/translate/PR run appears here with its status and stats. A failed job can be retried in place — a translate retry only reprocesses the items still pending.

07

Scenarios

Every situation you can hit, and exactly what the platform does about it. The through-line: the source file decides which keys exist, and a translation ships only once it has passed the ICU gate — everything below is the system protecting those two rules.

When the source changes
First push (empty project)Every key counts as added; one pending row is created per target locale and a full translation run starts.
A new key is addedOnly the new key is queued. Every existing translation is left exactly as it was.
An English string is editedThe hash changes, so the key is changed: all of its translations — including hand-edited manual ones — reset to pending and re-translate. Nothing stale survives a source edit, with one exception: pinned rows keep their human-approved value (unpin or re-translate explicitly when ready).
A key is deleted from sourceIt is archived, not destroyed. Its last good translation still exports, so deleting a key mid-run can never blank out a shipped string; the key is just excluded from new work.
A deleted key comes backIt is revived. If its text changed while it was gone it re-translates; if it is identical, the old translations return untouched.
Reorder / reformat / re-push unchangedUnchanged hashes are skipped, so a push is idempotent — pushing the same file twice does no work and starts no job.
Nested objects & arraysThe source is flattened (arrays included, e.g. an FAQ list); each leaf string becomes one key.
Locales & projects
Add a target localeEach existing key gains a fresh pending row for the new locale; it fills on the next run without touching the locales already done. Repo already has translations for it? Save, then Import from repo — existing values finalize as manual and only the gaps are machine-translated.
Remove a target localeIts rows turn inert, not deleted: no more translation work, no export, no stats. The platform never deletes repo files — remove the locale file and your routing config yourself. Re-add the locale later and the existing finals revive instantly; nothing re-translates.
Several codebasesEach is its own project, with independent source / target locales, translator & reviewer models, glossary, style prompt, target repo, and CLI tokens.
Who can see a projectEvery member of the org that owns the project's repo, by default. An operator can narrow a project to a member allowlist (Settings → Access): only the listed GitHub logins — plus platform operators — can see or touch it. Malformed entries are refused at save time, so a typo can never silently lock the whole team out.
Operating many projects at onceOperators get a cross-project Ops view (header link): LLM usage and translation state per project, stuck jobs, recent ingest errors, and the alert memory — one place to spot trouble before a tenant does.
How one string resolves
Clean translationpendingtranslating → ICU gate → (review, if enabled) → final.
A placeholder is dropped or renamedThe ICU gate rejects it mechanically — before any reviewer model sees it — and it retries. If it keeps failing it lands in needs_attention.
The model returns empty textAn empty or whitespace-only translation is rejected and stays pending for retry — never shipped as a blank.
The reviewer rejects itThe reviewer's note is fed back into a re-translation, for a few attempts.
The reviewer says nothingA missing verdict for a string that already passed the ICU gate is treated as a pass — a silent reviewer never blocks a valid translation.
The translator endpoint errorsA timeout or 5xx releases just that batch back to pending (attempt count +1); the job itself does not fail and resumes on the next batch or cron sweep.
Retries run outThe string stops at needs_attention with a note — a soft stop for a human, never a hard job failure.
Human calibration
Edit a translation by handSave marks it manual and locks it; the automated pipeline will not overwrite it.
…then the source changesThe lock yields to the source of truth: a changed English string resets even a manual translation, so it can never drift out of sync.
Re-translate one rowClears the manual lock and sends just that string back through the pipeline.
Requeue from needs-attentionMoves the string back to pending to be reprocessed. Fixed the glossary or style prompt and want the whole backlog re-tried? Retry all on the needs-attention view requeues everything as fresh attempts in one job.
Pin a reviewed translationPinlocks a row against automatic retranslation — even a source edit won't queue it. For legal or brand copy where a human must stay in the loop.
You already had translations before onboardingImport from reporeads each target locale's existing file and finalizes every non-final row from it — no re-translating what your team already shipped, and platform finals are never overwritten.
A full key rename is refused (mass-archive guard)Automated ingestion refuses a push that would archive most active keys — it usually means a truncated file. When the restructure is deliberate, Sync from repo offers a one-click Force sync after the refusal.
Ship your manual editsHand edits do not open a PR on their own. Hit Sync nowon the Keys tab to open or update the project's PR with the current translations whenever you like — or simply let the next translate run carry them along. Either way, every edit folds into the one reused PR.
Delivery & sync
A run finishesFinished translations export and open a pull request against the project's fixed branch.
Re-run with no net changeThe target branch's current tree is compared; if nothing differs the PR step is a no-op — no empty commits, no PR churn.
A PR is already openIt is updated in place. Only one open translation PR exists per project at a time, so runs never pile up duplicates. The PR body always carries the latest sync's key-level increment ("+3 new, ~5 updated" per locale) so reviewers see what changed without reading a full-file diff.
Your team wants to know when translations landSet a Team Slack webhook in Settings: whenever the translation PR is opened or updated, that channel gets the increment summary and the PR link.
Gate a release on translation completenessGET /api/v1/projects/<slug>/readiness (project token auth) returns {ready, total, final, pending, …}ready is true only when every active key is final in every targeted locale. Curl it in CI before promoting a deploy.
trans-mgm pullWrites only final translations into {locale}.json — pending or flagged strings are never pulled.
No repo or token configuredTranslation still completes in the platform; you fetch the results with pull instead of receiving a PR.
Branches & go-live
Which pushes trigger ingestionOnly pushes to the project's Base branch. That one setting is both where the source is read from and where the translation PR targets — usually your default branch.
Pre-launch work on a feature branchPoint Base branch at the feature branch: source changes there translate normally and the PR comes back to that same branch, leaving the default branch untouched until launch.
The feature branch merges (go-live)Update Base branch to the default branch in Settings. This is the one manual step in the branch lifecycle — from then on "merge to default → translate → PR back" is fully automatic.
…and if you forgetNothing fails silently: a push that changes the source catalog on the repo's default branch while Base branch still points elsewhere fires a stale-base-branch ops alert, and Settings shows a standing warning while the mismatch lasts.
Concurrency & recovery
Two runners or a double triggerA per-job lease and the one-open-PR guard mean the same work is never done twice.
A serverless time limit is hitThe job chains batch to batch to stay within the limit, and a cron sweep resumes anything that stalls.
A runner dies mid-batchThe rows it had locked are reclaimed by id; if a superseded run's late write arrives it is dropped, and its item is marked done so the job's stats stay correct.
08

Reference

CLI config keys
apiUrlPlatform base URL.
projectProject slug on the platform.
sourcePathPath to the source (English) JSON file.
outputTemplateWhere pulled translations are written; must contain {locale}.
HTTP API (Bearer project token)
POST /api/v1/projects/:slug/pushUpload the flattened source catalog (the CLI uses this).
GET /api/v1/projects/:slug/pullFetch final translations per locale.
GET /api/v1/projects/:slug/statusPer-locale status counts and the latest job.
GET /api/v1/projects/:slug/readinessRelease gate: ready=true when every active key is final in every targeted locale.
Server environment
DATABASE_URLPostgres connection string.
AUTH_GITHUB_ID / _SECRETGitHub OAuth app for sign-in.
ALLOWED_GITHUB_LOGINSComma-separated platform operators (admin rights). Regular sign-in only requires membership of an account the App is installed on.
GH_APP_ID / GH_APP_PRIVATE_KEYThe GitHub App identity: mints short-lived, repo-scoped installation tokens for reading sources and opening PRs.
GH_APP_WEBHOOK_SECRETSigns the App’s push-event deliveries.
LLM_KEY_* Model API keys, referenced by a project’s apiKeyRef by name.
APP_URL / INTERNAL_SECRETSelf-address and shared secret for the internal job-runner endpoint.
CRON_SECRETAuthorizes the resume-jobs cron (fails closed when unset).
SLACK_WEBHOOK_URLOps channel for health alerts (stuck jobs, ingest failures).
GITHUB_PAT / GITHUB_WEBHOOK_SECRETLegacy fallbacks for deployments without the App: a personal token and a per-repo webhook secret.