# Lingo.dev > Lingo.dev is an AI-powered localization engine that turns LLMs into stateful translation APIs – producing consistent, production-grade translations for apps, docs, and content across every language. ## Blog - [Enterprises pay a coordination tax on localization](https://lingo.dev/en/blog/multi-team-localization-tax): Managing shared glossaries, aligning terminology across teams, and carrying the cost of ever migrating. An organization-scoped localization engine with retrieval at inference time, and forward-deployed localization engineering from the Lingo.dev team, reduce the tax. Every enterprise we talk to hits the same two walls. The first is the coordination tax on consistency. Your Android app is built by one team. Your web app by another. Your marketing site, your docs, your internal tooling – each owned by a different team, each with its own release cadence, its own reviewers, its own shipping pipeline. Legacy tools can share translation memory and glossaries across projects. Workspaces exist. Org-level assets exist. But shared is not enforced. A term in the shared glossary is a suggestion to the translator, not a constraint on the model. Consistency across teams becomes a discipline. Someone keeps the glossary aligned. Someone resolves the term-of-art conflicts between teams. Someone chases the team that translates a call-to-action one way while another team ships it differently. The consistency is possible. The maintenance is continuous. Inside each team's project, the drift compounds further. Translation memory holds consistency as long as segments don't change. In a codebase that refactors every week, segments change every week. Our [RAL research](/research/retrieval-augmented-localization) measures how fast terminology drifts when the model has no retrieved context. The second wall is the cost of ever leaving the tools that produce the tax. In an enterprise, every dimension multiplies – glossaries accumulated across teams, translation memory built up in TMX and vendor-proprietary formats across projects, connectors wired into each team's CI, translator rosters negotiated through procurement, SSO integrated with the IdP. The migration reads as a multi-quarter engineering program no localization manager wants to own. Two architectures fit the multi-team shape. One replaces project-scoped translation memory with an organization-scoped localization engine that retrieves context at inference time. One glossary, one brand voice, every team's app pulls from the same localization engine. The other replaces customer-runs-the-migration with a forward-deployed localization engineer from Lingo.dev who does the migration on our clock, not yours. Both patterns already run every other piece of infrastructure in your stack – now, we want localization to finally catch up. ## Architecture #1: the localization engine {% callout type="info" title="Localization engine" %} A stateful translation API that teams create on Lingo.dev, configured per organization. Each localization engine persists its own glossary, brand voice, locale-specific instructions, and ranked model chain. Every request retrieves matching glossary terms, injects them into the model's context window before the first token is generated, and is independently scored after completion. The first translation benefits from zero context; the thousandth benefits from everything. {% /callout %} A [localization engine](/docs/platform/engines) holds consistency at the term level, not the segment level. It is scoped to your organization, not to any single team's project. One glossary, one brand voice, every team's surface pulls from the same localization engine. A [glossary](/docs/platform/glossaries) entry for "Submit" fires on every Spanish surface – button, email subject, tooltip. Web team or mobile team, it does not matter. Retrieval matches meaning, not strings. One entry for "Deploy" fires on "deploying", "deployment", "Deploy your app" – no separate entry for each form. A [brand voice](/docs/platform/brand-voices) is attached to the localization engine per locale. Every request uses it. [Instructions](/docs/platform/instructions) are discrete, testable rules scoped to a locale. Abbreviation conventions, non-breaking spaces, quotation marks – each debuggable on its own. A [model chain](/docs/platform/llm-models) routes each request to the primary model with ranked fallbacks. Swap providers without touching the glossary. An [AI reviewer](/docs/platform/ai-reviewers) runs on an independent model. It scores every request against the glossary and each instruction separately. Pass/fail with reasoning, tracked as a time series. | Concern | Project-scoped tooling | Organization-scoped localization engine | | --- | --- | --- | | **Scope of consistency** | Per project, per team | Per organization | | **Consistency unit** | Whole segment, keyed by hash | Individual term, matched semantically | | **Survives source rewrites** | No | Yes | | **Cross-app, cross-team** | Discipline; humans keep it aligned | Architectural; the localization engine keeps it aligned | | **Quality measurement** | Rule-based checks (tags, numbers) | Per-request LLM scoring | | **Model flexibility** | Provider lock | Ranked chain | | **Authority over output** | Translator discretion | Glossary overrides model | Drift becomes a condition you can measure, not a condition you absorb. The glossary fires on every request. The AI reviewer verifies compliance per request. The named mechanism is [**retrieval augmented localization (RAL)**](/research/retrieval-augmented-localization). At inference time, the engine decomposes the input into n-gram phrases, embeds them, and runs cosine similarity search against the glossary's vector index. Matched terms go into the model's context window before the first token is generated. Structurally identical to RAG, applied to translation. In a controlled evaluation across multiple LLM providers and multiple European languages, [RAL reduced terminology errors by 17–45%](/research/retrieval-augmented-localization). 42,000+ paired quality judgments. Holm-Bonferroni corrected p < 0.001 on every provider. Holistic quality scores could not detect the gap at all. ## Architecture #2: forward-deployed localization engineering The second wall is migration. You have a working stack. It produces the tax, but it works. The cost of replacing it – engineering time, integration rework, translator reonboarding, historical data migration – consistently exceeds the cost of paying the tax. That calculation is why the tax still gets paid. After watching the same migration bottleneck block serious enterprises from moving, we decided to absorb the migration ourselves. When Lingo.dev onboards an enterprise, our engineers do the migration. Not as a professional-services contract layered on top of the license. As the default onboarding path. A forward-deployed localization engineer reads your glossary, your brand-voice documents, your connector configuration, your translator contracts. They import your translation memory from TMX and your glossary from whatever legacy format it lives in. Nothing gets re-derived. They build the localization engine on Lingo.dev with your terminology preloaded. They wire it into your CI. They pipe your translator roster through the async pipeline so the humans you trust stay in the loop. The multi-team case is where the architecture pays off. In the legacy version, aligning terminology across teams means N synchronized migrations – each team re-deriving keys and TM inside its own project. Here the localization engine is built once. Each team wires its app to it on its own cadence. Cross-app consistency shows up on the first locale that hits the engine, not after every team finishes its own migration. Our engineers stay with you through your next deployment to production, and the one after that, until your internal team owns the system. This is how we onboard enterprise customers. By the time a multi-team org is shipping every week, translation cannot be a procurement ticket between buyer and vendor. It has to run alongside your next deployment to production, not after it. Forward-deployed engineering is how Palantir, Scale AI, Ramp, and other infrastructure vendors have onboarded enterprise customers for over a decade. Now, we want localization to finally catch up. {% steps %} {% step title="Audit" %} A Lingo.dev engineer reads your source repos, your existing TM (including TMX exports), your glossary, your connectors, and your translator contracts – across every team that owns a surface. They produce a migration plan with order and timeline. You own the plan. {% /step %} {% step title="Engine built to match your current quality" %} We configure the localization engine with your imported glossary, your brand voice per locale, and your translator pipeline. Before any production traffic, we run a side-by-side comparison – your current tool's output versus the engine's, same strings, same week. You decide whether the quality holds. {% /step %} {% step title="Wired into each team's CI" %} No rip-and-replace. The localization engine runs as one step in each team's existing pipeline. Merge flows, review flows, reviewers – all stay the same. The engine replaces the old step. {% /step %} {% step title="Cutover at your cadence" %} One team, one locale pair first. Then three. Then the rest. You choose the order. We run the comparison at each step. Rollback is one commit. {% /step %} {% step title="Transfer to your team" %} Our engineer hands the system off to your platform team – docs, runbooks, and an on-call rotation we cover until they take it over. {% /step %} {% /steps %} ## Evidence **Research.** The [RAL study](/research/retrieval-augmented-localization): 42,000+ paired quality judgments across multiple LLM providers and multiple European languages. Holm-Bonferroni corrected p < 0.001 on every provider. Terminology error reduction ranged 17–45%. **Configuration over model choice.** We found that across Mistral, Gemini, Claude, GPT – any model + a good glossary, brand voice, and context setup consistently produces shippable, reference-quality translations at a fraction of the cost. Not because we improved the model. On every request, the localization engine retrieves the matching glossary terms, brand voice, and locale instructions by similarity search, and injects them into the model's context window before the first token is generated. **Production scale.** 200M+ words translated on the platform. **Named customers.** Mistral, Solana, SoSafe, Cal.com. ## Scope Lingo.dev serves localization teams of many shapes – single-product companies, open-source projects, mobile-only teams, enterprise platforms. The architecture described here is the one tuned for enterprises with several teams shipping several apps across 20+ locales. ## What happens next The first step is a two-week pilot. One team, one locale pair. A forward-deployed localization engineer sits with your localization owner and your engineering lead. We study your workflow. We set up a measurement system so you can see the quality of translations in languages your team does not speak – AI reviewers running on independent models, scoring each translation against your glossary and your rules. The scoring is adapted from MQM, the standard framework for translation quality evaluation. We build the localization engine against your glossary and your brand-voice documents. We run it on your source content, side-by-side with your current tool. You see the delta and decide. From there, we schedule the migration for the remaining teams and locales on your clock, not ours. [Talk to our localization engineering experts today](https://cal.com/team/lingodotdev/talk). ## Next Steps {% card-grid %} {% link-card title="Localization engines" href="/docs/platform/engines" description="The stateful translation API – glossary, brand voice, instructions, model chains, AI reviewers" icon="gear" /%} {% link-card title="RAL research" href="/research/retrieval-augmented-localization" description="How retrieval at inference time cuts terminology errors 17-45% across multiple LLM providers" icon="book" /%} {% link-card title="The Localization API" href="/blog/the-localization-api" description="One POST, any number of target locales, results via webhook" icon="code" /%} {% link-card title="Async API reference" href="/docs/api/localization" description="Full endpoint documentation with examples" icon="rocket" /%} {% /card-grid %} - [The Localization API](https://lingo.dev/en/blog/the-localization-api): Professional localization - source refinement, glossary enrichment, quality scoring, and optional human review - encapsulated behind a single POST. One request, any number of target locales, results via webhook. Before 2010, accepting a payment online meant applying for a merchant account with a bank. Weeks of paperwork. Credit checks. Minimum transaction requirements. Then you integrated with a payment gateway through an XML API where the test environment barely resembled production. You handled PCI compliance yourself - storing card numbers, managing encryption keys, passing security audits. Today, a developer drops a few lines of code into a checkout page. Compliance, fraud detection, currency conversion, payouts - handled behind the API. The developer never sees the complexity. The complexity didn't go away. It got encapsulated. This keeps happening. A category of professional work - previously requiring specialists, vendors, and months of coordination - gets compressed into an API call. The call is simple. What runs behind it is not. ## Professional localization before the API Localizing a product into multiple languages meant hiring a translation vendor. The vendor assigned translators - often unfamiliar with the product, the domain, or the existing terminology. You sent a terminology guide. The translator read it, mostly. Translations came back in 5-10 business days. Three terms were wrong. You sent them back for revision. Another 3 days. Meanwhile, someone needed to manage the glossary, track which strings changed since the last batch, verify that the German translations used the right quotation marks, and confirm that the Portuguese output used European spelling, not Brazilian. This coordination happened across spreadsheets, emails, and Slack threads. For teams that tried to automate with LLMs - the translation was fast, but the quality assurance wasn't. Every request started from zero. No memory of approved terms. No awareness of brand voice. No verification that the output matched the domain's terminology conventions. Then LLMs crossed the quality threshold for production translation. Not because they got better at languages — because it became possible to [inject domain context at inference time](/research/retrieval-augmented-localization) and get consistent, terminology-accurate output. The missing piece was the context pipeline around the model. ## Professional localization behind the API One POST. Results arrive via webhook as each language completes. ```bash curl -X POST https://api.lingo.dev/jobs/localization \ -H "X-API-Key: $LINGO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sourceLocale": "en", "targetLocales": ["de", "fr", "ja", "ko", "pt-BR", "es", "it", "zh-Hans", "nl", "sv", "pl", "tr", "ar", "th"], "data": { "title": "Introduction to Machine Learning", "steps": [ { "heading": "What is ML?", "body": "Machine learning is a subset of artificial intelligence." }, { "heading": "Supervised Learning", "body": "Training a model with labeled data." } ] }, "callbackUrl": "https://your-app.com/webhooks/translations" }' ``` 202 back in milliseconds. The caller is free to continue. Each language processes independently through the localization engine. German arrives in 4 seconds. Japanese in 6. Arabic in 8. Each result hits your webhook the moment it's ready. For real-time progress in your UI, connect a [WebSocket](/docs/api/localization) to the job group - "3 of 16 languages ready" updating live. The developer never manages the complexity. The complexity didn't go away. It got encapsulated - just like payments did. ## What runs behind the API When a localization job starts, the platform runs a multi-step pipeline through the engine's configuration. Six steps, each addressing a specific failure mode that surfaces when you skip it. {% steps %} {% step title="Source refinement" %} An AI agent pre-edits the source text before translation begins. Ambiguous phrasing, inconsistent terminology, culturally loaded idioms - rewritten for translatability. This eliminates the garbage-in, garbage-out problem that degrades every downstream step. {% /step %} {% step title="Context enrichment" %} The engine retrieves the glossary, brand voice, and locale-specific instructions configured for this language pair. Matched glossary terms are injected into the LLM's context window. The model sees the correct term mapping before generating a single token. This is [retrieval augmented localization](/research/retrieval-augmented-localization) — the step that [reduces terminology errors by 17-45%](/research/retrieval-augmented-localization) across providers. {% /step %} {% step title="LLM translation" %} The engine selects the highest-priority model for this locale pair from the [configured fallback chain](/docs/platform/llm-models). If the primary model fails, the engine routes to the next ranked model automatically. The caller never sees the failover. {% /step %} {% step title="Human post-editing" %} Optional. A qualified translator reviews and corrects the AI-generated draft — focusing on what the model got wrong, not translating from scratch. The platform handles translator sourcing and matches to content domain; results arrive through the same webhook. No vendor management, no approval step in the UI. Turnaround: hours, not weeks. {% callout type="info" title="Enterprise" %} Human post-editing is available in private beta on enterprise plans. [Contact us](https://cal.com/team/lingodotdev/talk) to enable it for your engines. {% /callout %} {% /step %} {% step title="AI post-editing" %} Optional. After the human edit, an AI agent applies a final consistency pass. Formatting validation, glossary re-verification, tone alignment with brand voice - preserving the human translator's intent while enforcing engine-wide standards. The human improves accuracy. The AI enforces consistency. {% callout type="info" title="Enterprise" %} AI post-editing is available in private beta on enterprise plans. [Contact us](https://cal.com/team/lingodotdev/talk) to enable it. {% /callout %} {% /step %} {% step title="Back-translation verification" %} Optional. An independent model translates the output back into the source language. The agent compares the back-translation to the original source, flags semantic divergence, and adjusts segments where meaning shifted during translation. This catches errors that forward-only evaluation misses. {% callout type="info" title="Enterprise" %} Back-translation verification is available in private beta on enterprise plans. [Contact us](https://cal.com/team/lingodotdev/talk) to enable it. {% /callout %} {% /step %} {% /steps %} Each step is configurable per engine. Source refinement, human post-editing, AI post-editing, and back-translation can be enabled or disabled independently. Context enrichment and LLM translation are always on - they are the core of the engine. A team localizing marketing copy might enable all six steps. A team localizing internal documentation might run context enrichment and LLM translation only. The API call is the same either way. The pipeline adapts. ## Delivery Results arrive through the channel you configured - no polling required. **Webhooks** - each language is delivered the moment it completes. A German translation that finishes in 4 seconds arrives immediately, without waiting for Japanese to finish in 6. Every webhook includes a cryptographic signature following the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) spec. Failed deliveries retry with exponential backoff up to 5 attempts. **WebSocket** - connect to a job group for real-time progress. Every event includes a full state snapshot: total jobs, completed jobs, failed jobs, per-language status. Your frontend never maintains local state - the server pushes the current truth on every event. **Failure isolation** - if Japanese fails but German succeeds, the German translation is delivered normally. The failed job appears with an error message. The group status becomes `partial`. Retry by submitting a new request with only the failed locales. ## What this means Compliance, terminology governance, domain adaptation, human review, quality scoring - handled behind the API. The developer sends content and target locales. The localization manager configures the engine. The platform runs the workflow. The coordination that used to require a project manager, a vendor relationship, and a shared spreadsheet now happens inside a durable background workflow with webhook delivery, failure isolation, and real-time progress streaming. If your localization need is a one-time document translation with no consistency requirements across releases, the API is overbuilt for your needs. The engine's value compounds over time - glossary terms accumulate, brand voice sharpens, quality scores trend upward. Lingo.dev is purpose-built for products that ship continuously, where the same content changes every sprint and consistency across locales is non-negotiable. [Read the full API reference](/docs/api/localization), or [book a demo](https://cal.com/team/lingodotdev/talk) to see the pipeline in action. ## Next steps {% card-grid %} {% link-card title="Async API reference" href="/docs/api/localization" description="Full endpoint documentation with examples" icon="code" /%} {% link-card title="Localization engines" href="/docs/platform/engines" description="Configure the pipeline behind the API" icon="gear" /%} {% link-card title="RAL research" href="/research/retrieval-augmented-localization" description="How context enrichment reduces errors 17-45%" icon="book" /%} {% /card-grid %} - [Introducing Lingo.dev v1.0](https://lingo.dev/en/blog/introducing-lingodotdev-v1): After proving that retrieval augmented localization reduces LLM terminology errors 17-45%, we built Lingo.dev v1.0 - a localization engineering platform where teams create stateful localization engines with per-locale models, glossaries, and quality scoring. {% text-banner title="Lingo.dev v1.0" emoji="🎉" /%} Every localization team knows the pattern. Translators unfamiliar with the product get brand terms wrong. LLM wrappers have no memory across requests. After a few releases, terminology drifts - and nobody notices, because holistic quality scores say 0.95 and move on. We measured the gap. [Injecting glossary context at inference time reduces terminology errors by 17-45%](/research/retrieval-augmented-localization) across five LLM providers and five European languages. We call this retrieval augmented localization (RAL). LLMs crossed the quality threshold for production translation - but only with the right context pipeline. Without it, every isolated request is a fresh opportunity for terminology drift. At Lingo.dev, after providing localization infrastructure for translating 200M+ words for customers like Mistral, Solana, SoSafe, and Cal.com, we built `v1.0` around it. ## Localization engine A [localization engine](/docs/platform/engines) is a stateful translation API on Lingo.dev - a RAL implementation that persists domain context across every request. Configure once, call everywhere: - **[Models](/docs/platform/llm-models)** - pick any model from the OpenRouter catalog. Rank models per locale with fallback chains - when the primary model is unavailable, the engine routes to the next without dropping the request. - **[Glossary](/docs/platform/glossaries)** - map source terms to target translations per locale pair. The engine injects matching terms into every request. When Cal.com's engine encounters "Workspace," the glossary resolves it before the LLM generates a single token - no translator onboarding required. - **[Brand voice](/docs/platform/brand-voices)** - define tone and register per locale. Formal for German legal content, conversational for Japanese marketing, technical for English documentation. - **[Instructions](/docs/platform/instructions)** - set per-locale rules for specific patterns: French elision rules, Portuguese spelling conventions, German quotation marks, Italian anglicism preferences. The engine is stateful. Every glossary term, every brand voice rule, every instruction persists across requests. The first translation through a new engine benefits from zero context. The thousandth benefits from everything the team has configured since day one. In diff-based CI/CD workflows, each build retranslates only what changed. The engine's statefulness is what prevents terminology drift - the same drift that plagues both human translators and stateless LLM wrappers. Test configurations in the [playground](/docs/platform/playground) before they go live. Try a string, compare across locales, push to production when it's right. ## How you call it Three integration paths. Same engine, same context, same quality: **CLI** - point at your repo, translate all configured locales in one command: ```bash npx lingo.dev@latest run ``` **CI/CD** - the [GitHub integration](/docs/integrations/github) opens a pull request with translated strings on every push. Review translations in the diff, merge when ready. No handoffs. No waiting for an external team. **API** - call the engine directly from backend code: ```bash curl -X POST https://api.lingo.dev/process/localize \ -H "X-API-Key: $LINGO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "engineId": "eng_abc123", "sourceLocale": "en", "targetLocale": "es", "data": { "welcome": "Welcome to the future of payments", "cta": "Get started" } }' ``` Every request is logged: model used, tokens consumed, fallback status, glossary terms applied, instructions matched. The [reports dashboard](/docs/platform/reports) surfaces word volume, token consumption, top locales, glossary coverage, and change rates - so you can see exactly which terms are being enforced and where coverage gaps remain. ## Model freedom Terminology consistency shouldn't depend on which LLM vendor you happen to use. RAL separates the consistency layer - glossary, brand voice, instructions - from the model that executes the translation. The enrichment context steers any model toward domain-accurate output. Swap GPT-5.4 for Claude Opus between releases without reconfiguring a single glossary term. This separation also eliminates vendor lock-in. Closed-model translation APIs bind glossary, style rules, and terminology to one provider. A model regression means a support ticket. A better model from another provider is out of reach. On Lingo.dev, the model is a configuration parameter - the consistency layer persists regardless of which model runs underneath. When we [tested five providers on the EU AI Act](/research/retrieval-augmented-localization), Mistral with a 72-term glossary (MQM 0.940, where 1.0 is error-free) approached Google's raw quality (0.938). The cost difference is an order of magnitude. A well-built glossary reduces how much intelligence the model needs. As glossaries mature, the platform flags when a cheaper model would match the same quality threshold. ## Quality scoring Fixing terminology drift is only half the problem. The other half: standard quality metrics can't even see it. Our [research](/research/retrieval-augmented-localization) found that holistic translation quality scores - the kind used by benchmarks and leaderboards - reported identical scores for raw and glossary-augmented translations, while dimensional scoring counted 17-45% fewer terminology errors. The gap that RAL closes is invisible to the metric the industry uses to measure translation quality. A problem you can't measure is a problem you can't fix. Lingo.dev v1.0 ships with [AI Reviewers](/docs/platform/ai-reviewers) - automated quality checks that evaluate each translation by dimension, not by a single holistic number. Define criteria in natural language: "Are all HTML tags preserved?" or "Rate naturalness for a native speaker." An independent LLM evaluates the output - if GPT-5.4 translates, Claude Sonnet scores, eliminating self-assessment bias. Cross-model, dimension-specific evaluation catches what holistic scores miss: hallucinated terms, shifted register, broken placeholders, and the terminology drift that accumulates silently across releases. ## What this means **If you're already using Lingo.dev** - the CLI, CI/CD, and MCP tools keep working as before. `v1.0` adds engine configuration: model selection, fallback chains, brand voice, glossaries, quality scoring. All per locale. All from the dashboard. Zero migration. **If you're new to Lingo.dev** - [create a free developer account](/orgs/~) and get a pre-configured localization engine. First translated build in 4 minutes with the [CLI](/docs/cli/setup), or integrate via the [API](/docs/api). Your next release ships to every locale with consistent terminology. The glossary you build in week one enforces every brand term across every language, every build - the configuration cost is front-loaded, not multiplied per translation. When a developer changes a paragraph, the CI pipeline retranslates just that paragraph - same engine, same glossary, same quality threshold. Translation stops being a handoff. It becomes a build step. Lingo.dev v1.0 is purpose-built for engineering teams, localization managers, and product leads who need localization to behave like infrastructure. Teams that prefer coordinating human translators through manual review rounds may find traditional localization platforms a better fit. [Create a free developer account](/orgs/~) to get started, or [book a demo](/demo). RAL is to localization what RAG is to generation. Lingo.dev v1.0 is where you run it. ## Next steps {% card-grid %} {% link-card title="Create an engine" href="/docs/platform/engines" description="Configure your first localization engine" icon="gear" /%} {% link-card title="API reference" href="/docs/api" description="Integrate via the localization API" icon="code" /%} {% link-card title="RAL research" href="/research/retrieval-augmented-localization" description="The study behind v1.0" icon="book" /%} {% /card-grid %} ## Changelog - [W24 – Rephrase: a new stage in the localization pipeline for natural-sounding copy](https://lingo.dev/en/changelog/2026-w24): Rephrase is a new optional stage in the async localization pipeline. After the core localization step, an AI agent rewrites the output to read like native, idiomatic copy in the target locale – while keeping placeholders, variables, tags, and formatting exactly as-is. The [async localization pipeline](/docs/api/pipeline) has a new optional stage: [**rephrase for natural copy**](/docs/api/pipeline#rephrase-for-natural-copy). It runs right after the core localization step – an AI agent rewrites the translation so it reads like native, idiomatic copy in the target locale, preferring local equivalents over literal renderings while keeping placeholders, variables, tags, and formatting exactly as-is. It applies your engine's [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) the same way the core localization step does. Downstream stages – [human review](/docs/api/pipeline#post-localization-human-review), [post-edit](/docs/api/pipeline#post-localization-ai-review), [back-translation](/docs/api/pipeline#back-translation-check) – see the rephrased output and continue from there. Configure it on the engine's **Pipeline** tab, or [override per job](/docs/api/pipeline/configure) through `pipelineConfig.rephrase`. Best fit: marketing copy, landing pages, and other surfaces where reading like a native original matters more than staying close to the source phrasing. Skip it for technical or legal content where literal accuracy is the priority. - [W23 – Human-in-the-loop for in-house AI translation review](https://lingo.dev/en/changelog/2026-w23): Human-in-the-loop review is now a pipeline stage your own team can run – in-house translators and language experts review, edit, and approve AI translations directly in the Lingo.dev dashboard, instead of an external translation provider. The [async localization pipeline](/docs/api/pipeline) includes human-in-the-loop review – an optional stage where the job pauses until a human edits or approves the AI translation. Until now, that stage routed to an external network of professional translators. It can now route to your own team. Set the review provider to **Internal reviewers** on an engine, and pending translations land in a review queue inside the dashboard instead of leaving your organization. Reviewers are organization members granted the review permission through [roles and permissions](/docs/platform/rbac). When a job reaches the human review stage, every reviewer with access to that engine gets an email and an in-app notification. Opening a review shows the source next to the AI translation – approve it as-is, or edit the translation inline and submit. The job resumes with the reviewer's output and continues through any remaining pipeline stages. A claim lock keeps two reviewers from editing the same job at once. The stage's timeout works the same as with external review: if nobody responds in time, the job continues with the AI translation as the final output. This closes a gap for teams with translators on retainer or in-house language experts – they review inside Lingo.dev, with the engine's [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) visible as context, instead of receiving files through a separate handoff. - [W22 – The Lingo.dev GitHub App: continuous localization, straight from git](https://lingo.dev/en/changelog/2026-w22): The Lingo.dev GitHub App runs continuous localization on any repository, straight from git — install it, commit a config, and every push runs through your engine's async localization pipeline. AI Reviewers now run on every plan, with unlimited usage. The [Lingo.dev GitHub App](/docs/workflows/github-app) runs continuous localization on a repository without leaving git. Install it, commit a `.lingo/config.json` that points at your [localization engine](/docs/platform/engines), and translations update on every push. When source files change on the default branch, the app translates them through your engine and opens a pull request with the localized files. Turn on the pull-request workflow and it commits translations straight to the PR branch as you work, updating a comment with the results and any failures. It translates only the source changes it detects — not the whole file — and routes everything through the engine you configured, with its [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) applied. Each run executes on the [async localization pipeline](/docs/api/pipeline) — the same one the API runs — so every translation passes through the stages configured on your engine: pre-edit, AI review, human review, and back-translation. Every push gets continuous localization with quality gates, not a bare translate call. Two controls keep it gated. Set `requireApproval` and the app waits for a human before it writes — **Approve** / **Deny** on the check run, or `/lingo approve` on a pull request. Use `/lingo translate ` in a comment to backfill or force specific files. It localizes JSON, JSONC, Markdown, MDX, Markdoc, and OpenAPI (YAML) files. Read the full setup — install, config, workflows, and commands — in the [GitHub App docs](/docs/workflows/github-app). ## Also shipped - **AI Reviewers on every plan.** Independent cross-model quality scoring is no longer tied to a tier — run [AI Reviewers](/docs/platform/ai-reviewers) on any plan, with unlimited usage. Usage-based, billed per run — see [pricing](/pricing). - **Source, AI, and human diff in the review step.** The human review stage of the [localization pipeline](/docs/api/pipeline) now shows the source, the AI draft, and the human edit side by side, and reports the cost of the edit step. - [W21 – Audit logs](https://lingo.dev/en/changelog/2026-w21): Audit logs land in the dashboard. Glob patterns in i18n.json. Organization invites through the MCP server. Last-used timestamps on API keys. [Audit logs](/docs/platform/audit-logs) record every state-changing action across your organization as an append-only event: who performed it, when, against which resource, and what changed. Covered resources include [engines](/docs/platform/engines), [glossaries](/docs/platform/glossaries), [instructions](/docs/platform/instructions), [brand voices](/docs/platform/brand-voices), [AI reviewers](/docs/platform/ai-reviewers), [API keys](/docs/platform/api-keys), [team members](/docs/platform/team), and [roles](/docs/platform/rbac). The log is viewable in the dashboard with filters for user, resource type, and date range. ## Also shipped - **Glob patterns in `i18n.json`.** The [Lingo.dev CLI](/docs/cli) accepts recursive globs in `include` and `exclude`. `src/**/*.json` matches every JSON file under `src`, at any depth. - **Organization invites through the MCP server.** Invite a teammate to your organization, or list pending invites, from any AI assistant connected to the [Lingo.dev MCP server](/docs/platform/mcp). See [Team](/docs/platform/team) for the dashboard equivalent. - **Last-used timestamp on API keys.** The [API keys](/docs/platform/api-keys) page now shows when each key was last used to authenticate a request. Useful for spotting dormant keys before rotation. - [W18 – Credit balance auto top-up](https://lingo.dev/en/changelog/2026-w18): Auto top-up replenishes credits when your balance dips below a threshold. New Reports charts for instruction adherence and terminology coverage. Credit balance alerts and ISO 5060 classification in triage land alongside. [Auto top-up](/docs/platform/billing) replenishes your credit balance automatically. Opt in once, set a threshold and an amount, and credits get purchased the moment your balance dips below the threshold – translation CI runs stop failing on empty balances. ## Also shipped - **New [Reports](/docs/platform/reports) charts: instruction adherence and terminology coverage.** Track over time how often translations follow your configured instructions and apply your glossary terms. - **Credit balance alerts.** Dashboard indicator shows current balance; emails fire at 20% remaining and at $0. - **ISO 5060 classification visible in [triage](/docs/mcp/triage).** Each triaged job surfaces the standardized error class alongside the agent's suggestions. - [W17 – Pipeline: pre-edit, human review, AI review, back-translation as optional stages](https://lingo.dev/en/changelog/2026-w17): The localization pipeline wraps the core translate step with optional stages – pre-edit, human review, AI review, back-translation. Toggle them per engine or override per job. The [localization pipeline](/docs/api/pipeline) wraps the core translate step with optional stages, each independently toggleable per engine and overridable per job: - **[Pre-localization AI edit](/docs/api/pipeline).** An AI agent cleans the source payload before translation, so a single source error doesn't propagate across every target locale. - **[Post-localization human edit](/docs/api/pipeline).** Sends the translation to a qualified human translator. The job pauses on a webhook until the edit returns. - **[Post-localization AI review](/docs/api/pipeline).** Reconciles the human output against the engine's [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions). - **[Back-translation check](/docs/api/pipeline).** Translates the final output back into the source locale and compares. The agent flags semantic drift by severity and auto-adjusts on major or critical drift. Turn on what you need, leave the rest off. - [W15 – Engine provisioning API: a fresh engine, auto-configured](https://lingo.dev/en/changelog/2026-w15): The provisioning API takes a fresh engine plus a few URLs or content samples and auto-configures brand voice, glossary, and instructions. Multi-step manual setup becomes one call. [Engine provisioning](/docs/api/provisioning) is an async API that takes a fresh engine plus a few URLs or content samples and auto-configures the brand voice, glossary, and instructions for you. Multi-step manual engine setup collapses into one call. - [W14 – Built-in glossary and instructions reviews](https://lingo.dev/en/changelog/2026-w14): Two built-in AI Reviewers check every glossary term and every instruction on every translation. Turn them on with a toggle instead of configuring a custom reviewer per criterion. Two new built-in [AI Reviewers](/docs/platform/ai-reviewers) ship: a **glossary reviewer** and an **instructions reviewer**. Toggle on the glossary reviewer and every configured [glossary term](/docs/platform/glossaries) is automatically checked on every translation. Toggle on the instructions reviewer and every configured [instruction](/docs/platform/instructions) is checked the same way. Open any translation in the dashboard log and see pass/fail per term, pass/fail per instruction, with the reviewer's reasoning on failure. One toggle replaces authoring an AI Reviewer for every single criterion. ## Also shipped - **Per-engine [AI Reviewer](/docs/platform/ai-reviewers) assignment.** Attach different reviewers to different engines instead of one set for the org. - **Organization-level timezone setting.** [Reports](/docs/platform/reports) display in the timezone you configure for your org. - [W13 – Triage Jira tickets with an agent that knows your glossary](https://lingo.dev/en/changelog/2026-w13): Jira triage workflow uses an agent that suggests glossary items, instructions, and model config tweaks. AI reviewers gain access to engine context. Translation logs gain review filters. The [triage workflow](/docs/mcp/triage) reads any Jira ticket about a bad translation, looks at the offending output, and proposes the actual fix in the [engine config](/docs/platform/engines): a [glossary item](/docs/platform/glossaries) to add, an [instruction](/docs/platform/instructions) to tighten, a [model config](/docs/platform/llm-models) to adjust. Review the suggestion, accept or edit it, and the engine is fixed for every future translation – not just the one that was reported. ## Also shipped - **[AI Reviewers](/docs/platform/ai-reviewers) now receive the same context the localizer saw.** Brand voice, instructions, and glossary items reach the reviewer prompt, so scoring aligns with the engine's actual configuration instead of evaluating in a vacuum. - **Review filters in translation logs.** Filter the [translation logs](/docs/platform/reports) by reviewer and score threshold – e.g. any reviewer below 80%, or a specific reviewer below a given percentage. - [W12 – One POST, every locale: the async localization API ships](https://lingo.dev/en/changelog/2026-w12): Async localization API delivers any number of target locales via webhook. Glossary terms now bulk-import from CSV. Jira and GitHub integrations move to OAuth. The [async localization API](/docs/api/localization) fans out a single localization request across every target locale in one POST and delivers results via webhook. Source payload in, job IDs out, webhook fires per locale as each finishes. ## Also shipped - **[Glossary](/docs/platform/glossaries) CSV upload.** Bulk-import glossary items from CSV with conflict resolution on collision, so re-imports don't duplicate entries. - **Jira and GitHub now connect over OAuth.** Both integrations switched off API-token setup. - [W11 – v1.0 is live: configure a localization engine once, call it from anywhere](https://lingo.dev/en/changelog/2026-w11): Lingo.dev v1.0 introduces localization engines – stateful translation APIs configured once with models, brand voice, glossaries, and instructions, then called from code, MCP, or CI/CD. Lingo.dev `v1.0` introduces [localization engines](/docs/platform/engines) – stateful translation APIs that you configure once and call from backend code, [MCP](/docs/platform/mcp), or [CI/CD](/docs/workflows). Before today, every team that wanted consistent terminology, on-brand tone, and per-locale rules wired it up by hand: a glossary in one repo, brand-voice notes in a Notion doc, prompt scaffolding hard-coded into the app. The engine collapses all of that into a single addressable thing. ## What's in the launch - **[Localization engines](/docs/platform/engines).** Configure models, brand voice, instructions, and glossaries per locale. - **[LLM models](/docs/platform/llm-models).** Pick the model per locale, with ranked fallback chains for reliability. - **[Brand voices](/docs/platform/brand-voices).** Linguistic rules and tone per locale. - **[Instructions](/docs/platform/instructions).** Per-locale translation rules for specific patterns. - **[Glossaries](/docs/platform/glossaries).** Lock down product terminology across languages and builds. - **[AI Reviewers](/docs/platform/ai-reviewers).** Cross-model evaluation criteria for translation quality. - **[Playground](/docs/platform/playground).** Test engine configurations before they go live. - **[Reports](/docs/platform/reports).** Word generations, token consumption, top locales, glossary coverage, change rates. - [W16 – Inspect every localization job, per language, end to end](https://lingo.dev/en/changelog/2026-w16): Localization Jobs UI ships end-to-end – inspect job groups, per-language progress, payload, and webhooks. The [Localization Jobs UI](/docs/api/localization) lands end-to-end. Inspect job groups, watch per-language progress, see the source payload, see the webhook delivery, drill into any failed locale. The [async localization API](/docs/api/localization) ships jobs in parallel across every target locale, retries on failure, and webhooks back when done – now you can watch it happen instead of reconstructing from logs. - [W20 – API keys split: personal keys, or service keys with their own role](https://lingo.dev/en/changelog/2026-w20): API keys split into Personal (inherit your role) and Service (own role, own engine scope). Sync localize returns model and cost per call. Engines gain an enable/disable toggle. [API keys](/docs/platform/api-keys) now come in two flavors. **Personal keys** inherit your [RBAC role](/docs/platform/rbac) and per-engine grants. **Service keys** carry their own role and per-engine scope, so a production worker is not tied to any one teammate – a service key can be roleless and reach only the engines listed on it. ## Also shipped - **Engines now have an enabled/disabled toggle.** Disabled engines stop accepting requests without being deleted. - **[Sync localize](/docs/api/localize) returns usage metrics per call.** The model used, the LLM cost, and the localization token cost ride back in the response, so you can attribute translation cost down to a single end user. - [W19 – RBAC lands: roles, per-engine access, ownership transfer](https://lingo.dev/en/changelog/2026-w19): RBAC ships – roles, per-user engine access, ownership transfer. Claude Desktop logs into the MCP server via browser OAuth. Async API gains lockedKeys; logs show retrieved context per request. [RBAC](/docs/platform/rbac) is now available. Define roles, assign them to users, and grant per-engine access independently. Ownership transfer is supported, so the original creator is no longer a permanent dependency. ## Also shipped - **Claude Desktop logs into your [MCP server](/docs/platform/mcp) with a click.** The server now speaks browser-redirect OAuth – the client opens a tab, you confirm in the dashboard, you're in. No more copy/pasting API keys into client configs. - **`lockedKeys` on the [async API](/docs/api/localization).** Slugs, IDs, URL parameters, and any value that's structural metadata rather than translatable content stay verbatim across target locales and bypass the LLM entirely. - **Logs show the context that produced each translation.** Open any request in the Log tab and you can see exactly which [glossary terms](/docs/platform/glossaries), [instructions](/docs/platform/instructions), and [brand-voice rules](/docs/platform/brand-voices) the engine retrieved – plus the glossary review reasoning. - **Cost-by-model breakdown.** See how much you spent on each model, in tokens and in dollars. ## Customers - [SoSafe replaced their translation agencies – more languages, fewer complaints](https://lingo.dev/en/customers/sosafe): How a German cybersecurity company stopped managing translation vendors and started engineering localization infrastructure. | | | | --- | --- | | Team size | 2 localization engineers | | Languages | 34 (simultaneous shipping) | | Current turnaround | 1 sprint for full content cycle | | Glossary terms enforced | 4,417 across all language pairs | | Quality scorer runs | 240,000+ | SoSafe's localization team is two people. They ship content in 34 languages to organizations across Europe, Asia, and Australia. Today, with Lingo.dev, a complete retranslation of 90+ lessons across every language takes a single day. This is not a story about a tool migration. It is a story about what happens when a team stops treating localization as a vendor relationship and starts treating it as infrastructure. > "I like that Lingo.dev's localization infrastructure is AI-first from the ground up. We went from six months per release to one sprint across 34 languages – and we talk directly to the engineers behind it." > > – Max Höffner, Director of Product Engineering, SoSafe ## The product demands it SoSafe is the fastest-growing platform for adaptive human risk management, trusted by over 6,000 organizations worldwide to build human resilience as risks evolve. Grounded in behavioral science and powered by community intelligence from real threats and behaviors, the platform turns everyday interactions into opportunities for adaptive learning. Content precision, accuracy, and relevance is key to ensuring training and learning is impactful. Ensuring the right legal terms are used for a specific jurisdiction strengthens the overall credibility of GDPR training. Phishing simulations have to feel real to create a learning moment. "We take a lot of pride in our in-house content," says Max Höffner, Director of Product Engineering at SoSafe. "In Germany, you would always use formal language. In Switzerland, they tend to use the informal version, even in official training. This is where high-quality localization becomes a product quality question – not a nice-to-have." Content relevance and quality is one of SoSafe's competitive advantages. Their simulation emails need to reference local institutions. Their compliance modules need jurisdiction-specific regulatory language. A generic translation is worse than no translation – it trains employees to recognize the wrong cues. ## What did enterprise localization look like before AI? Before 2025, SoSafe's localization workflow looked like most enterprise localization setups: legacy TMS, external translation agencies for execution, and a painful manual process connecting the two. Annika Palm, Localization Engineer at SoSafe, describes her previous role without nostalgia: "File management was probably about 90% of my job. Not terribly exciting, not terribly fun, but it had to be done. We had to do file transfers mostly via email. Files were sometimes lost in the communication. File versioning was often an issue because we didn't have Git." The workflow for a single lesson: duplicate a project file, open it in the authoring tool, double-click each string, paste in the translated text from the agency, save, export, upload. For every language. For every lesson. We discovered that quality control only covered the languages someone on the team could actually read. For the rest, there was no measurement at all. "Translators often don't have the domain-specific knowledge," Annika says. "Their translations were good enough, but they were not as good as they should have been for us." ## When does a legacy TMS stop working? The shift happened at a structural level – SoSafe migrated their content architecture to JSON files in Git repositories, replacing the proprietary authoring-tool format. Once content lived in Git, the question became obvious: why does localization live outside this pipeline? Max Höffner saw the same pattern he'd seen across the industry: "The problem with all of the established players is they were just adding AI features on a process that was, in my opinion, already broken. Some of them had GitHub integration, some had this or that. But what Lingo.dev did differently is they thought through localization AI-first from the ground up. And because of how their team is set up, we talk directly to the engineers building the infrastructure. Edge cases get resolved the same day." The team evaluated three or four alternatives. Each offered some version of the same proposition – the legacy TMS workflow, now with an AI layer bolted on top. "I remember having that conversation with our CTO," Max says. "I said: the established players are adding AI to the end of a broken process. Some offered AI as an intermediate step while they work on the human version. And I thought – well, can't we just make the AI version better?" ## How does a localization engine replace translation agencies? Today, SoSafe's localization runs through a set of localization engines configured with 4,417 glossary terms, professional style guides per locale, per-locale instructions for formal/informal register, terminology preferences, and regulatory language. The technical workflow: content lives in a Git repository as structured JSON. Localization runs as a CLI command. "We have one shared source of truth," Annika says. "Everyone knows that the version on GitHub is the version that should be on the platform. No one has to search for files. No one has to ask, is this really the live version?" The glossary enforces terminology across every language pair – GDPR-specific terms, cybersecurity vocabulary, product-specific language. After configuring the localization engine with 4,417 glossary terms, the team found that terminology consistency across all 34 languages reached measurable levels for the first time. Issues that previously took weeks to surface through customer complaints now get flagged within minutes. When a score flags an issue in Swiss German because a glossary term was missing, Annika adds the term and it retranslates. Annika's average resolution time turned out to be eight minutes from alert to fix – Max Höffner's favorite metric. ## The concerns that almost stopped them Annika's biggest fear was losing control: "Our content is seen by thousands of people every day. We get complaints almost every day. If we had put ourselves in a position where we translate faster but have less control over the output, that would not have been good for us." What changed her mind was the configurability of the localization engine – glossary, translation rules, brand voice, per-locale instructions. "You could interpret it almost like an engine is a translator," she says. "The translator has a glossary at hand, is given translation rules, and should have knowledge of the product. The engine has all the knowledge packages a translator brings to the table, but condensed and more reliable." The GDPR review was the other potential blocker. SoSafe is a German cybersecurity company – procurement involves ISM, legal, IT, finance, and multi-level management approval. Max Höffner's approach: scope the data flow. "As long as we control the data that goes into the system, we have control over the GDPR side of things. Everything that is PII we just do not send. For everything else – our own training content, our internal materials – we can proceed." The DPA was signed, legal cleared it, and IT confirmed. The question was never whether AI localization was compliant – it was which content fell inside the boundary. ## How fast can a team ship 34 languages simultaneously? All 34 languages ship simultaneously. The production cycle – from final German content to all languages live – dropped from months to just one sprint. "We don't need a tier system anymore because it is irrelevant whether we translate into one language versus thirty languages at the same time," Sheree says. We measured the shift: a new language release is now taking just one sprint end-to-end. For the first time in years, localization is not the production bottleneck anymore. "The bottleneck shifted to other engineering processes – database entries that need updating, manual steps elsewhere," Max Höffner says. "It basically shifted the whole bottleneck conversation away from the localization team where it has been for the last several years." The most revealing proof point came from a product team building an AI-generated lesson feature. The team asked Max how to handle localization for customer-uploaded PDFs that generate lessons in different source languages. His answer: "Use Lingo.dev." They implemented it in a single day – something that would have required a dedicated sprint under the previous model. "We didn't have to think about this much," he says. "The team asked me, and we implemented it from day one." ## What does a localization engineer actually do? Annika's job title hasn't changed, but her work has. "I'm more of an engineer now in every sense of the word," she says. "We no longer rely on other people to help us. We can be more proactive. Before, we had to rely on dev engineers. Now we can create our own solutions." The shift is from operational to architectural. Instead of managing vendor email chains and copying strings into files, the team configures engines, tunes glossaries, monitors quality scores, and builds automation scripts. "Now we are the solution providers ourselves," Annika says. "Our processes are a lot leaner. It's focused on solutions, whereas before it was managing people who provided the solutions." When asked what she'd say to a localization engineer worried that AI workflows will diminish the role: "The knowledge stays with us – what quality looks like, what needs translating, for what audience. When we hand the boring parts to AI localization infrastructure, we solve important problems faster." ## What's next SoSafe is rolling localization infrastructure out to every product team. Sheree calls the model "centralized decentralization" – her team owns governance, quality standards, and engine configuration. Other teams execute localization autonomously within those guardrails. "Each team can work more autonomously because we are providing the overarching infrastructure for them," Sheree says. "They don't need us to intervene anymore. They can trust that the information from the engine is at the standard it should be, and execute in their normal development cycle without blockers." Two people, thirty-four languages, quality governance across every product – without vendor coordination or waiting. ## What this means for localization teams scaling beyond 10 languages SoSafe's experience surfaces a pattern across teams that cross the 10-language threshold: the legacy model of TMS + translation vendor breaks at scale not because translation quality degrades, but because coordination cost grows faster than content volume. Three things changed the equation: glossary enforcement eliminated terminology drift across all 34 languages without human review per pair. Quality scoring made every language measurable – not just the ones someone on the team speaks. And the localization engineering workflow moved ownership from vendor project managers to the team that ships the product. The result is a shift in who does what. Translation agencies provided labor. Localization infrastructure provides capability. The two-person team at SoSafe now governs more language output per quarter than the previous two-person team plus four vendors produced per year. ## In their words Max Höffner, Director of Product Engineering, on whether to treat localization as infrastructure: > "Infrastructure all the time. It should always be infrastructure. In this day and age, localization isn't a human-reliant process anymore." Sheree Foltin, Content Engineering Teamlead, on recommending the shift: > "I would recommend revamping the process so the humans employed can actually focus on what matters." Annika Palm, Localization Engineer, on the legacy model: > "The TMS-and-vendor model is a dying model, to put it bluntly. In the fast-moving world we are in right now, it won't be able to adapt fast enough." SoSafe is the adaptive human risk management platform that helps organizations unlock human resilience at scale – combining behavioral science, global community intelligence, and AI to stay ahead of evolving threats. Their localization infrastructure runs on Lingo.dev. ## Frequently asked questions **How many languages can a two-person localization team support?** SoSafe's two-person team ships 34 languages simultaneously using localization engines configured with per-locale glossaries, brand voice rules, and AI quality scoring. **How long does it take to add a new language with a localization engine?** At SoSafe, a complete retranslation of 90+ lessons across a new language takes one day. The acceleration comes from glossary enforcement and per-locale model configuration running through a CLI, not from cutting corners on quality. **What does a localization engineer do differently from a localization manager?** SoSafe's Annika Palm describes the shift: the role moved from managing vendor relationships and file transfers to configuring localization engines, tuning glossaries, monitoring quality scores, and building automation. The team went from managing people who provided solutions to being the solution providers themselves. **How do you measure translation quality in languages you don't speak?** SoSafe uses AI quality scoring – independent models that evaluate each translation against configured criteria. They've run 240,000+ scorer evaluations across the platform. When scores flag an issue, the localization engineer adjusts the glossary or instructions and retranslates. Average resolution time: eight minutes. - [Laurel added a language to their product in a day – without an engineering sprint](https://lingo.dev/en/customers/laurel): How a Staff Product Manager at a legal AI company turned localization from a roadmap item into a product decision. | | | | --- | --- | | Company | Laurel (AI for legal & accounting) | | Stage | Currently Series C, but Series B and ~50 people at time of decision | | Decision maker | Staff Product Manager | | Languages live | 12+ (Swedish, Norwegian, Danish, Finnish, Icelandic, French, Dutch, Portuguese, Spanish, Korean) | | Pipeline | Mandarin, Thai, Arabic, Japanese, Vietnamese | | Time to add a language | 1 day (no engineering sprint) | | Build estimate avoided | 4–6 months engineering time | Laurel is the work intelligence platform for professional services. Firms use Laurel to automate timekeeping, understand what's driving profitability, and prove the ROI of their AI investments. The product was English-only until recently. Today it ships in over a dozen languages – Nordic languages, French, Dutch, Portuguese, Spanish, Korean – with Mandarin, Thai, Arabic, Japanese, and Vietnamese in the pipeline. Laurel's integration was straightforward – the majority of the effort was on their side, extracting and organizing existing strings to prepare the codebase. The build alternative was estimated at four to six months of engineering time, with ongoing maintenance indefinitely. We found that the total cost of not buying was roughly 10x the cost of buying, once you factor in the deals that would have slipped. Now, adding a new language takes a day – a product manager can do it solo. That sentence would have been absurd eighteen months ago. > "I loved how Lingo.dev showed up, understood our problems, and came up with solutions. We didn't need to build localization infrastructure – they solved it perfectly. The enterprise pricing was fair, and we're in a shared Slack channel with their engineers. Turnaround on edge cases is hours." > > – Nick Bazley, Staff Product Manager, Laurel ## When should a SaaS company invest in localization? Nick Bazley is a Staff Product Manager at Laurel, where he has spent the last six years leading product teams as the company scales its product globally. He'd been thinking about localization for about a year before it happened. "We knew we'd need to do this at some point," Nick says. "It's just – when? Whenever we talked about it, the conclusion was always the same: it's going to be an XXL project, it's going to take forever, and we're not going to fully know the quality of the output." Laurel was growing, expanding from mid-market customers to global enterprises. A pattern emerged: sign a customer in one region, prove value, then the customer wants to expand across regions. As the sales team hired across Europe and customer success fielded expansion requests, Nick could see the problem coming. "At the time of needing to do this work, we were about 50 people. Taking on building localization infrastructure ourselves would have been a large ask – half the team tied up for months, when there is plenty more we need to be building for our customers." ## Why buy localization infrastructure instead of building it? Laurel was faced with two options, buy vs. build. Building was likely going to be a multi-quarter effort, with the internal estimate as an XXL project – four to six months of engineering time. "It's not a smart choice to take on the cost, time and effort of building our own localization infrastructure. The sensible choice for the business in the stage of growth we were in was to buy the best solution from the market and focus on building our core platform instead." In this discussion, the major tipping points to the decision came down to four things: speed to market, scalability after launch, customization, and quality. "We're not experts in localization infrastructure. We don't know what quality is going to be. Why take that risk when someone has built an entire business around localization engineering?" ## How to evaluate a localization platform vs a legacy TMS Nick mapped the market with a quick Perplexity Pro search, scanned the top results, and found a legacy TMS. A separate Google search turned up a few more options, and Laurel's Head of Engineering had independently come across Lingo.dev. He ran parallel evaluations with both. "That legacy TMS on the surface looked like a really slick, solid business" Nick says. "But when we peeled back the layers of what they have, and what we needed there was only one choice for the speed and quality we were striving towards." "What I appreciated was how Lingo.dev showed up – understood our problems, what we were trying to do, and came up with solutions. The enterprise pricing was fair. And the speed and quality promise was the key driver. What stood out was the access. We're in a shared Slack channel with the engineers who actually build the platform. When we hit an edge case, the turnaround is hours. It almost feels like they're part of our org." ## How accurate is AI localization for legal and accounting terminology? Laurel's users are legal and accounting professionals. A German UI that says the wrong word for "billing rate" or "matter" doesn't just look bad, it undermines confidence in the entire product. Legal and accounting terminology has to be exact. Nick tested quality with real customers. The first pass went to Nordic customers and a French customer. The Nordic team had zero feedback. The French customer, a native speaker, caught only two inaccuracies: the team immediately added it to the glossary. Since then, there have been no issues across Dutch, Portuguese, Spanish etc.. We tested quality across 12 languages with native-speaking customers over six months. Total terminology issues reported: two, both resolved same-day through glossary additions. It turned out that the localization engine with a configured glossary produced more consistent legal terminology than building translation logic from scratch would have – because the engine enforces terms across every language pair simultaneously, something a manual process can't guarantee. "We haven't had to go back into the glossary to tweak anything in a while," says Nick. ## How long does it take to add a new language to a SaaS product? When a customer success manager flags that a customer needs Portuguese in the UI, that request used to go into the roadmap, get prioritized, wait for a sprint, and take weeks. Now, Nick creates a ticket, references a past language-addition ticket as a template, points AI Tooling at it, and waits. The tooling then adds the language to the config, updates the language switcher, opens a PR. An engineer reviews the PR ships, Lingo.dev handles continuous localization on autopilot. "A day is end to end – from crafting the ticket to the PR being finished. Doesn't mean I'm working on it for a day. Most of that time I'm doing other things." With AI coding tools and a Lingo.dev localization infrastructure, Nick can add a language without engineering bandwidth. "With Lingo.dev, anyone at the company can add new languages now, and tune our localization engines. That's pretty remarkable." ## How does localization speed affect enterprise deal velocity? The business case isn't about saving engineering time – though it does that. It's about making sure we can react quickly to evolving expansion opportunities "With enterprise customers, the opportunity for expansion can appear quickly" Nick says. "Our customers will get some traction within a new office, and want to expand there if we can turn around the product in their language. We need to be able to react at that speed no problem to continue our growth." Laurel started with five Nordic languages plus French to support their first European expansion. Since then, sales and customer success have driven requests for Portuguese, Spanish, Dutch, – and now they're in discussions with many more as we expand across global offices We counted: in the six months after integration, Laurel added seven languages in response to customer expansion requests. Each took less than a day. Under the build-it-ourselves model, those seven languages would have consumed approximately 28 engineering sprints – capacity that went to core product features instead. ## Should you build or buy localization infrastructure? When asked what he'd tell a VP Product at a similar company – enterprise B2B, professional users, expanding globally, engineering team with real product work to do – Nick doesn't hesitate. "I 100% would go with a vendor." The thing they'd underestimate about building it themselves: "The complexity and the quality. You don't know what the quality is going to be. Why take that risk?" The thing they'd get wrong about choosing a vendor: not understanding their own problem clearly enough to pick the right one. "Truly understanding the problem you're solving and choosing the right vendor for that specific problem – that's critical." What to test first: "The speed and time to market. Then once you've done the initial setup, it's the speed and the scalability." ## What it actually costs to wait The initial setup was mostly on Laurel's side, getting the tech stack ready. After that: a day per language, no engineering sprint required, no roadmap negotiation. The alternative was three to four months of engineering time to build, ongoing maintenance forever, and quality they couldn't guarantee in languages nobody on the team speaks. Nick frames it simply: "Localization infrastructure isn't a project that's done and you move on. It's ongoing – every time you scale, every time you enter a new market, every time you add a new feature. It has to be easy. I don't want to keep coming back to engineering asking for another sprint to deliver everything we need." ## What this means for product teams expanding into new markets Laurel's experience maps to a pattern across B2B SaaS companies with international enterprise demand: localization shifts from a feature request to a growth constraint. The question stops being "should we localize?" and becomes "how fast can we say yes to the next market?" Three factors determined Laurel's approach: the team couldn't afford to dedicate engineering capacity to infrastructure that isn't their core product. Quality in legal and accounting terminology had to be verifiable, not assumed. The localization engineering approach treats language support as a configuration layer rather than an engineering project. A product manager can add a language without a sprint, without engineering bandwidth, and without coordinating with a translation vendor. For teams where market expansion speed determines revenue growth, that operational shift is the difference between capturing an opportunity and watching it close. Laurel builds AI for legal and accounting professionals. They ship their product in over a dozen languages – and can add a new one in a day. Their localization infrastructure runs on Lingo.dev. ## Frequently asked questions **How long does it take to add a new language to a SaaS product?** Laurel adds a new language in approximately one day, end to end. A product manager creates a ticket referencing a previous language addition, points an AI coding agent at it, and an engineer reviews the PR. No dedicated sprint, no vendor coordination. The previous alternative – building localization infrastructure in-house – was estimated at four to six months before any language could ship. **Should a startup build or buy localization infrastructure?** Laurel's Staff PM Nick Bazley evaluated building in-house vs. buying. His conclusion: "We're not the experts in localization infrastructure. We don't know what quality is going to be. Why take that risk when someone has built an entire business around localization engineering?" The build estimate was an XXL project consuming half the engineering team for months. **How accurate is AI localization for professional terminology?** Laurel tested across 12 languages with native-speaking customers in legal and accounting over six months. Total terminology issues: two, both resolved same-day through glossary additions. The localization engine enforces glossary consistency across every language pair simultaneously – something a manual build can't guarantee at scale. **How does localization speed affect enterprise sales cycles?** Laurel's experience: enterprise customers requesting new language support expect a response within days, not months. Nick Bazley describes the dynamic: "The opportunity doesn't stay around for too long. If we can't turn it around in a week, the window could close." After switching to localization infrastructure, Laurel added seven languages in six months – each in under a day. **What's the build vs buy cost comparison for localization?** Laurel's comparison: a short integration period and ongoing per-usage cost vs. four to six months of engineering time to build, plus indefinite maintenance, plus quality they couldn't guarantee. The seven languages added in six months would have consumed approximately 28 engineering sprints under the build model – capacity that went to core product features instead. - [How Truely Replaced 32 Uncontrolled Languages with a Localization Engine](https://lingo.dev/en/customers/truely): Truely started with raw OpenAI calls across 32 languages and no way to control terminology or quality. After switching to a localization engine on Lingo.dev, Dutch reads naturally, Russian fits the UI, and brand voice is consistent across every locale. Truely is a travel platform with 200 pages of content and a landing page to maintain across 32 languages. They started with the same approach most teams do: raw OpenAI API calls. It didn't hold. ## The before-state: no control "We made the rookie mistake of using raw OpenAI for translations," recalls Sebastiaan van Leeuwen, Product Manager at Truely. "We had 32 languages that nobody could control. German was terrible. Dutch was worse." Stateless LLM calls are the localization equivalent of building without a foundation. Each request starts from zero context. There is no glossary, no brand voice, no record of how the product terminology should read in German versus Dutch versus Russian. The model makes a fresh guess every time. Across 32 languages and 200 pages, those guesses compound into drift that nobody can see until it's in production. The team tried the standard alternatives. Human translators meant weeks of coordination, plus translators who didn't know the product. Crowdin added process without solving the root problem. "Crowdin felt like opening an airplane cockpit," says van Leeuwen. "Too many buttons, too many features we'd never use. We needed something focused, not a Swiss Army knife of complexity." ## Switching to a localization engine The difference with Lingo.dev was architectural, not cosmetic. A {% tooltip tip="A stateful translation API that persists domain context – glossary, brand voice, and per-locale instructions – across every request" %}localization engine{% /tooltip %} is stateful: it persists Truely's product terminology, brand voice configuration, and per-locale instructions across every request. Where raw OpenAI calls made a fresh guess at "provider" every time, the localization engine resolves it from the glossary before the model generates a single token. Truely needed a Directus CMS integration with parallel processing for their content pipeline. Veronica, Lingo.dev's co-founder, shipped the integration in days. "Their co-founder didn't just understand the problem – she shipped a solution in a couple of days. That's the kind of partner you want for core infrastructure." ## Results The quality improvement was immediate and measurable. - Dutch translations read naturally – van Leeuwen flagged this specifically because natural Dutch output from AI is rare - Russian text automatically fits Truely's UI constraints despite Russian words running longer than their English equivalents - A custom glossary enforces consistent brand terminology across all 32 languages - Zero ongoing configuration required for automated translations - Developers freed from translation management entirely "The quality surprised everyone," says van Leeuwen. "Dutch translations read naturally, which is rare for AI. Russian, despite having longer words, fits perfectly in our UI. And the glossary feature lets us maintain precise control over our brand voice." The underlying mechanism: the localization engine injects brand voice rules and per-locale instructions at inference time. Russian receives instructions about character budget constraints; Dutch receives brand voice configuration that shapes register and phrasing. The same configuration applies to every request, every page, every release. ## The practical outcome Truely maintains 200 pages across 32 languages with zero translation management overhead. The localization engine handles new content automatically. The glossary prevents terminology drift. Quality is measurable and improvable. "It's rare to find a tool that solves a complex problem without adding complexity," concludes van Leeuwen. "Lingo.dev did exactly that." {% card-grid %} {% link-card title="Brand voice configuration" href="/docs/platform/brand-voices" description="Define tone and register per locale – formal, conversational, technical" icon="chat" /%} {% link-card title="Glossary" href="/docs/platform/glossaries" description="Lock product terminology across locales with semantic matching at inference time" icon="book" /%} {% /card-grid %} - [How Cal.com Automated Localization for 36 Languages](https://lingo.dev/en/customers/cal-com): Cal.com was always behind on i18n despite agency spending. After deploying a localization engine on Lingo.dev, engineers stopped thinking about localization entirely – translations happen automatically in 36 languages with every push. Cal.com's mission is to connect a billion people by 2031 through open-source scheduling infrastructure. Getting there requires serving users in every language – and for years, that requirement was a constant drag on engineering velocity. ## The problem: always behind Cal.com had tried every standard approach. Crowdsourced translators through a translation management system. Localization agencies. Each added cost and coordination overhead, yet the result was the same: even top-priority languages were perpetually out of sync. "We were always behind with internationalization," recalls Keith Williams, Head of Engineering. "Despite investing in agencies and crowdsourcing through translation management systems, even our top languages were out of sync. The costs were high and the manual work took our engineers away from building features." The pattern is common. Legacy localization platforms manage human translator workflows – they don't eliminate the coordination overhead, they just organize it. Each release cycle meant another handoff to an external team, another wait, another round of review. The engineering team could ship a feature in a day; getting it localized could take two weeks. ## The shift: localization as infrastructure In 2025, Cal.com deployed a localization engine on Lingo.dev. A {% tooltip tip="A stateful translation API that persists domain context – glossary, brand voice, and per-locale instructions – across every request" %}localization engine{% /tooltip %} is a stateful translation API: it persists Cal.com's product terminology, scheduling domain vocabulary, and per-locale configuration across every translation request. When the engine encounters "Workspace" or "Booking" in an English string, the glossary resolves the correct per-locale term before the model generates a single token. The integration process required some initial adjustments given Cal.com's open-source codebase and contributor base. Lingo.dev's team worked through those directly. "Their response times were insane," says Williams. "When we needed adjustments, they delivered fixes faster than I've seen from any vendor." ## Results Once the localization engine was configured, Cal.com connected it to their CI/CD pipeline. Every code push triggers the engine; translations update automatically across all 36 languages. - Engineering team no longer manages translation workflows - All 36 languages stay synchronized with every release - Significant reduction in localization costs compared to agency spend - Zero handoffs – translations happen in the same pipeline as code deploys "The best part? Our engineers don't even think about localization anymore," Keith explains. "They just build features, and translations happen automatically. It's exactly what we needed to make Cal.com accessible to everyone around the world." ## What comes next Cal.com is extending the localization engine to cover email templates – the last remaining surface where translations were handled separately. When complete, every user-facing string in the product will flow through the same localization infrastructure. For a team building toward a billion connections, the compounding effect matters: every glossary term configured today enforces correctly across every new feature, every new release, every new locale. {% card-grid %} {% link-card title="How localization engines work" href="/docs/platform/engines" description="Stateful translation APIs with per-locale model chains, glossaries, and brand voice" icon="gear" /%} {% link-card title="CI/CD integration" href="/ci-cd" description="Connect your pipeline so every push triggers automatic localization" icon="git-branch" /%} {% /card-grid %} - [How Jarvi Kept 300+ Agencies in Sync Across Languages](https://lingo.dev/en/customers/jarvi): Jarvi serves 300+ recruitment agencies across France and Europe. Translation quality and keeping content synchronized with rapid product development were the two blockers to expansion – until AI translations beat their human translations in head-to-head testing. Jarvi is a recruitment platform serving 300+ agencies across France and Europe. Fast product development is core to how the team competes – but fast development creates a localization problem: content goes out of sync before the translation cycle catches up. ## Two problems, one root cause Jarvi's localization challenges were really one problem in two forms. The first: **translation quality.** Jarvi had established human-translated baselines for their French and European markets. Before switching to automated localization, they ran a head-to-head test: Lingo.dev's localization engine against their existing human-translated content. The result surprised the team. "The results surprised us – Lingo.dev's translations were actually more accurate than our human translations," says Quentin Decré, Co-founder. The reason is structural. Human translators work from the source text without product context. A localization engine configured with Jarvi's recruitment domain vocabulary – the specific terms for "applicant tracking," "placement," "sourcing pipeline" – applies that context to every request. The localization engine knows the product. Most individual translators don't. The second: **sync.** "What kills you isn't just the translation time – it's remembering to translate everything as you ship features," explains Decré. "Our content was constantly out of sync, which hurt our expansion to new markets." Every new feature meant a new translation task. In a team moving fast, those tasks accumulated. Untranslated strings meant French agencies saw gaps. European expansion required content that tracked the product in real time. ## Localization connected to the development workflow Jarvi configured a localization engine with their recruitment domain terminology and connected it to their GitHub Actions workflow. Now, when a pull request merges, the CI/CD pipeline triggers the localization engine. Translated strings are committed back to the repository automatically. The development culture change was more significant than the technical change. "We used to constantly worry about keeping translations in sync. Now we just build features, and localization happens automatically. For a product that needs to move fast and serve recruiters across multiple countries, this was exactly what we needed." ## Results - Zero developer time on translation management - Faster feature shipping – localization no longer a gating step - AI translations that outperformed prior human-translated baselines in accuracy - Consistent terminology across all languages via the localization engine's glossary - GitHub Actions integration: every PR includes localized strings One downstream effect: with localization automated, the team started investing more in markdown content for documentation and SEO. Content that previously would have required manual translation overhead now gets localized automatically on every push. Jarvi continues to expand across Europe, exploring additional localization engine features including screenshot-based translation for visual content. {% card-grid %} {% link-card title="GitHub CI/CD integration" href="/docs/integrations/github" description="Open a PR with translated strings on every push" icon="git-branch" /%} {% link-card title="Glossary configuration" href="/docs/platform/glossaries" description="Lock product terminology across locales with semantic matching" icon="book" /%} {% /card-grid %} - [How Papermark Automated Docs Localization with Lingo.dev](https://lingo.dev/en/customers/papermark): Papermark tried every i18n package and automation tool to localize their Next.js docs platform. Nothing worked until they deployed a localization engine that handled MDX files, edge cases, and 80 pages on day one. Papermark is an open-source document sharing platform. When the team decided to localize their documentation, they hit the problem that stops most developer tools teams: getting i18n working correctly with a Next.js application is the harder problem than translation itself. ## The setup problem "I tried every automation package and self-made tool out there," recalls Iuliia Shnai, Founder of Papermark. "The biggest pain wasn't even the translation step – it was getting i18n working properly with our app structure." This is a familiar pattern. Most localization tools assume the i18n infrastructure is already in place. They handle translation. They don't handle the configuration, file structure, MDX parsing, or the edge cases that differ by framework. For an open-source project with a small team, spending engineering time on localization setup is a direct cost to the product. MDX files – documentation written in Markdown with embedded React components – add another layer. Standard i18n tools handle JSON locale files and simple strings. MDX content with component interpolations, frontmatter, and custom tags requires a different approach. ## What changed Max, Lingo.dev's founder, reached out directly and helped configure Papermark's Next.js project. The implementation handled the edge cases the team had been blocked on: MDX file processing, the interaction between next-intl and the app's file structure, and extraction of translatable strings from component-heavy documentation. "The implementation handled so many edge cases we hadn't considered," says Shnai. "It was clear they had thought through all the complexities of localization, especially for MDX files which were a particular pain point for us." On day one: 80 documentation pages translated. The localization engine – configured with Papermark's product terminology and connected to their repository – handled the entire documentation set automatically. ## How it works now Papermark's localization engine runs on every code push. When new documentation is added or existing content is updated, the engine translates the changes automatically. Engineers write documentation in English; localized versions appear without any additional steps. The statefulness matters here. Because the localization engine persists Papermark's product terminology across every request, product-specific terms like "Data Room," "Link tracking," and "NDA flow" translate consistently across all languages. The first documentation page through the engine and the hundredth both apply the same product vocabulary. "Zero ongoing engineering effort for translations" is the measurable outcome – but the underlying reason is that localization became infrastructure rather than a recurring task. ## Results - 80 documentation pages translated on day one - Zero ongoing engineering effort for localization - Automatic handling of complex MDX documentation - Continuous translation on every push, covering new and updated content - Consistent terminology across all languages For an open-source project, the economics matter. Every hour not spent on localization maintenance is an hour available for the product. Papermark continues to extend its localization engine to cover SEO optimization across locales. {% card-grid %} {% link-card title="CLI for Next.js projects" href="/docs/cli" description="Connect your repo and run translations with a single command" icon="terminal" /%} {% link-card title="Localization engines" href="/docs/platform/engines" description="Stateful APIs that persist your product terminology across every request" icon="gear" /%} {% /card-grid %} ## Docs – Api - [Create localization jobs](https://lingo.dev/en/docs/api/localization/create): POST /jobs/localization to translate one payload into up to 100 locales in a single request. Returns 202 with a group ID and one job per locale; pass an idempotency key so a retry returns the existing group instead of creating a second one. Create a localization job group: one request that fans your content out to every target locale you name. You have a payload of strings and a list of locales, and you want to translate them all without writing the fan-out yourself. `POST /jobs/localization` takes the whole payload and up to 100 target locales in a single request, then returns `202 Accepted` right away with a group ID and one job per locale. One request, every locale – the platform creates the jobs and processes each one independently. ``` POST /jobs/localization ``` This page covers the create call: its parameters, the request shape, the `202` response, and how to make the call safe to retry. New to async localization? Start with the [Async Localization API overview](/docs/api/localization) for the mental model. Once a group exists, [tracking a job group](/docs/api/localization/job-groups) tells you what each locale's status means. {% callout type="info" title="Authentication" %} Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the organization. See [Authentication](/docs/api/authentication) for details. {% /callout %} ## Parameters `sourceLocale`, `targetLocales`, and `data` are required. Everything else tunes behavior or makes the call safer to repeat. | Parameter | Type | Description | | --- | --- | --- | | `sourceLocale` | string | BCP-47 source locale (e.g. `en`). | | `targetLocales` | string[] | BCP-47 target locales (e.g. `["de", "fr", "ja"]`). 1–100 per request. One job is created per locale. | | `data` | object | Key-value content to translate. Nested objects and arrays are allowed at any depth. | | `context` | string (optional) | Broad context for this translation payload, such as the product surface, audience, or purpose. Applies to every job created for the request. | | `hints` | object (optional) | Per-key context as arrays of breadcrumb strings, to disambiguate short or reused strings. | | `callbackUrl` | string (optional) | HTTPS webhook URL for this group. Overrides the organization default. HTTP is rejected. | | `idempotencyKey` | string (optional) | Client-generated key. Send the same request twice with the same key and the existing group is returned instead of a new one. Scoped per engine. | | `engineId` | string (optional) | Localization engine to run the jobs through. Falls back to the organization's default engine when omitted. | | `pipelineConfig` | object (optional) | Per-request [pipeline](/docs/api/pipeline/configure) overrides. Stages you omit inherit from the engine config. | | `lockedKeys` | string[] (optional) | Keys or glob patterns whose values are excluded from translation and merged back verbatim into `outputData`. Up to 100 patterns. See [Lock non-translatable keys](/docs/api/localization/locked-keys). | ## Request The `data` field accepts flat key-value pairs or nested structures with objects and arrays at any depth. The engine translates every string value, preserves non-string values (numbers, booleans, `null`) untouched, and returns the exact shape you sent. So you can hand it the same object your app already stores – no flattening, no reshaping. {% tabs %} {% tab label="Flat data" %} ```json { "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "lesson_title": "Introduction to Machine Learning", "lesson_summary": "This lesson covers the fundamentals of ML, including supervised and unsupervised learning." }, "callbackUrl": "https://your-app.com/webhooks/translations", "idempotencyKey": "course_101-v3" } ``` {% /tab %} {% tab label="Nested data" %} ```json { "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "id": "course_101", "title": "Introduction to Machine Learning", "steps": [ { "heading": "What is ML?", "body": "Machine learning is a subset of artificial intelligence." }, { "heading": "Supervised Learning", "body": "Training a model with labeled data." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } }, "callbackUrl": "https://your-app.com/webhooks/translations" } ``` {% /tab %} {% /tabs %} {% callout type="warning" title="HTTPS required" %} The `callbackUrl` must use HTTPS. HTTP URLs are rejected with a `400` error. {% /callout %} That nested payload mixes translatable text with values that must survive untouched – `id`, `course_101`, `difficulty`. Strings are translated; the rest is preserved by type. When you need a *string* held back too (a slug, an asset URL, an enum code), name it in [`lockedKeys`](/docs/api/localization/locked-keys) and it is merged back verbatim into every locale's output. ## Response (202 Accepted) The call returns immediately. It does not wait for translation – it hands you the group ID and the per-locale job IDs, then the platform processes each job independently in the background. ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "pending", "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "queued" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "queued" }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued" } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` | Field | Description | | --- | --- | | `groupId` | `ljg_`-prefixed identifier for the whole group. Store this – it is the handle for [tracking](/docs/api/localization/job-groups) and live progress. | | `status` | Group status at creation, normally `pending`. | | `jobs` | One entry per target locale: `id` (`ljb_`-prefixed), `targetLocale`, and the job's `status`. | | `createdAt` | ISO 8601 timestamp. | Three locales in, three jobs back, each `queued` and ready to run. What each status means as the jobs progress – and what happens when one locale fails while the others ship – lives on [Track a job group](/docs/api/localization/job-groups). ## Examples The same request from Node and Python. Both fire one POST and read the group ID and job count straight off the `202`. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch("https://api.lingo.dev/jobs/localization", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ sourceLocale: "en", targetLocales: ["de", "fr", "ja"], data: { title: "Introduction to Machine Learning", steps: [ { heading: "What is ML?", body: "Machine learning is a subset of AI." }, { heading: "Supervised Learning", body: "Training with labeled data." }, ], }, callbackUrl: "https://your-app.com/webhooks/translations", }), }); const { groupId, jobs } = await response.json(); // 202 Accepted – the call returns without waiting for translation. console.log(groupId); // "ljg_A1b2C3d4E5f6G7h8" console.log(jobs.length); // 3 – one queued job per target locale ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/jobs/localization", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "title": "Introduction to Machine Learning", "steps": [ {"heading": "What is ML?", "body": "Machine learning is a subset of AI."}, {"heading": "Supervised Learning", "body": "Training with labeled data."}, ], }, "callbackUrl": "https://your-app.com/webhooks/translations", }, ) result = response.json() # 202 Accepted – the call returns without waiting for translation. print(result["groupId"]) # "ljg_A1b2C3d4E5f6G7h8" print(len(result["jobs"])) # 3 – one queued job per target locale ``` {% /tab %} {% /tabs %} ## Make the call safe to retry The natural place to fire this request is a save hook or an event handler – exactly the code that runs twice when a retry fires or a duplicate event arrives. Without protection, two calls mean two job groups, and the same content is queued for translation twice. Pass an `idempotencyKey` and that stops being a risk. Send the same request twice with the same key and the platform returns the *existing* group instead of creating a new one – no second set of jobs. Keys are scoped per engine, so the same key against a different engine is a different group. {% callout type="info" title="Pick a key that means something" %} A good key combines content identity with version: `{contentId}-v{contentVersion}`. The same content at the same version always resolves to the same group, so a retry is automatically a no-op. Bump the version when the content changes and you get a fresh group. {% /callout %} ```javascript const key = `${content.id}-v${content.version}`; async function submit() { const response = await fetch("https://api.lingo.dev/jobs/localization", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ sourceLocale: "en", targetLocales: ["de", "fr", "ja", "ko", "pt-BR"], data: { title: content.title, steps: content.steps }, callbackUrl: "https://your-app.com/webhooks/translations", idempotencyKey: key, }), }); return (await response.json()).groupId; } const first = await submit(); const again = await submit(); // same key – duplicate submission console.log(first === again); // true – same group returned, no second set of jobs ``` This is the one POST that fans a payload out to every locale, and it is safe to fire from the same code path that retries. Store the `groupId`; that is what you carry into tracking and live progress. ## Next steps {% card-grid %} {% link-card title="Lock non-translatable keys" href="/docs/api/localization/locked-keys" icon="lightning" description="Hold IDs, slugs, asset URLs, and enum codes back from translation with key and glob patterns." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Override pipeline stages per request, or set engine-level defaults that every job inherits." /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Read group and per-locale status, and handle the case where one locale fails while the rest ship." /%} {% /card-grid %} - [Localize](https://lingo.dev/en/docs/api/localize): Translate key-value data in one synchronous request. POST your strings with a source and target locale, and the response carries the translation in the same shape – run through your engine's glossary, brand voice, and model selection, with per-request cost itemized. You have a string, or a small object of strings, that needs to be in another language right now – a form label, a notification, a short block of UI copy a user is waiting on. You do not want to run a webhook endpoint or poll a job for a single round-trip. You want to send the text and read the translation back. That is what the synchronous Localize endpoint is for: **one request, translated data back in the same shape.** You POST key-value content with a source and target locale, the call blocks while your engine translates, and the response hands back the same object with its values translated and its structure untouched. There is no job to track and no second call to make. The translation is not a generic model call. It runs through the localization engine you configured – its [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), [instructions](/docs/platform/instructions), and per-locale [model selection](/docs/platform/llm-models) – the same engine the [async API](/docs/api/localization) uses. The difference is only in shape: async fans one request out to many locales and delivers results as they land; this call does one locale pair and returns it inline. {% callout type="info" title="One locale and a blocking call is fine? You are on the right page." %} Reach for this endpoint when you need a single locale pair and can wait for one round-trip. When you have many target locales, long content, or want failures isolated per locale, the [async Localization API](/docs/api/localization) takes one request, returns a `202` immediately, and runs each locale as an independent durable background workflow. One more difference beyond latency: the [localization pipeline](/docs/api/pipeline) – pre-edit, human review, back-translation, and the other optional stages – runs on async jobs only. This synchronous call ignores pipeline configuration. {% /callout %} **On this page** - [Request](#request) - [Response](#response) - [Examples](#examples) - [What happens during localization](#what-happens-during-localization) - [Next steps](#next-steps) ## Request ``` POST /process/localize ``` Authenticate with the `X-API-Key` header. Keys are organization-scoped and reach every engine in your organization – see [Authentication](/docs/api/authentication) for where to generate one, and [Errors and status codes](/docs/api/errors) for the full error model. | Parameter | Type | Description | | --- | --- | --- | | `engineId` | string (optional) | The localization engine ID (`eng_...`). Uses your organization's default engine if omitted. | | `sourceLocale` | string | BCP-47 source locale (e.g. `en`). | | `targetLocale` | string | BCP-47 target locale (e.g. `de`). | | `data` | object | Key-value content to translate. Nested objects and arrays are supported; the response mirrors whatever shape you send. | | `context` | string (optional) | Broad context for this translation payload, such as the product surface, audience, or purpose. Applies to the whole request. | | `hints` | object (optional) | Contextual hints per key. Keys match `data` keys; values are arrays of breadcrumb strings (e.g. `{ "nav.home": ["Navbar", "Home link"] }`) that tell the engine where a string lives, so it disambiguates short or overloaded text. | ```json { "engineId": "eng_abc123", "sourceLocale": "en", "targetLocale": "de", "data": { "greeting": "Hello, world!", "cta": "Get started" }, "hints": { "cta": ["Landing page", "Primary button"] } } ``` ## Response The response carries the translated content in the same shape you sent, plus the model that produced it and the per-request cost. The same keys come back, in the same nesting – your code can read the translation out of the structure it already knows. | Field | Type | Description | | --- | --- | --- | | `sourceLocale` | string | BCP-47 source locale, echoed from the request. | | `targetLocale` | string | BCP-47 target locale, echoed from the request. | | `data` | object | Translated key-value content, matching the input shape. | | `model` | string (optional) | [LLM model](/docs/platform/llm-models) that produced this translation, formatted `provider/model` (e.g. `anthropic/claude-sonnet-4.5`). Read it to know which model in your fallback chain actually ran. Absent when no LLM call was made – see the callout below. | | `usage` | object (optional) | Token counts and per-request cost in USD. Absent when no LLM call was made. | The `usage` object itemizes the cost of the call, so you can attribute spend without a separate billing lookup: | Field | Type | Description | | --- | --- | --- | | `inputTokens` | number | Total input tokens consumed across all chunks. | | `outputTokens` | number | Total output tokens generated across all chunks. | | `cacheReadTokens` | number | Input tokens served from the provider's prompt cache, when the model reports them. | | `cacheWriteTokens` | number | Input tokens written to the provider's prompt cache, when the model reports them. | | `llmCost` | number | Upstream [LLM](/docs/platform/llm-models) provider cost in USD. `0` when no cost was reported. | | `localizationCost` | number | Lingo.dev's per-token cost in USD, computed from `outputTokens`. | | `cost` | number | Total request cost in USD (`llmCost + localizationCost`). | ```json { "sourceLocale": "en", "targetLocale": "de", "data": { "greeting": "Hallo, Welt!", "cta": "Jetzt starten" }, "model": "anthropic/claude-sonnet-4.5", "usage": { "inputTokens": 2789, "outputTokens": 861, "cacheReadTokens": 0, "cacheWriteTokens": 0, "llmCost": 0.02129, "localizationCost": 0.001722, "cost": 0.023012 } } ``` {% callout type="info" title="When `model` and `usage` are absent" %} If `data` is empty – no keys to translate – the endpoint short-circuits without calling an LLM, and the response omits `model` and `usage`. This is the one case where the cost fields are missing, and the reason is that there was no cost: nothing was translated, so nothing was spent. Every request that triggers a translation includes both fields. Treat them as optional in your parser, and you will not be surprised by the empty-input case. {% /callout %} ## Examples The same call in five languages. Each sends a flat object for clarity; `data` accepts nested objects and arrays too, and the response comes back in whatever shape you send. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch( "https://api.lingo.dev/process/localize", { method: "POST", headers: { "X-API-Key": "your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ engineId: "eng_abc123", sourceLocale: "en", targetLocale: "de", data: { greeting: "Hello, world!", cta: "Get started", }, }), } ); const { data } = await response.json(); // { greeting: "Hallo, Welt!", cta: "Jetzt starten" } ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/process/localize", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "engineId": "eng_abc123", "sourceLocale": "en", "targetLocale": "de", "data": { "greeting": "Hello, world!", "cta": "Get started", }, }, ) data = response.json()["data"] # {"greeting": "Hallo, Welt!", "cta": "Jetzt starten"} ``` {% /tab %} {% tab label="PHP" %} ```php $response = file_get_contents( "https://api.lingo.dev/process/localize", false, stream_context_create([ "http" => [ "method" => "POST", "header" => implode("\r\n", [ "X-API-Key: your_api_key", "Content-Type: application/json", ]), "content" => json_encode([ "engineId" => "eng_abc123", "sourceLocale" => "en", "targetLocale" => "de", "data" => [ "greeting" => "Hello, world!", "cta" => "Get started", ], ]), ], ]) ); $data = json_decode($response, true)["data"]; // ["greeting" => "Hallo, Welt!", "cta" => "Jetzt starten"] ``` {% /tab %} {% tab label="Java" %} ```java HttpClient client = HttpClient.newHttpClient(); String body = """ { "engineId": "eng_abc123", "sourceLocale": "en", "targetLocale": "de", "data": { "greeting": "Hello, world!", "cta": "Get started" } } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.lingo.dev/process/localize")) .header("X-API-Key", "your_api_key") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send( request, HttpResponse.BodyHandlers.ofString() ); // Parse response.body() as JSON ``` {% /tab %} {% tab label="C#" %} ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "X-API-Key", "your_api_key" ); var response = await client.PostAsJsonAsync( "https://api.lingo.dev/process/localize", new { engineId = "eng_abc123", sourceLocale = "en", targetLocale = "de", data = new { greeting = "Hello, world!", cta = "Get started", }, } ); var result = await response.Content .ReadFromJsonAsync(); var data = result.GetProperty("data"); // { "greeting": "Hallo, Welt!", "cta": "Jetzt starten" } ``` {% /tab %} {% /tabs %} ## What happens during localization A single POST hides a sequence of steps, and it is worth knowing what they are – because they are why the output is consistent with the rest of your localized content rather than a one-off model guess. When a request hits the endpoint, the engine applies its full configuration in order: 1. **Model selection** – Selects the highest-priority [LLM model](/docs/platform/llm-models) matching the locale pair. Locale-specific models take precedence over wildcard (`*`) models. If the primary model fails, the engine falls through to the next ranked model automatically. 2. **Brand voice** – Loads the [brand voice](/docs/platform/brand-voices) for the target locale, falling back to the wildcard brand voice if no locale-specific one exists. 3. **Instructions** – Loads every [instruction](/docs/platform/instructions) matching the target locale, including wildcard instructions. 4. **Glossary lookup** – Splits input values into searchable chunks, generates embeddings, and runs a vector similarity search against the engine's [glossary](/docs/platform/glossaries). Matched terms enforce exact translations, or mark terms as non-translatable so they pass through verbatim. 5. **Generation** – Sends the composed prompt to the selected model, then parses and validates the JSON response. This is the same pipeline of engine steps the [async API](/docs/api/localization) runs per job. Calling sync instead of async changes the delivery shape, not how a translation is produced – so a string translated here and the same string translated in an async job land on the same glossary terms and the same voice. {% callout type="info" title="Model fallback is automatic, and the response tells you which one ran" %} If the primary model fails, the engine attempts the next model in rank order. This happens transparently – the response shape is identical regardless of which model produced the translation. The one signal of a fallback is the `model` field in the response: read it when you need to know exactly which model in your chain handled a given request. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Recognize" href="/docs/api/recognize" icon="globe" description="Detect the language of arbitrary text and get structured locale metadata." /%} {% link-card title="Async Localization API" href="/docs/api/localization" icon="lightning" description="One request, many locales, results as they land – when one round-trip is not enough." /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="gear" description="Configure per-locale model selection and rank-ordered fallback chains." /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale." /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define how your product speaks in each language." /%} {% /card-grid %} - [Engine Suggestions API](https://lingo.dev/en/docs/api/engine-suggestions): Generate engine improvement suggestions from free-text feedback, list the pending ones, and apply or dismiss them – all from your own code. Generation is asynchronous; applying writes the proposed glossary, instruction, or brand-voice change. Feedback about your translations rarely arrives as a dashboard click. It is a line in your own support tool, a note from a reviewer, a row in your own QA queue – "stop translating the product name", "use the formal register in German". The Engine Suggestions API turns that free text into engine changes from code: send the feedback as text, the platform reasons over it, and concrete, structured edits to your engine's glossary, instructions, or brand voice come back for you to apply. This is the programmatic counterpart to the dashboard feature. There, suggestions are generated automatically when your [AI Reviewers](/docs/platform/ai-reviewers) score a translation low; here, **you** supply the signal as text. Either way the output is the same – pending suggestions you review and apply. The flow is two halves. **Generation** is asynchronous – you hand over feedback and the platform reasons over it in the background, landing pending suggestions on the engine. **Review** is synchronous – you list the pending suggestions, read what each proposes, and apply or dismiss each one. This page covers both. For the dashboard experience – automatic generation from low review scores, the Suggestions tab, notifications – see [Engine Suggestions](/docs/platform/engine-suggestions). {% callout type="info" title="A configuration endpoint, not a translation one" %} These endpoints read and change an engine's **configuration** – its glossary, instructions, and brand voice. They are scoped to a single engine by its `:id`, and authenticate with the same organization-scoped `X-API-Key` as the rest of the API. They never translate content or alter past translations; an applied suggestion takes effect on the engine's next translation. {% /callout %} {% callout type="info" title="Authentication" %} Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the organization. See [Authentication](/docs/api/authentication) for details, and [Errors and status codes](/docs/api/errors) for the error model every endpoint here shares. {% /callout %} ## Generate from feedback ``` POST /engines/:id/suggestions/from-text ``` Send a plain-text description of what the engine is getting wrong. The platform reasons over **that text** plus the engine's current configuration, and proposes atomic edits – it will not re-propose something the engine already has. Generation runs asynchronously, so the call returns the moment the work is accepted, not when the suggestions are ready. | Parameter | Type | Description | | --- | --- | --- | | `id` (path) | string | The engine to generate suggestions for. | | `text` | string | Free-text feedback about the engine's output. 1–10,000 characters; must contain at least one non-whitespace character. | {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch( `https://api.lingo.dev/engines/${engineId}/suggestions/from-text`, { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Our German (de-DE) translations keep using the informal 'du'. For our B2B audience they must always use the formal 'Sie'.", }), }, ); const { enqueued } = await response.json(); console.log(enqueued); // true – generation accepted, running in the background ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( f"https://api.lingo.dev/engines/{engine_id}/suggestions/from-text", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "text": "Our German (de-DE) translations keep using the informal 'du'. For our B2B audience they must always use the formal 'Sie'.", }, ) result = response.json() print(result["enqueued"]) # True – generation accepted, running in the background ``` {% /tab %} {% /tabs %} ```json { "enqueued": true } ``` `enqueued: true` means the platform accepted the work, not that suggestions exist yet. Generation is one background step – it reads your text, reasons over the config, de-duplicates against what is already there, and persists whatever it proposes. A run can legitimately propose nothing (the feedback was vague, or the engine already covers it). Read the results by [listing the engine's suggestions](#list-pending-suggestions) a few moments later. {% callout type="warning" title="Empty feedback is rejected" %} `text` must contain a real message. An empty string, or whitespace only, is rejected with a `400` – it is not silently turned into a different kind of request. Send something the model can actually reason over. {% /callout %} {% callout type="info" title="Generate from review scores instead" %} The same low-score trigger that powers the dashboard is available from code: `POST /engines/:id/suggestions/generate` (empty body) asks the platform to propose edits from the engine's recent low-scoring [AI reviews](/docs/platform/ai-reviewers) instead of from text. Same `{ "enqueued": true }` response, same pending suggestions out. Reach for `from-text` when you have specific written feedback; reach for `generate` to pull suggestions from what your reviewers have already flagged. {% /callout %} ## List pending suggestions ``` GET /engines/:id/suggestions ``` Returns the engine's suggestions – the result of any generation run, whether triggered from text, from the manual button, or automatically from low review scores. Each entry is a proposed edit with its reasoning attached. ```json [ { "id": "egs_A1b2C3d4E5f6G7h8", "ownerOrganizationId": "org_X1y2Z3a4B5c6D7e8", "ownerEngineId": "eng_X1y2Z3a4B5c6D7e8", "actionType": "add_instruction", "targetKind": "instruction", "targetId": null, "targetLocale": "de-DE", "payload": { "instruction": "Use the formal 'Sie' form in all German translations; never use the informal 'du'." }, "reasoning": "Feedback states the B2B audience requires formal address, but the engine has no instruction enforcing it.", "sourceReviewLogIds": [], "status": "pending", "appliedTargetId": null, "createdAt": "2026-06-18T10:30:00.000Z" } ] ``` | Field | Description | | --- | --- | | `id` | `egs_`-prefixed suggestion identifier. Pass it to [apply](#apply-a-suggestion) or [dismiss](#dismiss-a-suggestion). | | `actionType` | One of `add_glossary_item`, `update_glossary_item`, `add_instruction`, `update_instruction`, `add_brand_voice`, `update_brand_voice`. | | `targetKind` | The part of the engine the edit touches: `glossary_item`, `instruction`, or `brand_voice`. | | `targetId` | For an `update_*` action, the id of the entry to change (`gli_` / `ins_` / `bvc_`). `null` for an `add_*` action. | | `targetLocale` | The locale the suggestion applies to. | | `payload` | The ready-to-apply edit. Its fields depend on `targetKind` – it is exactly what the create/update operation needs, which is why applying requires no further input from you. | | `reasoning` | A short explanation of why this edit is proposed. | | `sourceReviewLogIds` | The review logs whose failures motivated the suggestion (`esrl_` ids); empty when the suggestion came from feedback text. | | `status` | `pending`, `applied`, or `dismissed`. | | `appliedTargetId` | The entry created or updated once the suggestion is applied; `null` while pending. | The `payload` is the detail that makes applying cheap: the proposed change is fully structured at generation time, so applying it is a plain write, not another round of AI. You decide; the platform does not re-reason. ## Apply a suggestion ``` POST /engine-suggestions/:id/apply ``` Writes the proposed change into the engine and marks the suggestion `applied`. This is a deterministic write of the `payload` you already saw in the list – there is no second AI call, so what you reviewed is exactly what gets written. An `add_*` suggestion creates a new glossary item, instruction, or brand voice; an `update_*` suggestion changes the existing entry named by `targetId`. ```javascript const response = await fetch( `https://api.lingo.dev/engine-suggestions/${suggestionId}/apply`, { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY }, }, ); const applied = await response.json(); console.log(applied.status); // "applied" console.log(applied.appliedTargetId); // "ins_…" – the instruction it just created ``` The response is the suggestion in its `applied` state, with `appliedTargetId` now pointing at the real engine entry it created or updated. That entry is an ordinary glossary item, instruction, or brand voice from this point on – open it, edit it, or delete it like any other. {% callout type="warning" title="Apply changes config, not past translations" %} Applying edits the engine's configuration. Content already translated keeps its current output; the change shows up the next time the engine translates. Apply does not re-localize anything on its own. {% /callout %} ## Dismiss a suggestion ``` POST /engine-suggestions/:id/dismiss ``` Drops a suggestion you do not want, marking it `dismissed` and leaving the engine untouched. Use it when a proposal is wrong for your product – the engine is not changed, and the suggestion stops showing up as pending. ```javascript await fetch( `https://api.lingo.dev/engine-suggestions/${suggestionId}/dismiss`, { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY }, }, ); // The suggestion is now "dismissed"; nothing was written to the engine. ``` ## The loop, end to end The four endpoints form one cycle you can drive entirely from code: feed in feedback, read what was proposed, and commit the edits you agree with. {% steps %} {% step title="Generate" %} `POST …/suggestions/from-text` with your written feedback (or `…/suggestions/generate` to draw from low review scores instead). You get `{ "enqueued": true }` immediately. {% /step %} {% step title="List" %} `GET /engines/:id/suggestions` a moment later to read the pending suggestions, each with its `payload` and `reasoning`. {% /step %} {% step title="Apply or dismiss" %} `POST /engine-suggestions/:id/apply` to commit the edit, or `…/dismiss` to drop it. Applying takes effect on the engine's next translation. {% /step %} {% /steps %} ## Next steps {% card-grid %} {% link-card title="Engine Suggestions (feature)" href="/docs/platform/engine-suggestions" icon="gear" description="The dashboard view, automatic generation from low review scores, and notifications." /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="target" description="Score translations against your config – the signal the automatic suggestion trigger reads." /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="The enforced-translation and non-translatable rules a glossary suggestion writes." /%} {% link-card title="Instructions" href="/docs/platform/instructions" icon="file-code" description="The per-locale rules an instruction suggestion creates or updates." /%} {% /card-grid %} - [Back-translation check](https://lingo.dev/en/docs/api/pipeline/back-translation): The backTranslation pipeline stage translates each output back into the source locale, has an AI compare it against your original, and flags drift as minor, major, or critical – auto-correcting the major and critical cases. A meaning check you can read, not a quality you have to trust. The translations came back. German, French, Japanese, all populated, all well-formed. They look fine – but "looks fine" in a language you do not read is faith, not verification, and the one thing a translation can quietly get wrong is the thing you most need it to get right: the meaning. The `backTranslation` stage closes that gap without a second pair of human eyes. It translates each output back into the source locale, has an AI compare that round trip against the words you originally sent, and flags where the meaning drifted. You are not trusting that the meaning survived the round trip – you are watching the pipeline check that it did. ``` stepId: backTranslation ``` This page covers that one stage: what each step does, how drift is graded, and which cases get fixed automatically. New to the pipeline? Start with the [pipeline overview](/docs/api/pipeline) for how stages wrap the core translate step. To turn it on, see [Configure the pipeline](/docs/api/pipeline/configure); to read what a run produced, see [Observe pipeline runs](/docs/api/pipeline/observability). **On this page** - [How the check works](#how-the-check-works) - [How drift is graded](#how-drift-is-graded) - [A borrowed technique, automated](#a-borrowed-technique-automated) - [When to enable it](#when-to-enable-it) - [Reading the result](#reading-the-result) ## How the check works The stage runs after the core translation – and after [rephrase](/docs/api/pipeline/rephrase), when that is enabled – so it always verifies the current best output, the exact text that would otherwise ship. It is three steps. {% steps %} {% step title="Reverse translate" %} The translated output is translated back into the source locale, using the same [engine configuration](/docs/platform/engines) – [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), [instructions](/docs/platform/instructions) – adapted for the reverse direction. This is a real second translation, not a lookup. {% /step %} {% step title="Detect drift" %} An AI agent compares the back-translation against the original source you sent and grades any divergence by severity: `minor`, `major`, or `critical`. {% /step %} {% step title="Correct if needed" %} When the grade is `major` or `critical`, a further AI pass adjusts the forward translation to resolve the drift. `minor` divergences are recorded for observability and left as they are – the output is not rewritten over a wording nuance. {% /step %} {% /steps %} So the check does not just tell you the meaning drifted – on the cases that matter, it repairs the forward translation before that translation reaches your users. ## How drift is graded A round trip never returns the source verbatim. Two translators – or one AI in two directions – will phrase the same idea differently, and that is expected, not a defect. The grade is what separates a harmless rewording from a meaning that moved: | Severity | What it means | What the stage does | | --- | --- | --- | | `minor` | Wording differs, meaning intact – a synonym, a reordered clause, a stylistic choice. | Recorded, not corrected. | | `major` | The meaning shifted enough to matter – a changed emphasis, a dropped qualifier, an altered claim. | A correction pass adjusts the forward translation. | | `critical` | The meaning is wrong – a negation flipped, a number changed, a term inverted. | A correction pass adjusts the forward translation. | The split is the point. A check that "fixed" every reworded sentence would churn good translations and bury the real problems; a check that flagged everything and fixed nothing would just hand you a longer report. Grading lets the stage leave the harmless cases alone and spend a correction pass only where the meaning actually moved. {% callout type="info" title="What gets auto-corrected, and what does not" %} Only `major` and `critical` drift triggers a correction pass. `minor` drift is surfaced for observability and the output is left unchanged. If you want every divergence in front of a person regardless of grade, that is what [human review](/docs/api/pipeline/human-review) is for – back-translation is the automated guard, not a replacement for a reviewer. {% /callout %} ## A borrowed technique, automated Back-translation is not a Lingo.dev invention. It is a classic quality-assurance method in human translation: a second translator renders the target text back into the source language, an editor compares the two, and the comparison surfaces where meaning was lost. It catches the failure that a single forward pass hides – a translation that reads fluently and means something subtly different. The pipeline runs that same method with LLMs in place of the second translator and the editor. The technique is the established one; what is automated is the labor and the latency. That is why the stage exists – not to add an AI flourish, but to put a recognized meaning check inline in your job, on every locale, every time. ## When to enable it Back-translation earns its place wherever a wrong meaning is expensive and you cannot read the target locale yourself to catch it. Reach for it when: - The content is **legal, medical, financial, or technical** – domains where a flipped negation or a shifted qualifier is a real liability, not a style note. - You ship into **locales no one on your team reads**, so the back-translation is your only window onto whether the meaning held. - The output is **high-stakes and low-volume** – a contract clause, a dosage instruction, a compliance notice – where the extra checking cost is trivial against the cost of being wrong. {% callout type="warning" title="It runs a second translation – so it costs more" %} It performs a full reverse translation and an AI comparison on every output, plus a correction pass on top of that whenever drift is graded `major` or `critical` – so a job with back-translation on does more model work than a forward-only translation. Each enabled stage records its own cost on the job, so you can see exactly what the check added. Turn it on where meaning fidelity is worth that cost, and leave it off for high-volume, low-risk strings where a forward translation is enough. {% /callout %} It pairs naturally with [rephrase](/docs/api/pipeline/rephrase): rephrase makes the copy read native, back-translation confirms that the native-sounding rewrite still says what the source said. Both are toggled independently – see [Configure the pipeline](/docs/api/pipeline/configure). ## Reading the result Like every pipeline stage, an enabled back-translation check writes its own record into the job's `steps` array. Fetch the job with [`GET /jobs/localization/:jobId`](/docs/api/localization/jobs) and the `backTranslation` step tells you the check ran and how it resolved: ```json { "id": "ljb_C3d4E5f6G7h8I9j0", "status": "completed", "outputData": { "clause": "Diese Vereinbarung darf nicht ohne schriftliche Zustimmung übertragen werden." }, "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "createdAt": "2026-04-17T10:00:00Z", "startedAt": "2026-04-17T10:00:00Z", "completedAt": "2026-04-17T10:00:09Z" }, { "stepId": "backTranslation", "type": "action", "status": "completed", "errorMessage": null, "createdAt": "2026-04-17T10:00:09Z", "startedAt": "2026-04-17T10:00:09Z", "completedAt": "2026-04-17T10:00:21Z" } ] } ``` A `completed` `backTranslation` step means the check ran end to end; if it graded `major` or `critical` drift, the `outputData` you read is the corrected forward translation, not the pre-check one. The full breakdown – the severities the check flagged, and what a stage `failed` or `skipped` means for the job as a whole – lives on [Observe pipeline runs](/docs/api/pipeline/observability), the canonical home for reading `steps` and `warnings`. That is the whole stage: a reverse translation, a graded comparison, and a correction where the meaning moved – so the fidelity of every output is verified inline, not assumed. Turn it on per request or set it as an engine default, then read the step to confirm the round trip held. ## Next steps {% card-grid %} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable backTranslation per request or as an engine default, alongside the other stages." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the steps array, the flagged severities, and what completed, failed, and skipped mean." /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="globe" description="The stage back-translation verifies when both are on – native-sounding copy, meaning confirmed." /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Fetch outputData and the steps array where the back-translation result shows up." /%} {% /card-grid %} - [Live progress over WebSocket](https://lingo.dev/en/docs/api/localization/realtime): Stream per-locale localization progress into your UI over a WebSocket where every message carries the full group state – so you render the snapshot instead of reconciling deltas, and never miss an event. You created a job group. Somewhere a user is watching a spinner, and "translating into 14 languages…" is true but useless – it never moves. You want the count to climb in front of them: 3 ready, then 4, then a locale that failed, then done. Polling the [job group](/docs/api/localization/job-groups) gets you there, but it is chatty, and each poll hands you a fresh snapshot you have to diff against the last one to know what actually changed. The WebSocket inverts that. Connect once and the server pushes an event every time a locale resolves – and **every message carries the full group state**, so you render the snapshot, you never reconcile a delta. Drop a frame, reconnect, restart the tab: the next message is the whole truth again. ``` GET /jobs/localization/groups/:groupId/ws ``` New to async localization? Start with the [Overview](/docs/api/localization). The `groupId` here is the one you got back when you [created the jobs](/docs/api/localization/create). **On this page** - [Message types](#message-types) - [Message payloads](#message-payloads) - [Wiring it into your UI](#wiring-it-into-your-ui) - [Keep your API key server-side](#keep-your-api-key-server-side) ## Message types Four message types travel over the socket. Each one tells you what just happened and hands you the current state of the whole group alongside it. | Type | When | Key fields | | --- | --- | --- | | `snapshot` | On initial connection | Full group state | | `job.completed` | A locale finishes successfully | `jobId`, `locale`, plus full group state | | `job.failed` | A locale fails | `jobId`, `locale`, `error`, plus full group state | | `group.completed` | Every job has resolved | `groupId`, `status`, plus full group state. The server closes the connection after this message. | Every message contains a `snapshot` object with the current group state: `totalJobs`, `completedJobs`, `completedWithWarningsJobs`, `failedJobs`, and a `jobs` map keyed by job ID, each with its `locale` and `status`. Those counts are the same ones the [job group endpoint](/docs/api/localization/job-groups) reports – so a snapshot off the socket and a poll off the REST endpoint agree on how far the group has progressed. {% callout type="info" title="render the snapshot, never reconcile" %} You never need to track which events you have already seen, replay missed messages, or merge a partial update into local state. Read `snapshot` on every message and paint your UI from it. A reconnect re-sends `snapshot` first, so a client that just joined and a client that has been listening the whole time converge on the same state. {% /callout %} ## Message payloads These are the exact frames the server sends. The IDs are real shapes (`ljg_` for the group, `ljb_` for each job); the `snapshot` is abbreviated with `"..."` only where it repeats the structure already shown. On connect, the server sends the current state: ```json { "type": "snapshot", "snapshot": { "groupId": "ljg_A1b2C3d4E5f6G7h8", "totalJobs": 3, "completedJobs": 1, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": { "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" }, "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" }, "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" } } } } ``` As each locale finishes, the event names the locale that changed and includes the updated snapshot: ```json { "type": "job.completed", "jobId": "ljb_B2c3D4e5F6g7H8i9", "locale": "fr", "snapshot": { "groupId": "ljg_A1b2C3d4E5f6G7h8", "totalJobs": 3, "completedJobs": 2, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": { "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" }, "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" }, "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" } } } } ``` A failure is a normal message, not a dropped connection. `job.failed` carries the locale and an `error`, and the same full snapshot – the failed locale shows `status: "failed"` in the `jobs` map, every other locale keeps streaming, and the socket runs on to `group.completed`: ```json { "type": "job.failed", "jobId": "ljb_C3d4E5f6G7h8I9j0", "locale": "ja", "error": "Model timeout after 30 seconds", "snapshot": { "...": "..." } } ``` When every job has resolved, the server sends a final event and closes the connection: ```json { "type": "group.completed", "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "completed", "snapshot": { "...": "..." } } ``` The terminal `status` is `completed` when every locale succeeded, `completed_with_warnings` when every locale produced output but one or more optional [pipeline](/docs/api/pipeline) stages failed on at least one of them, `partial` when some locales succeeded and some failed, and `failed` when all of them failed. For what each of those means for the group as a whole, see [Track a job group](/docs/api/localization/job-groups). {% callout type="info" title="Render from snapshot on anything you do not recognize" %} Switch on the message types you know, and fall through to re-rendering from `snapshot` on anything you do not recognize. Every message carries a full snapshot, so a client that defaults to painting from it stays correct even on a frame it has no specific branch for. {% /callout %} ## Wiring it into your UI The group is your progress model. When you [created the jobs](/docs/api/localization/create), the 202 handed you a `groupId` and a `jobs` array – one entry per locale. Seed your progress record from that response and you have the shape the socket will fill in: the total to count toward, and a counter starting at zero. ```javascript const { groupId, jobs } = await response.json(); await db.translationProgress.create({ contentId: content.id, groupId, totalLanguages: jobs.length, completedLanguages: 0, }); ``` Then open the socket against that `groupId`, and on every message read `snapshot` and repaint. Watch the counter climb as locales land, and stop when `group.completed` arrives: ```javascript import WebSocket from "ws"; const groupId = "ljg_A1b2C3d4E5f6G7h8"; const ws = new WebSocket( `wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`, { headers: { "X-API-Key": process.env.LINGO_API_KEY } } ); ws.on("message", (raw) => { const event = JSON.parse(raw); const { snapshot } = event; switch (event.type) { case "snapshot": console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`); break; case "job.completed": console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`); break; case "job.failed": console.error(`${event.locale} failed: ${event.error}`); break; case "group.completed": console.log(`All translations done: ${event.status}`); ws.close(); break; } }); ``` Running against a three-locale group, that prints the run as it happens: ``` 1/3 complete fr ready (2/3) ja failed: Model timeout after 30 seconds All translations done: partial ``` The counter moved on its own, one locale failed without taking the stream down, and `partial` told you where the run landed – exactly what your spinner needs to become a real progress bar. Notice the loop never accumulates state: each branch reads from the `snapshot` on the message in hand, so the same code is correct on first connect, on every update, and on reconnect. ## Keep your API key server-side The socket authenticates with your API key, the same [organization-scoped key](/docs/platform/api-keys) the REST endpoints use. That means the browser is the wrong place to open it – an API key in client JavaScript reaches every engine in your organization, for anyone who views source. {% callout type="warning" title="Connect from your backend, not the browser" %} Open the WebSocket from your server, where the key already lives, then fan the events out to the browser over your own channel – a WebSocket or server-sent events stream you control. Your frontend gets live progress; your key never leaves your infrastructure. {% /callout %} This mirrors the [webhook](/docs/api/localization/webhooks) model: the connection that touches Lingo.dev is server-side, and what reaches the user is whatever your own app chooses to forward. ## Where this fits The WebSocket is the live view – it is bound to one group and closes when that group is done. For durable, server-to-server delivery that survives a tab closing or a deploy, pair it with [webhooks](/docs/api/localization/webhooks): the socket drives the UI while the run is on screen, the webhook records each result the moment it lands. Wire both from the same [create call](/docs/api/localization/create) and your users see progress as it happens while your backend keeps the output regardless of who is watching. {% card-grid %} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Durable server-to-server delivery of each locale as it completes" /%} {% link-card title="Create jobs" href="/docs/api/localization/create" icon="lightning" description="Submit content for translation and get the groupId you connect to here" /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Group statuses and what partial completion means for the group" /%} {% /card-grid %} - [Rephrase for natural copy](https://lingo.dev/en/docs/api/pipeline/rephrase): The rephrase pipeline stage rewrites an accurate translation so it reads like native copy in the target locale – keeping placeholders, tags, and meaning intact. Non-critical and opt-in; enable it for marketing copy, skip it where literal accuracy matters. The translation is accurate, the glossary terms are right, and it still reads like a translation. Rephrase is the pipeline stage that closes that last gap: an AI agent rewrites the current output so it reads like native copy in the target locale, while preserving the meaning, the placeholders, and the tags. This page covers the `rephrase` stage on its own – what it rewrites, what it leaves untouched, what happens when the pass fails, and the one decision it puts in front of you: enable it or skip it. New to the pipeline? Start with the [Async Localization Pipeline overview](/docs/api/pipeline) for how the stages fit together. Rephrase is async-only – it runs for jobs created through the [Async Localization API](/docs/api/localization), never for the synchronous [`/localize`](/docs/api/localize) call. ## What it does A literal translation can carry the source's phrasing into the target locale – grammatically correct, but recognizably translated. The rephrase stage rewrites the current best output so it reads like native copy: natural and idiomatic in the target locale, preferring an idiomatic equivalent over a word-for-word rendering. It preserves the original meaning and intent, and it applies your engine's [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) – the same configuration that produced the translation governs the rewrite. It runs after the AI and human refinement steps, on whatever output reached it. So it works the same whether or not [human review](/docs/api/pipeline/human-review) and [AI post-edit](/docs/api/pipeline/ai-review) are enabled – it rewrites the current best version either way. When the [back-translation check](/docs/api/pipeline/back-translation) is also enabled, it verifies the rephrased output, not the pre-rephrase one. A literal pass keeps you close to the source wording; rephrase moves the copy toward how a native writer would phrase it. Therefore the two aren't interchangeable – which you want is the decision below. ## Your placeholders and tags survive The obvious worry about a stage whose job is to rewrite text: will it touch the parts that are not prose? It does not. Rephrase keeps placeholders, variables, tags, and formatting exactly as-is – it rewrites the words around them, not the tokens your app depends on. So a string like this keeps every interpolation and every tag, and only the human-readable copy changes: ``` Source (en): "Hi {firstName}, you have {count} new messages." Translated (de): "Hallo {firstName}, du hast {count} neue Nachrichten." After rephrase (de):"Hey {firstName}, {count} neue Nachrichten warten auf dich." ``` `{firstName}`, `{count}`, and the `` tags are identical across all three. The copy reads more natural in German; the structure the runtime relies on is unchanged. ## When it fails, the job does not Rephrase is a **non-critical** stage. An AI rewrite can fail or time out – and when it does, the translation you already paid for is not lost. The previous output carries forward unchanged and the job continues. You are not gambling an accurate translation on a stylistic pass. A failed rephrase does not fail the job. It surfaces as a step record with `status: failed`, the job finishes as `completed_with_warnings`, and the pre-rephrase translation is what lands in `outputData`: ```json { "id": "ljb_C3d4E5f6G7h8I9j0", "status": "completed_with_warnings", "outputData": { "greeting": "Hallo {firstName}, du hast {count} neue Nachrichten." }, "warnings": [ { "step": "rephrase", "message": "" } ], "steps": [ { "stepId": "localize", "type": "action", "status": "completed" }, { "stepId": "rephrase", "type": "action", "status": "failed" } ] } ``` The `de` translation shipped. The exact `message` text is illustrative here – what is fixed is the shape: a `warnings[]` entry with `step` and `message`, the `rephrase` step recorded as `failed`, and the pre-rephrase output preserved in `outputData`. Read the `step` field to see the copy was not polished on this run, so you can re-run that locale if natural phrasing matters for it. See [Observe pipeline runs](/docs/api/pipeline/observability) for the full `steps[]` and `warnings` shape, and how non-critical failures roll up to `completed_with_warnings`. {% callout type="info" title="Non-critical means best-effort, by design" %} Enabling rephrase cannot lower the reliability of a job. The worst case is the translation you would have shipped without the stage, plus a warning. That is what lets you turn it on broadly and treat the polished copy as an upgrade rather than a dependency. {% /callout %} ## When to enable, when to skip Rephrase optimizes for one thing: reading like a native original. That is exactly right for some content and exactly wrong for other content, so this is a per-content decision, not a global default. **Enable it for** marketing copy, landing pages, product descriptions, and onboarding – anywhere reading like native copy matters more than staying close to the source phrasing. **Skip it for** technical and legal content, where literal accuracy is the priority. Rephrase rewrites wording to sound natural; in a contract clause, an API reference, or a compliance string, the wording closer to the source is the safer one. For that content, leave rephrase off and let the [core localization](/docs/api/pipeline) step's output stand. {% callout type="warning" title="Natural phrasing is a trade, not a free win" %} Rephrase moves the copy away from the source wording on purpose. That is the point for marketing, and the risk for anything where the precise wording carries legal or technical weight. If you are unsure which side a given payload is on, skip rephrase for it – literal is the safer default. {% /callout %} ## Turning it on Rephrase is configured like every other stage – an engine-level default plus an optional per-request override – so the full mechanics live on [Configure the pipeline](/docs/api/pipeline/configure). The short version: toggle it in the engine's **Pipeline** tab to apply it to every job, or set it for a single submission with `pipelineConfig`: ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "headline": "Ship global products faster." }, "pipelineConfig": { "rephrase": { "enabled": true } } } ``` Stages you omit inherit the engine config – so the override above turns rephrase on for this submission without touching any other stage. That is what lets you keep rephrase off on the engine for literal content and switch it on per request for the marketing payloads that want it. ## Next steps {% card-grid %} {% link-card title="Back-translation check" href="/docs/api/pipeline/back-translation" icon="shield" description="Verify the rephrased output preserves the source meaning - reverse-translate, detect drift, auto-correct." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Set rephrase as an engine default or override it per request alongside the other stages." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the rephrase step record and see how a non-critical failure becomes completed_with_warnings." /%} {% link-card title="Pipeline overview" href="/docs/api/pipeline" icon="gear" description="How rephrase fits with pre-edit, human review, AI post-edit, and back-translation." /%} {% /card-grid %} - [Webhook delivery](https://lingo.dev/en/docs/api/localization/webhooks): When an async localization job finishes, Lingo POSTs the result to your callbackUrl – one request per locale, translation.completed with the data or translation.failed with the error. Return 200 first, process after. You [created a job group](/docs/api/localization/create) and got a 202 back in milliseconds. The translations are now running in the background, one job per locale. You could [poll each job](/docs/api/localization/jobs) until it finishes – but you'd rather not run a polling loop just to learn that German is ready. You want your server told the moment each locale lands. That is what the webhook does. When you pass a `callbackUrl` while creating jobs, Lingo POSTs the result to that URL as each job reaches a terminal state – **one POST per locale, the moment it lands.** A locale that translates cleanly arrives as `translation.completed` with the data. A locale that fails arrives as `translation.failed` with the error. You are told either way, per language, without asking. This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with provisioning and lives on the [webhook signature verification](/docs/api/webhooks) page, linked at each point you'll need it. **On this page** - [How delivery works](#how-delivery-works) - [The completed payload](#the-completed-payload) - [The failed payload](#the-failed-payload) - [Handling a webhook](#handling-a-webhook) - [When delivery is the wrong tool](#when-delivery-is-the-wrong-tool) ## How delivery works Each locale in a group is an independent job. The instant one reaches a terminal state, its result is delivered to your `callbackUrl` on its own – Lingo does not wait for the slowest locale, and does not batch the group into a single call. Fourteen target locales means up to fourteen POSTs, arriving as each language finishes, in whatever order they finish. Set the destination per request with `callbackUrl` when you [create the job group](/docs/api/localization/create), or set an organization default in the dashboard that every group inherits. A per-request `callbackUrl` overrides the org default for that group. {% callout type="warning" title="HTTPS only" %} `callbackUrl` must use HTTPS. An HTTP URL is rejected with a 400 when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point. {% /callout %} Two payload shapes cross the wire, distinguished by their `type` field: `translation.completed` and `translation.failed`. Both name the job and group they belong to and the locale they carry, so a single handler can route on `type` and update the right record. {% callout type="info" title="Handle unknown event types gracefully" %} Today the wire carries `translation.completed` and `translation.failed`. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler. {% /callout %} ## The completed payload When a job finishes successfully, the payload carries the translated `data` – the same shape you'd get from [fetching the job](/docs/api/localization/jobs), pushed to you instead of polled. The `data` mirrors the structure you submitted: every string translated, every non-string value (numbers, booleans, `null`) preserved, nesting intact. ```json { "type": "translation.completed", "jobId": "ljb_A1b2C3d4E5f6G7h8", "groupId": "ljg_A1b2C3d4E5f6G7h8", "sourceLocale": "en", "targetLocale": "de", "data": { "id": "course_101", "title": "Einführung in maschinelles Lernen", "steps": [ { "heading": "Was ist ML?", "body": "Maschinelles Lernen ist ein Teilbereich der künstlichen Intelligenz." }, { "heading": "Überwachtes Lernen", "body": "Trainieren eines Modells mit gelabelten Daten." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } } } ``` | Field | Description | | --- | --- | | `type` | `translation.completed` | | `jobId` | The job that finished (`ljb_` prefix) | | `groupId` | The group it belongs to (`ljg_` prefix) | | `sourceLocale` | The source locale you submitted | | `targetLocale` | The locale this payload was translated into | | `data` | Translated content, matching the structure of the `data` you submitted | A job that produces output is not a failure – so a job that finished as `completed_with_warnings` (output produced, but an optional [pipeline](/docs/api/pipeline) stage fell through) is delivered as `translation.completed`, with usable `data`. The webhook tells you the locale is ready; the per-step warnings that explain the fall-through live on the [single job](/docs/api/localization/jobs), which you fetch by `jobId` when you want them. ## The failed payload A locale can fail – a model can time out, every configured model can be unavailable. When a job reaches `failed`, you are still told. The payload type is `translation.failed`, and it carries an `error` string in place of `data`: ```json { "type": "translation.failed", "jobId": "ljb_C3d4E5f6G7h8I9j0", "groupId": "ljg_A1b2C3d4E5f6G7h8", "sourceLocale": "en", "targetLocale": "ja", "error": "Model timeout after 30 seconds" } ``` | Field | Description | | --- | --- | | `type` | `translation.failed` | | `jobId` | The job that failed | | `groupId` | The group it belongs to | | `sourceLocale` | The source locale you submitted | | `targetLocale` | The locale that failed | | `error` | Human-readable failure description | The failure is scoped to one locale. If you submitted `de`, `fr`, and `ja`, a `ja` failure is delivered as its own `translation.failed` POST while `de` and `fr` arrive as `translation.completed` – the German and French translations ship regardless. The group's [partial-failure status](/docs/api/localization/job-groups) reflects the mix. To recover the failed locale, submit a new job for just that locale with a fresh idempotency key. ## Handling a webhook A skeptical reader's first thought here is the right one: *my handler does real work – a database write, a cache bust, a fan-out to connected clients – so won't that hold the connection open long enough to time the webhook out?* It would, so don't make Lingo wait for it. **Return 200 first, then process.** Acknowledge receipt immediately and do the real work after the response is sent. A handler that returns promptly keeps delivery healthy; a handler that blocks on downstream work invites a retry it didn't need. ```javascript app.post("/webhooks/translations", verifyWebhook, async (req, res) => { // Acknowledge first - one POST per locale, the moment it lands. res.status(200).send("ok"); const { type, jobId, groupId, targetLocale, data } = req.body; if (type === "translation.completed") { await db.content.update({ where: { groupId }, data: { [`content_${targetLocale}`]: data }, }); // Advance your own progress model - your UI can poll this or receive it over SSE. await db.translationProgress.increment({ where: { groupId }, data: { completedLanguages: { increment: 1 } }, }); } if (type === "translation.failed") { console.error(`Translation failed: ${jobId} (${targetLocale})`, req.body.error); } }); ``` The `verifyWebhook` middleware is the one piece this page doesn't define. Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so it isn't a scheme you have to reverse-engineer. How you verify it – and the retry schedule behind a non-2xx response – is documented in full on [webhook signature verification](/docs/api/webhooks), shared with provisioning. Wire that middleware in before you trust a payload: an unverified body is an unauthenticated one. {% callout type="warning" title="Verify before you trust the body" %} Your endpoint is a public URL; anyone can POST to it. Verify the signature against the raw request body before acting on any payload. The how – headers, the HMAC, the `whsec_` secret – is on the [signature verification](/docs/api/webhooks) page. {% /callout %} ## When delivery is the wrong tool The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away. If your endpoint was down when a result was delivered, the platform retries – and if every retry is exhausted, the result isn't lost. It stays [retrievable by `jobId`](/docs/api/localization/jobs); the job's `callbackStatus` records whether the push ultimately succeeded. The retry schedule itself is on the [signature and delivery](/docs/api/webhooks) page. The webhook saves you a polling loop in the common case; the job record is always there underneath it in the uncommon one. And if what you want is live progress in a UI – a counter ticking from 3 of 14 to 4 of 14 as locales land, rather than a per-locale callback to your server – that is the job-group WebSocket, not the webhook. {% card-grid %} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream group progress to a UI with full-state snapshots, instead of per-locale callbacks to your server." /%} {% link-card title="Webhook signature verification" href="/docs/api/webhooks" icon="shield" description="Verify the signature, read the headers, and handle the retry schedule – shared across all webhook deliveries." /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Fetch any result by jobId, including warnings – the source of truth behind every delivery." /%} {% /card-grid %} - [AI review](https://lingo.dev/en/docs/api/pipeline/ai-review): postEdit runs after human review and reconciles the human's edit back to your engine config – glossary, brand voice, and instructions. It only runs when the human stage produced output, and it changes the translation rather than scoring it. After a human review, reconcile the edit back to your engine config. You turned on [human review](/docs/api/pipeline/human-review), and a translator fixed a German string by hand. Good – but in fixing it, they may have quietly replaced a glossary term, used a register your brand voice rules forbid, or worded something the engine's instructions tell it to word differently. The human's edit is now authoritative, and it has overridden the configuration you tuned the engine to hold. The post-edit stage (`postEdit`) is the AI pass that closes that gap: it takes the human's edit as input and reconciles it back to your engine config before the job finishes. ``` postEdit → runs after humanEdit, reconciles its output to engine config ``` {% inline-callout %} New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline). {% /inline-callout %} That is the whole job of this stage, and it is a narrow one. It acts on the human's edit, not the source, and it does not score the result – it adjusts the human's output so it conforms to the same [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) every other translation on the engine already follows. ## What it adjusts A human translator brings judgment the engine cannot – nuance, context, a fix for a phrasing that read wrong. That judgment is exactly why you enabled the [human review](/docs/api/pipeline/human-review) stage. But a human working a queue does not have your full engine configuration in their head, so an edit can introduce variations that conflict with engine-level rules. The post-edit pass reconciles those two without discarding either. It reviews the human's output against three layers of engine config and adjusts the text to align with them: - [**Glossary**](/docs/platform/glossaries) – the source terms you map to exact translations per locale, and the terms that must never be translated. If a human edit swapped an approved term for a synonym, this pass brings it back. - [**Brand voice**](/docs/platform/brand-voices) – the tone and register the engine holds steady across languages. An edit that drifted formal-to-casual is reconciled to the voice you defined. - [**Instructions**](/docs/platform/instructions) – the standing rules the engine follows on every translation, applied here to the human's edit as they are to a fresh one. The human's intent survives. What changes is the places where their wording collided with a rule the rest of your translations obey. The output that carries forward to the [remaining stages](/docs/api/pipeline) is the human's edit, reconciled. ## When it runs The post-edit stage is only available when [human review](/docs/api/pipeline/human-review) is enabled, because the human's edit is its input – there is nothing to reconcile without one. Two consequences follow, and the second one is the one that surprises people: - **It depends on `humanEdit`.** In the engine config, `postEdit` cannot be enabled until human review is on. Enable human review first; post-edit reconciles its output. - **It is skipped when the human stage produces no output, even with `postEdit.enabled: true`.** Human review is event-driven and has a timeout – default 48 hours – and on timeout the AI translation becomes final with no human edit. If the human stage times out or is otherwise skipped, there is no edit to reconcile, so post-edit is skipped too. You will see a `skipped` step record, not a failure. {% callout type="warning" title="Set postEdit.enabled but saw nothing happen?" %} The most common reason is that the human stage produced no output. Post-edit runs only on the human's edit – if [human review](/docs/api/pipeline/human-review) timed out (default 48h) or was skipped, there is nothing for this stage to reconcile, and it is skipped regardless of `postEdit.enabled`. Check the human stage's outcome first: a `skipped` `humanEdit` step means `postEdit` was skipped for the same reason. The per-stage record that shows this lives on [Observe pipeline runs](/docs/api/pipeline/observability). {% /callout %} ## Not the same as AI Reviewers This is the distinction worth getting right before you turn the stage on, because the names are close and the behavior is opposite. {% callout type="warning" title="Not the same as AI Reviewers" %} The post-edit stage **modifies the translation output** to conform with your engine rules. [AI Reviewers](/docs/platform/ai-reviewers) are a separate feature that **scores translation quality asynchronously without altering the output**. One changes the text; the other grades it and leaves it alone. You can run both together: post-edit reconciles the human's edit, then AI Reviewers score the reconciled result. {% /callout %} If your goal is to keep human edits inside your engine's rules, that is post-edit – it acts on the output. If your goal is a quality signal you can monitor and report on without touching the translation, that is [AI Reviewers](/docs/platform/ai-reviewers). They answer different questions, and using one does not replace the other. ## Configuration and observability Post-edit is one stage in the engine pipeline, toggled like the others. Its schema entry is `postEdit { enabled }`, with the standing rule that it cannot be enabled unless `humanEdit` is. To turn it on for every job, set it on the engine's Pipeline tab; to turn it on for a single submission, pass it in the request's `pipelineConfig`. Both layers, and the rule that ties `postEdit` to `humanEdit`, are specified on [Configure the pipeline](/docs/api/pipeline/configure). When the stage runs, it appears as a `postEdit` entry in the job's `steps[]` array – `completed` when it reconciled the edit, `skipped` when the human stage gave it nothing to work with. The full step-record shape and the completed/failed/skipped semantics live on one canonical page: [Observe pipeline runs](/docs/api/pipeline/observability). ## Next steps {% card-grid %} {% link-card title="Human review" href="/docs/api/pipeline/human-review" icon="gear" description="The stage that produces the edit post-edit reconciles – Internal vs External, tiers, and the 48h timeout." /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="gear" description="The next optional stage – rewrite the reconciled output to read like native copy." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable postEdit on the engine or per request, and the rule that ties it to human review." /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="shield" description="Score translation quality without altering the output – the feature this stage is not." /%} {% /card-grid %} - [Provisioning live progress](https://lingo.dev/en/docs/api/provisioning/realtime): Stream a provisioning job's progress over a WebSocket while the AI crawls your sources and configures the engine. The server sends a snapshot on connect, then crawling, configuring, completed, and failed events as the workflow advances – and you can connect at any point, even after the job has finished. You [created a provisioning job](/docs/api/provisioning/create) and got back a `pjb_` job ID and an `eng_` engine ID in milliseconds. The engine is usable already, but it is still filling in: an AI agent is crawling your sources and writing brand voices, glossary items, and instructions onto it. While that runs you want to show the work – a "Crawling your style guide… configuring the engine… done" line, the way an install wizard does, instead of a spinner that says nothing. The WebSocket gives you exactly that feed. Connect to the job and the server pushes a snapshot of the current state, then a `provisioning.progress` event each time the workflow moves to a new step. And because the server sends the current state on connect and closes a finished job right after, **you can connect any time, even after it finishes** – there is no window you have to catch. ``` GET /jobs/provisioning/:jobId/ws ``` The `jobId` is the `pjb_` value from the [create call](/docs/api/provisioning/create). New to async provisioning? Start with the [Overview](/docs/api/provisioning) for the mental model. **On this page** - [Message types](#message-types) - [Snapshot on connect](#snapshot-on-connect) - [Progress events](#progress-events) - [Connecting after the job finishes](#connecting-after-the-job-finishes) - [Wiring it into your UI](#wiring-it-into-your-ui) - [Keep your API key server-side](#keep-your-api-key-server-side) ## Message types Two message types travel over the socket. The first arrives once, on connect; the second arrives repeatedly, as the job advances. | Type | When | Key fields | | --- | --- | --- | | `provisioning.snapshot` | On initial connection | `jobId`, `status`, `errorMessage` | | `provisioning.progress` | As each workflow step starts or completes | `jobId`, `step`, `detail` | This is a liveness feed, not a results feed: it tells you where the job is and whether it failed, not which records the AI created. The summary of everything provisioned – the brand-voice, glossary, and instruction IDs – arrives separately, in the [completion webhook](/docs/api/provisioning/webhooks) or by reading the job once it is done. Keep the socket for the progress bar; reach for the webhook for the payload. ## Snapshot on connect The instant you connect, the server reads the job's current state from the database and sends it. No progress event is required first – the snapshot stands on its own. ```json { "type": "provisioning.snapshot", "jobId": "pjb_A1b2C3d4E5f6G7h8", "status": "in_progress", "errorMessage": null } ``` | Field | Description | | --- | --- | | `status` | `in_progress`, `completed`, or `failed`. | | `errorMessage` | The failure description when `status` is `failed`, otherwise `null`. | The snapshot is the one message you are guaranteed to receive. If the job is still running you will get progress events after it; if the job has already finished you will get the snapshot and nothing more (see [below](#connecting-after-the-job-finishes)). ## Progress events As the workflow runs, the server broadcasts a `provisioning.progress` event each time it enters a new step. Each event names the `step` and carries a human-readable `detail`. ```json { "type": "provisioning.progress", "jobId": "pjb_A1b2C3d4E5f6G7h8", "step": "crawling", "detail": "Crawling source URLs..." } ``` | `step` | When | Example `detail` | | --- | --- | --- | | `crawling` | Source URLs are being fetched | `"Crawling source URLs..."` or `"Retrying crawl (attempt 2)..."` | | `configuring` | The AI agent is analyzing content and writing engine config | `"AI agent analyzing content and configuring engine..."` or `"Retrying configuration (attempt 2)..."` | | `completed` | The job finished successfully | `"Provisioning complete"` | | `failed` | The job failed | An error message describing the failure | {% callout type="info" title="A retry is not a failure" %} The `crawling` and `configuring` steps can fire more than once – a transient fetch or analysis error retries, and the retry surfaces as a progress event with a `detail` like `"Retrying crawl (attempt 2)..."`. That is the job recovering, not the job failing. Treat only the `failed` step as terminal; its `detail` carries the actual reason. {% /callout %} {% callout type="info" title="Handle steps you do not recognize" %} New `step` values may be added over time. Switch on the steps you know, treat `completed` and `failed` as the two that close the socket, and ignore anything else as informational – a forward-compatible client keeps working without an update. {% /callout %} ## Connecting after the job finishes The hard question with any progress socket is what happens if you connect late – after the crawl is done, after a deploy reconnected the tab, after the job has already failed. Here the answer is built into how the snapshot works. If the job has already reached `completed` or `failed`, the server sends the snapshot with that final `status` (and `errorMessage`, if it failed) and closes the connection immediately. There are no progress events to replay, because the final state is the snapshot. A job still in flight keeps the connection open and streams progress; a finished job hands you the outcome and hangs up. Either way, the first message tells you where things stand. **Connect any time, even after it finishes** – you cannot connect too early and you cannot connect too late. ## Wiring it into your UI Open the socket against the `pjb_` job ID, read the snapshot to set your initial state, then update on each progress event and close when the job reaches `completed` or `failed`: ```javascript import WebSocket from "ws"; const jobId = "pjb_A1b2C3d4E5f6G7h8"; const ws = new WebSocket( `wss://api.lingo.dev/jobs/provisioning/${jobId}/ws`, { headers: { "X-API-Key": process.env.LINGO_API_KEY } } ); ws.on("message", (raw) => { const event = JSON.parse(raw); switch (event.type) { case "provisioning.snapshot": console.log(`status: ${event.status}`); break; case "provisioning.progress": console.log(`${event.step}: ${event.detail}`); if (event.step === "completed" || event.step === "failed") { ws.close(); } break; } }); ``` Run against a job that crawls cleanly and that prints the configuration happening, step by step: ``` status: in_progress crawling: Crawling source URLs... configuring: AI agent analyzing content and configuring engine... completed: Provisioning complete ``` That is the whole arc on screen: the job opens `in_progress`, you watch it crawl and then configure, and `completed` tells you the engine is fully provisioned. The same loop is correct on a late connect – a finished job sends one snapshot with its final `status` and the socket closes, so the code that handles the live run handles the replay without a special case. ## Keep your API key server-side The socket authenticates with your API key – the same [organization-scoped key](/docs/platform/api-keys) the REST endpoints use. That key reaches every engine in your organization, so the browser is the wrong place to open the connection: anyone who views source would see it. {% callout type="warning" title="Connect from your backend, not the browser" %} Open the WebSocket from your server, where the key already lives, then forward the progress to the browser over your own channel – a WebSocket or server-sent events stream you control. Your frontend shows the engine configuring; your key never leaves your infrastructure. {% /callout %} This mirrors the [webhook](/docs/api/provisioning/webhooks) model: the connection that touches Lingo.dev runs server-side, and what reaches the user is whatever your own app chooses to forward. ## Where this fits The WebSocket is the live view – it is bound to one job and closes when that job is done. For a durable, server-to-server record of the result that survives a closed tab or a deploy, pair it with the [completion webhook](/docs/api/provisioning/webhooks): the socket drives the progress bar while the job is on screen, the webhook delivers the summary of everything the AI created the moment it lands. Wire both from the same [create call](/docs/api/provisioning/create). {% card-grid %} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="The completion and failure payloads, with the full summary of brand voices, glossary items, and instructions - plus signature verification." /%} {% link-card title="Create a provisioning job" href="/docs/api/provisioning/create" icon="gear" description="POST /jobs/provisioning - where the pjb_ job ID you connect to here comes from." /%} {% link-card title="Translate with your new engine" href="/docs/api/localization" icon="lightning" description="Once the job completes, fan content out to every locale through the async Localization API." /%} {% /card-grid %} - [List jobs](https://lingo.dev/en/docs/api/localization/list): Page through historical localization jobs with GET /jobs/localization – cursor pagination plus engineId and status filters, newest first, nextCursor null on the last page. Webhooks and the [live WebSocket](/docs/api/localization/realtime) tell you about a job the moment it resolves. But neither helps the next morning, after a deploy, or when you want every locale that failed in the last hour. The moment passed; the event is gone. The jobs are not – each one is a durable record on the platform, long after the process that submitted it has moved on. `GET /jobs/localization` is how you reach back for those records. It returns your jobs newest first, in pages you walk with a cursor, narrowed by the engine they ran on or the status they ended in. This is the catch-up channel: the durable record you query when you weren't listening live. ``` GET /jobs/localization ``` New to async localization? Start with the [Overview](/docs/api/localization). This page assumes you already have jobs to look through. Like every endpoint, it authenticates with your [`X-API-Key`](/docs/api/authentication). ## Filters and pagination ``` GET /jobs/localization?engineId=eng_abc123&status=completed&limit=20&cursor=... ``` | Parameter | Type | Description | | --- | --- | --- | | `engineId` | string (optional) | Return only jobs that ran on this localization engine (`eng_...`). | | `status` | string (optional) | Return only jobs in this state: `queued`, `processing`, `completed`, `completed_with_warnings`, or `failed`. | | `limit` | number (optional) | Page size. Default 20, maximum 100. | | `cursor` | string (optional) | Opaque cursor from the previous response's `nextCursor`. Omit it for the first page. | Both filters are optional and combine: `engineId=eng_abc123&status=failed` returns the failed jobs for one engine and nothing else. That combination answers a question you will actually ask in an incident – _show me everything that failed on this engine_ – without pulling back every job in the organization to filter client-side. The `cursor` is a position in the result stream, not a page number. You don't compute it; you receive it. Each response hands you a `nextCursor`, and you pass that value back to fetch the page after it. ## Response Each page is an `items` array plus a `nextCursor`. **`nextCursor` is `null` on the last page** – that is your loop's exit condition, not an error. ```json { "items": [ { "id": "ljb_C3d4E5f6G7h8I9j0", "groupId": "ljg_A1b2C3d4E5f6G7h8", "targetLocale": "ja", "status": "completed", "warnings": [], "createdAt": "2026-03-16T10:30:00.000Z", "completedAt": "2026-03-16T10:30:06.000Z" } ], "nextCursor": "eyJ0IjoiMjAyNi0wMy0xNlQxMDozMDowMC4wMDBaIiwiaSI6ImxqYl9CMmMzRDRlNUY2ZzdIOGk5In0" } ``` Each item is a summary – enough to locate a job and read its outcome: which locale, which group, what status, when it was created and finished. It deliberately does not carry the translated output. To pull the full `outputData` and per-stage `steps` for one of these jobs, take its `id` and call [Get a single job](/docs/api/localization/jobs). List to find; fetch to read. {% callout type="info" title="Handle unknown status values gracefully" %} Match on the status values you know and fall through to a default branch for the rest, rather than crashing the consumer on a value it hasn't seen. Tolerating an unrecognized value is the defensive default for any string enum you don't own – it keeps your reader running instead of throwing on input it can't classify. {% /callout %} ## Page through every result The exit condition is the whole point: keep requesting until `nextCursor` comes back `null`. Pass the `nextCursor` from one response as the `cursor` of the next, and the loop terminates on its own. {% tabs %} {% tab label="Node.js" %} ```javascript async function listFailedJobs(engineId) { const failed = []; let cursor = undefined; // first page: no cursor do { const url = new URL("https://api.lingo.dev/jobs/localization"); url.searchParams.set("engineId", engineId); url.searchParams.set("status", "failed"); url.searchParams.set("limit", "100"); // fewer round-trips if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { "X-API-Key": process.env.LINGO_API_KEY }, }); const { items, nextCursor } = await response.json(); failed.push(...items); cursor = nextCursor; // null on the last page -> loop ends } while (cursor); return failed; // every failed job for this engine } ``` {% /tab %} {% tab label="Python" %} ```python import requests def list_failed_jobs(engine_id: str) -> list: failed = [] cursor = None # first page: no cursor while True: params = {"engineId": engine_id, "status": "failed", "limit": 100} if cursor: params["cursor"] = cursor response = requests.get( "https://api.lingo.dev/jobs/localization", headers={"X-API-Key": "your_api_key"}, params=params, ) body = response.json() failed.extend(body["items"]) cursor = body["nextCursor"] # None on the last page if cursor is None: # loop ends on its own break return failed # every failed job for this engine ``` {% /tab %} {% /tabs %} Raising `limit` to 100 cuts the number of round-trips for a large backlog; it does not change the result, only how many pages you walk to read it. There is no offset to drift and no page count to keep in sync – the cursor carries your place, and `null` tells you when you've read everything. ## Next steps You have the `id` of a job. The catch-up channel got you here; from here you read the result, or wire up the live channels so next time you hear it as it happens. {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Take an id from the list and pull its full outputData and per-stage steps." /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Know the group? Fetch it directly with rolled-up per-locale counts." /%} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Get each result POSTed to you the moment a locale completes." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream status as it happens, no polling - the as-it-happens channel." /%} {% /card-grid %} - [Human review](https://lingo.dev/en/docs/api/pipeline/human-review): Pause an async localization job on a human reviewer – your own team or an external professional – and resume with their edit. Event-driven, per-locale, bounded by a timeout so a silent reviewer never strands the job. Most of your content can ship the moment the engine returns it. Some can't. A regulated disclosure, a medical instruction, a headline that carries the brand – for those, you want a person to read the translation and sign off before it goes live, not after a customer files a complaint. The usual way to get a human into an automated flow is the painful way: hold the request open while someone reads, or build your own review queue, or hand-wire a translation vendor's API. This stage does it inside the job. When `humanEdit` is enabled, the async job runs the engine, then **pauses on a person** – your own team or an external professional – and resumes with their edit, carrying that output forward to any stages that follow. It is the third stage of the [localization pipeline](/docs/api/pipeline) and, like every stage, applies only to jobs created through the [Async Localization API](/docs/api/localization). New to the pipeline? Start with the [overview](/docs/api/pipeline). **On this page** - [How the pause works](#how-the-pause-works) - [Internal Review](#internal-review) - [Permissions](#permissions) - [External Review](#external-review) - [Timeout](#timeout) - [Enabling the stage](#enabling-the-stage) ## How the pause works After the core translate step, the job hands the AI translation to a human for review. The reviewer reads it, then either approves it as-is or submits an edited version. Only then does the job continue. The human output – approved or edited – becomes the input to every later stage and, ultimately, the job's `outputData`. The obvious objection to pausing a job for minutes, hours, or a day is cost: a request held open is a resource burning while nothing happens. This stage does not work that way. {% callout type="info" title="The wait is event-driven, not a held-open connection" %} The workflow resumes on an event – a reviewer submitting in the dashboard (Internal Review) or a callback from the translation provider (External Review). It is not polling on a tight loop and it is not holding a connection open, so a long timeout consumes no compute in the background. A job can wait 48 hours for a human the same way it waits on a model: it is parked, not spinning. {% /callout %} Two review modes are available, picked per engine. They differ only in who does the reading – the pause, resume, and carry-forward behavior is identical. ## Internal Review Your own team reviews translations directly in the Lingo.dev dashboard. Pending reviews land on the **Human Reviewer** page of your organization (`/orgs//human-reviewer`), and reviewers are notified when new items arrive. A reviewer claims an item, then approves the translation as-is or submits an edited version. The job resumes immediately with their output. A second reviewer can't pick up an item someone is already working. **Claims are exclusive** – one reviewer holds an item at a time, so two people never edit the same translation and quietly overwrite each other. The string is locked to the claimant until they release it or submit. Internal Review is the default mode for new engines. Reach for it when your team has the language expertise in-house and you want full control over the final wording, with no third party in the loop. ## Permissions Internal review is not open to everyone in the organization – the **Human Reviewer** page is permission-gated, so a translation in review is visible only to the people you grant access. An organization admin assigns access through roles (**Settings → Roles**): | Permission | What it unlocks | | --- | --- | | **Review translations** (`engine:review_translations`) | See and work the review queue – claim a pending translation, edit it, approve it as-is, or submit the edited version | | **Manage reviews** (`org:manage_reviews`) | Review history and reviewer statistics for all internal reviews across the organization | The two permissions are deliberately separate. A reviewer needs only **Review translations** – that single grant is the whole job: claim, edit, approve, submit. **Manage reviews** is for whoever oversees the operation; it adds the history and stats view across the org but does **not** include queue access on its own. Grant both to a lead who reviews and reports; grant only the first to someone who only works the queue. ## External Review When you don't have in-house reviewers for a locale, the translation is submitted to a qualified professional translator through an external provider instead. The job pauses the same way; it resumes when the provider returns the edited translation. Nothing about your code changes – the difference is who reads the string, not how the job behaves. External Review has two tiers, and the split is about how much accuracy the content demands: | Tier | Best for | | --- | --- | | **Standard** | Accurate translation with a human voice – marketing copy, UI strings, help content | | **Pro** | Professional use with an even higher accuracy bar – legal, medical, and regulated content | This is the candor worth being explicit about: External Review is a real human translator on the other end, with the turnaround and cost that implies. It is not a faster model. Use it where a person's judgment is the point – regulated text, high-stakes copy – and lean on the AI-only path for the bulk of your content that doesn't need it. ## Timeout A human stage introduces a risk the AI stages don't have: the human might never respond. A reviewer is on vacation, the provider is backed up, the item is forgotten. Left unbounded, the job would wait forever. So the wait is bounded. You set how long the workflow waits for the human output – the same `timeoutHours` setting applies to both review modes. If the timeout expires with no response, the stage is marked `skipped` and **the job continues with the AI translation as the final output**. The default is **48 hours**. State the cost plainly: on timeout you ship the unreviewed AI translation. That is the correct fallback for most content – a translation that shipped beats a job stuck open indefinitely – but it is a real tradeoff. For content where a human must sign off no matter what, set a generous timeout and alert on the skip; for content where review is a nice-to-have, a short timeout keeps the pipeline moving. {% callout type="info" title="Timeout is per stage, not per job" %} The timeout governs only how long this stage waits for a human. It is independent of how long the rest of the job takes. Because the wait is event-driven, a long timeout costs you latency-to-final-output if the reviewer is slow – never background compute. {% /callout %} ## Enabling the stage `humanEdit` is configured like every pipeline stage: an engine-level default in the engine's **Pipeline** tab, optionally overridden per request. The full two-layer model lives on [Configure the pipeline](/docs/api/pipeline/configure); the shape specific to this stage is: ```json { "humanEdit": { "enabled": true, "provider": "internal", "tier": "standard", "timeoutHours": 48 } } ``` `provider` selects the mode – `internal` for your own team, the external provider otherwise. `tier` (`standard` or `pro`) applies to External Review and is ignored for Internal. `timeoutHours` is the bound from the section above. To override for a single submission, pass this block inside `pipelineConfig` on the [create call](/docs/api/localization/create); omit it and the job inherits the engine's setting. When the stage runs, it records itself on the job under `stepId: "humanEdit"` with a status of `completed`, `failed`, or `skipped` – the same step record every stage produces. Reading those records is covered in [Observe pipeline runs](/docs/api/pipeline/observability). {% callout type="warning" title="A human edit can drift from your engine rules" %} A human translator may phrase something in a way that conflicts with your [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), or [instructions](/docs/platform/instructions) – they're translating well, not memorizing your config. To reconcile the human's edit back to your engine rules automatically, enable the next stage, [AI review](/docs/api/pipeline/ai-review). It runs only after a human stage that actually produced output. {% /callout %} ## Next steps {% card-grid %} {% link-card title="AI review (post-edit)" href="/docs/api/pipeline/ai-review" icon="gear" description="Reconcile the human edit back to your engine's glossary, brand voice, and instructions" /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Engine-level defaults and per-request pipelineConfig overrides for every stage" /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the humanEdit step record – completed, failed, or skipped on timeout" /%} {% link-card title="Engines" href="/docs/platform/engines" icon="gear" description="Where you pick the review mode, tier, and timeout for a pipeline" /%} {% /card-grid %} - [Get a single job](https://lingo.dev/en/docs/api/localization/jobs): GET /jobs/localization/:jobId returns one locale's translated outputData plus the per-stage record of how it was produced, with explicit job-status values for your code to branch on. Read one locale's translated output and the per-stage record of how it was produced. You reach for this once you hold a `jobId` – returned in the [202 from create](/docs/api/localization/create), carried in a [webhook](/docs/api/localization/webhooks), or listed under a [job group](/docs/api/localization/job-groups). The group endpoint tells you _how many_ locales are done. This endpoint tells you _what_ a single locale produced, and what happened along the way. ``` GET /jobs/localization/:jobId ``` {% inline-callout %} New to async localization? Start with the [Overview](/docs/api/localization). {% /inline-callout %} That distinction is the whole job of this page. A group response is a scoreboard – counts and per-job status, [covered on the job-group page](/docs/api/localization/job-groups). A single job is **the full record of one locale**: the translated `outputData`, the terminal `status`, any `warnings`, and a `steps[]` trail of every stage the [pipeline](/docs/api/pipeline) ran. When you're ready to write the German copy into your database, this is the call that hands you the German copy. ## Authentication Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the org. See [Authentication](/docs/api/authentication) for details. ## Response The `outputData` field mirrors the structure of the input `data`, with every string value translated and every non-string value (numbers, booleans, `null`) preserved in place. Same keys, same nesting, same array order – only the strings change. ```json { "id": "ljb_A1b2C3d4E5f6G7h8", "groupId": "ljg_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "outputData": { "id": "course_101", "title": "Einführung in maschinelles Lernen", "steps": [ { "heading": "Was ist ML?", "body": "Maschinelles Lernen ist ein Teilbereich der künstlichen Intelligenz." }, { "heading": "Überwachtes Lernen", "body": "Trainieren eines Modells mit gelabelten Daten." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } }, "errorMessage": null, "warnings": [], "callbackStatus": "delivered", "createdAt": "2026-03-16T10:30:00.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:04.000Z", "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:01.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:04.000Z" } ] } ``` The `metadata` block above survived untouched – `Dr. Smith` and `beginner` are non-string leaves the engine left alone. The `outputData` you read back fits the shape you sent, so the same code that built the payload can consume the translation. | Field | Description | | --- | --- | | `id` | This job's own ID (`ljb_…`). The value you passed in the path. | | `groupId` | The parent job group (`ljg_…`) this job belongs to. Pass it to [the job-group endpoint](/docs/api/localization/job-groups) to see every sibling locale at once. | | `targetLocale` | The BCP-47 locale this job translated into – one job exists per target locale. This is the field you branch on to route `outputData` to the right column or file. | | `status` | `queued`, `processing`, `completed`, `completed_with_warnings`, or `failed`. | | `outputData` | Translated content matching the input structure. Present when `status` is `completed` or `completed_with_warnings`. | | `errorMessage` | Error description. Present when `status` is `failed`, otherwise `null`. | | `warnings` | Non-critical [pipeline](/docs/api/pipeline) stage failures. Each entry is `{ step, message }`. Empty unless `status` is `completed_with_warnings`. | | `callbackStatus` | Webhook delivery state: `pending`, `delivered`, or `failed`. `null` if no callback URL is configured. | | `createdAt` | When the job was accepted (the timestamp on the `202` that created it). | | `startedAt` | When the engine began translating this locale. Set once the job leaves `queued`. | | `completedAt` | When the job reached a terminal state. Set once `status` is `completed`, `completed_with_warnings`, or `failed`. | | `steps` | Per-stage execution records. Always contains the `localize` step, plus one entry per enabled optional pipeline stage. Full record shape in [Observe pipeline runs](/docs/api/pipeline/observability). | {% callout type="info" title="outputData is null until the job finishes" %} While `status` is `queued` or `processing`, `outputData` is empty and `errorMessage` is `null` – there is nothing to read yet. Read `outputData` only after `status` reaches `completed` or `completed_with_warnings`; on `failed`, read `errorMessage` instead. Branch on `status` first, then touch the payload. {% /callout %} ## Job status values A job moves from `queued` to `processing` to exactly one terminal state. Branch on `status` before you read anything else – it tells you which fields are populated. | Status | Meaning | What to read | | --- | --- | --- | | `queued` | Accepted, not yet started. | Nothing yet – poll, or wait for the webhook. | | `processing` | The engine is translating this locale. | Nothing yet. | | `completed` | Translation finished, every enabled stage succeeded. | `outputData`. | | `completed_with_warnings` | Translation finished and `outputData` is complete, but a non-critical pipeline stage fell through. | `outputData`, then `warnings`. | | `failed` | The job did not produce a translation. | `errorMessage`. | {% callout type="info" title="completed_with_warnings still ships a translation" %} `completed_with_warnings` is not a soft failure. You get full `outputData` – the core translate step succeeded. What changed is that a non-critical stage (for example [pre-edit](/docs/api/pipeline/pre-edit) or [back-translation](/docs/api/pipeline/back-translation)) did not complete, and each failure is logged in `warnings` as `{ step, message }`. Treat the output as usable; treat `warnings` as a quality signal worth surfacing to whoever reviews translations. Only `failed` means there is no translation to read. {% /callout %} {% callout type="info" title="Handle unknown status values" %} The five status values above are the contract today. Pipeline stages evolve, so treat `status` as an open set: branch on the values you know and route anything unexpected to a default that reads `outputData` if present and logs otherwise. A `switch` with no fallback is the line that breaks on the day a new state ships. {% /callout %} ## The steps array `steps[]` is the per-stage trail behind a single job – one record for every stage the engine ran, in order. Every job carries at least the `localize` step, because core translation always runs. Each optional [pipeline](/docs/api/pipeline) stage you enabled adds one more record. So a job with no extra stages shows a single `localize` step; a job with pre-edit and back-translation enabled shows three. This is what makes a job auditable rather than a black box. You don't have to trust that a stage ran – you read its record: which stage (`stepId`), whether it `completed`, `failed`, or was `skipped`, what it cost (`costUsd`), and when it started and finished. For human-review stages, `externalRef*` points at the external record. ```json "steps": [ { "stepId": "preEdit", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0012, "createdAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:02.000Z" }, { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "createdAt": "2026-03-16T10:30:02.000Z", "completedAt": "2026-03-16T10:30:05.000Z" } ] ``` A `failed` entry here does not necessarily fail the job. When a non-critical stage fails, its `steps[]` record reads `failed`, the same failure surfaces in the job's top-level `warnings`, and the job still reaches `completed_with_warnings` with full `outputData`. The full record shape – every field, every `stepId`, the completed/failed/skipped semantics – lives on one canonical page: [Observe pipeline runs](/docs/api/pipeline/observability). This page shows you where to find it on a job; that page specifies it. ## Reading a completed job A typical consumer branches on `status`, writes `outputData` on success, and logs `errorMessage` on failure. The copy-paste call below returns the payload shown above. {% tabs %} {% tab label="Node.js" %} ```javascript const jobId = "ljb_A1b2C3d4E5f6G7h8"; const response = await fetch(`https://api.lingo.dev/jobs/localization/${jobId}`, { headers: { "X-API-Key": process.env.LINGO_API_KEY }, }); const job = await response.json(); switch (job.status) { case "completed": case "completed_with_warnings": // outputData is populated; warnings may carry non-critical stage failures await db.content.update({ where: { id: job.outputData.id }, data: { [`content_${job.targetLocale}`]: job.outputData }, }); if (job.warnings.length) console.warn(job.targetLocale, job.warnings); break; case "failed": console.error(`${job.targetLocale} failed: ${job.errorMessage}`); break; default: // queued or processing - nothing to read yet; also catches future states break; } ``` {% /tab %} {% tab label="Python" %} ```python import os, requests job_id = "ljb_A1b2C3d4E5f6G7h8" response = requests.get( f"https://api.lingo.dev/jobs/localization/{job_id}", headers={"X-API-Key": os.environ["LINGO_API_KEY"]}, ) job = response.json() if job["status"] in ("completed", "completed_with_warnings"): # outputData is populated; warnings may carry non-critical stage failures save_translation(job["targetLocale"], job["outputData"]) if job["warnings"]: print(job["targetLocale"], job["warnings"]) elif job["status"] == "failed": print(f"{job['targetLocale']} failed: {job['errorMessage']}") else: # queued or processing - nothing to read yet; also catches future states pass ``` {% /tab %} {% /tabs %} {% callout type="info" title="Polling vs push" %} This endpoint is a point-in-time read. For most jobs the engine takes 2–8 seconds per locale, so if you poll, a 2-second interval is a reasonable start. To avoid polling entirely, register a [webhook](/docs/api/localization/webhooks) and fetch the job only when it tells you the locale is done, or watch the whole group over the [WebSocket](/docs/api/localization/realtime). Either way, a final `GET` here is the canonical read of `outputData`. {% /callout %} When this endpoint returns an error – an unknown `jobId`, a missing key – it follows the standard JSON error model. See [Errors and status codes](/docs/api/errors). ## Next steps {% card-grid %} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Read aggregate counts and handle partial failures across every locale" /%} {% link-card title="List jobs" href="/docs/api/localization/list" icon="lightning" description="Page through jobs with cursor pagination and filter by status or engine" /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Read per-locale status as each job finishes, without polling" /%} {% /card-grid %} - [Webhook delivery](https://lingo.dev/en/docs/api/provisioning/webhooks): When a provisioning job ends, Lingo POSTs the result to your callbackUrl – provisioning.completed carries the summary of every brand voice, glossary item, and instruction the AI created, provisioning.failed carries the error. Return 200 first, process after. You [created a provisioning job](/docs/api/provisioning/create) and got a `202` back: an engine ID, and `status: "in_progress"`. The AI agent is now crawling your sources and applying brand voices, glossary items, and instructions to that engine in the background. The work could take a moment or a while, depending on how many links it has to crawl. You could hold a [live WebSocket](/docs/api/provisioning/realtime) open and watch it work – but you'd rather not keep a connection open just to learn the agent is done and find out what it built. That is what the webhook does. When you pass a `callbackUrl` while creating the job, Lingo POSTs the terminal result to that URL the moment the job ends – **told when the engine is ready, with the inventory of what got built.** A job that finishes arrives as `provisioning.completed` with the summary of every record the AI created. A job that fails arrives as `provisioning.failed` with the reason. Either way, your setup flow is told, without asking. This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with [localization](/docs/api/localization/webhooks) and lives on the [webhook signature verification](/docs/api/webhooks) page, linked at each point you'll need it. **On this page** - [How delivery works](#how-delivery-works) - [The completed payload](#the-completed-payload) - [The failed payload](#the-failed-payload) - [Handling a webhook](#handling-a-webhook) - [When delivery is the wrong tool](#when-delivery-is-the-wrong-tool) ## How delivery works A provisioning job ends exactly once. The instant it reaches a terminal state – every source crawled and analyzed, or the run abandoned – its result is delivered to your `callbackUrl` as a single `POST`. A localization group fans out into one job per target locale, each delivering its own callback; a provisioning job is one job, so it is one delivery. Set the destination with `callbackUrl` when you [create the job](/docs/api/provisioning/create). Two payload shapes cross the wire, distinguished by their `type` field: `provisioning.completed` and `provisioning.failed`. Both name the `jobId` and `engineId` they belong to, so a single handler can route on `type` and update the right record. {% callout type="warning" title="HTTPS only" %} `callbackUrl` must use HTTPS. An HTTP URL is rejected when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point. {% /callout %} {% callout type="info" title="Handle unknown event types gracefully" %} Today the wire carries `provisioning.completed` and `provisioning.failed`. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler. {% /callout %} ## The completed payload When the job finishes, the payload carries the `summary` – the same inventory you would get by reading the job, pushed to you instead of polled. It names every brand voice, glossary item, and instruction the AI created on your engine, and lists any per-item failures it hit along the way. ```json { "type": "provisioning.completed", "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "summary": { "brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] }, "glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "..."] }, "instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "..."] }, "errors": [] } } ``` | Field | Description | | --- | --- | | `type` | `provisioning.completed` | | `jobId` | The provisioning job that finished (`pjb_` prefix) | | `engineId` | The engine it configured (`eng_` prefix) | | `summary` | What the AI created on the engine – counts and IDs per component, plus per-item failures in `errors` | The `summary` is the same object the job carries, and its field-by-field meaning – what each component is, how items map to locales, what lands in `errors` – is documented once on [What the AI extracts](/docs/api/provisioning/extraction). Here it is enough to know the completed payload hands you the IDs of everything the agent built, so your handler can record them or surface them in your dashboard without re-fetching the job. {% callout type="info" title="A non-empty errors array still arrives as completed." %} Per-item failures do not fail the job. If a single source would not crawl or one record could not be created, it lands in `summary.errors` and the rest are still applied to the engine – and the payload is still `provisioning.completed`, not `provisioning.failed`. The completed event means the job ran to the end; read `errors` to see what to fix. A `provisioning.failed` payload is sent when the run produced no usable engine at all. {% /callout %} ## The failed payload A provisioning job fails when the run produces nothing to work with – for example, every source fails to crawl, so the agent has no content to analyze. When that happens, you are still told. The payload type is `provisioning.failed`, and it carries an `error` string in place of the summary: ```json { "type": "provisioning.failed", "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "error": "All sources failed to crawl. No content available for analysis." } ``` | Field | Description | | --- | --- | | `type` | `provisioning.failed` | | `jobId` | The provisioning job that failed | | `engineId` | The engine that was created but left unconfigured | | `error` | Human-readable reason the job could not complete | Here is the part a skeptical reader is right to ask about: *if the job failed, did I lose the engine too?* You did not. The `engineId` in this payload is the same engine you received in the `202` – it still exists, created the moment you made the call, just without the configuration the failed run would have added. A failure costs you the extraction, never the engine. Adjust what you submitted and try again, or configure the engine by hand from the dashboard. When a job fails on crawling, the sources are usually the reason – [Source types](/docs/api/provisioning/sources) covers what makes a source worth pointing at. ## Handling a webhook A skeptical reader's first thought here is the right one: *my handler does real work – a database write, a notification, a dashboard refresh – so won't that hold the connection open long enough to time the webhook out?* It would, so don't make Lingo wait for it. **Return 200 first, then process.** Acknowledge receipt, then do the real work after the response is sent. The full delivery contract – why you acknowledge first, and the retry schedule that follows if you don't – is on the [signature and delivery](/docs/api/webhooks) page; the handler below shows the shape it takes for a provisioning payload. ```javascript app.post("/webhooks/provisioning", verifyWebhook, async (req, res) => { // Acknowledge first - the job ends once, so this fires once. res.status(200).send("ok"); const { type, jobId, engineId } = req.body; if (type === "provisioning.completed") { const { summary } = req.body; await db.engines.update({ where: { engineId }, data: { status: "ready", brandVoiceCount: summary.brandVoices.count, glossaryCount: summary.glossaryItems.count, instructionCount: summary.instructions.count, }, }); } if (type === "provisioning.failed") { console.error(`Provisioning failed: ${jobId} (${engineId})`, req.body.error); await db.engines.update({ where: { engineId }, data: { status: "needs_configuration" }, }); } }); ``` The `verifyWebhook` middleware is the one piece this page doesn't define. Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com/) spec – three headers, an HMAC over the raw body, a `whsec_` secret minted the first time you submit a job with a callback. Provisioning and [localization](/docs/api/localization/webhooks) callbacks use that scheme unchanged, so it lives once on [webhook signature verification](/docs/api/webhooks). Wire the middleware in before you trust a payload – an unverified body is an unauthenticated one. {% callout type="warning" title="Verify before you trust the body" %} Your endpoint is a public URL; anyone can `POST` to it. Verify the signature against the raw request body before acting on any payload – before you mark an engine ready or store the IDs it claims to have created. The how – the headers, the HMAC, the `whsec_` secret – is on the [signature verification](/docs/api/webhooks) page. {% /callout %} ## When delivery is the wrong tool The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away. If your endpoint was down when the result was delivered, the platform retries on the same schedule every Lingo webhook uses – and the result is not trapped in the callback. The records the AI created are the engine's actual configuration; the completed summary is a report of work that already happened on a real engine, not the only copy of it. So a stretch of downtime costs you a notification, never the engine. The retry schedule itself is on the [signature and delivery](/docs/api/webhooks) page. And if what you want is live progress while the engine configures – a crawling-then-configuring status in a UI, rather than a single callback to your server when it ends – that is the provisioning job WebSocket, not the webhook. It streams a snapshot on connect and progress events as the run advances, and you can connect at any point, even after the job has finished. {% card-grid %} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Stream snapshot and progress events while the engine configures, instead of one callback when it ends. Connect any time, even after it finishes." /%} {% link-card title="Webhook signature verification" href="/docs/api/webhooks" icon="shield" description="Verify the signature, read the headers, and handle the retry schedule – shared across all webhook deliveries." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="book" description="The summary's field-by-field meaning: brand voices, glossary items, instructions, and what lands in errors." /%} {% /card-grid %} - [Pre-localization AI edit](https://lingo.dev/en/docs/api/pipeline/pre-edit): An optional pipeline stage that cleans the source payload – typos, grammar, spelling – before any locale is translated, so one source error doesn't reach every target. Non-critical, so it can never fail the job. A typo in your source is a typo you only get to fix once – before it multiplies. An async job fans one source payload out to every target locale, and each locale translates the text it was handed. So a misspelling, a dropped word, or a broken sentence in the source doesn't stay one problem. It becomes one problem in German, the same problem in French, and the same problem in every other locale the job touches – each one now needing its own correction after the fact. Pre-localization AI edit (`preEdit`) closes that gap at the source. It is the first stage of the [async localization pipeline](/docs/api/pipeline): before the core translate step runs, an AI agent reviews the source payload and corrects typos, grammar mistakes, and spelling errors. The cleaned source is what gets translated – so you fix it once, before it fans out, instead of catching the same error in a dozen outputs. This is an [async pipeline](/docs/api/pipeline) stage, so it runs only on jobs created through the [Async Localization API](/docs/api/localization). The synchronous [`/localize` endpoint](/docs/api/localize) runs the core translate step alone and ignores pipeline settings. ## What the stage does `preEdit` operates on the **source**, not the translation. An AI agent reads your source payload and rewrites it to remove surface errors – typos, grammar, spelling – then hands the corrected text to the core localization step. Every target locale translates from that cleaned source. Its scope is deliberately narrow, and that is the point. This is a copy-cleaning pass, not a rewrite: it targets the kind of surface noise that makes source text ambiguous to a translation model, so the model spends its attention on translating rather than guessing what a mangled sentence meant. Cleaner source produces more consistent translations across locales – because every locale starts from the same corrected text instead of each model independently interpreting the same error. For idiomatic, native-sounding *output* – rewriting the translation itself to read like a native copywriter wrote it – that is a different stage. See [Rephrase for natural copy](/docs/api/pipeline/rephrase). `preEdit` cleans the input; `rephrase` polishes the output. ## It can't make the job worse The first question a careful engineer asks about an AI step that edits their content before translation is the right one: *what happens when that step gets it wrong, or doesn't run at all?* `preEdit` is a **non-critical** stage. If the pre-edit call fails or times out, the original source is passed through unchanged and the job continues to the translate step exactly as if the stage were off. A failure here costs you the cleanup on that job – not the job. The translation still ships. {% callout type="info" title="What if pre-edit fails or times out?" %} The job does not fail. Non-critical stages fall back to their input: on a `preEdit` failure, the unedited source is translated as-is, and the job runs to completion. The job status becomes `completed_with_warnings`, the `preEdit` step is recorded as `failed`, and the reason lands in the job's `warnings` array – so you can see it happened without it blocking delivery. Reading those step records is covered on [Observe pipeline runs](/docs/api/pipeline/observability). {% /callout %} So the honest framing of the floor: enabling `preEdit` cannot make a job fail that would otherwise have succeeded. The worst case is that it doesn't help on a given job and quietly steps aside. ## What it is not Worth stating plainly, at the point you'd most want it to be more: `preEdit` is **best-effort**, and it is a copy-cleaning pass over surface errors – not a proofreader that understands your domain or a fact-checker that validates your claims. It corrects typos, grammar, and spelling. It does not verify that a price is right, that a product name is current, or that a sentence says what you meant it to say. If your source is factually wrong, `preEdit` will faithfully clean the grammar of a wrong sentence and translate it cleanly into every locale. For terms that must stay exactly as written regardless of any AI pass – product names, trademarks, code identifiers – pin them at the source. Mark them non-translatable in your engine's [glossary](/docs/platform/glossaries), or, for structural fields in a specific payload, exclude them with [`lockedKeys`](/docs/api/localization/locked-keys). Those are guarantees about the data; `preEdit` is a best-effort cleanup around them. ## When to enable it `preEdit` earns its extra pass when your source is likely to carry noise, and it's redundant when your source is already clean. - **Enable it** when source content is user-generated, machine-extracted, scraped, OCR'd, or otherwise authored outside an editorial process – the cases where surface errors are common and the multiply-across-locales cost is real. - **Skip it** for curated content that has already passed editorial or human review. If the source is clean, there is nothing for the stage to correct, and you would be paying for an AI pass that has no work to do. Each enabled stage is one more step the job runs and one more line on its cost – worth it where source quality is uncertain, wasted where it isn't. That is the whole trade: spend one pass up front, on the jobs where source quality is uncertain, to fix it once before it fans out – instead of correcting the same error across every locale after delivery. You toggle `preEdit` on the engine's **Pipeline** tab, where it applies to every async job routed to that engine, or override it for a single submission with `pipelineConfig` on the create-jobs request. Both layers, and how an omitted stage inherits the engine default, are covered on [Configure the pipeline](/docs/api/pipeline/configure). ## Next steps {% card-grid %} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable preEdit as an engine default or override it per request with pipelineConfig" /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the preEdit step record and find warnings when a non-critical stage falls back" /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="chat" description="The output-side counterpart - rewrite the translation to read native, not clean the input" /%} {% link-card title="Localization Pipeline" href="/docs/api/pipeline" icon="lightning" description="All the stages that wrap the core translate step, and how they fit together" /%} {% /card-grid %} - [What the AI extracts](https://lingo.dev/en/docs/api/provisioning/extraction): An AI agent turns your sources into three kinds of engine configuration – brand voices, glossary items, and instructions. This page covers what it looks for, how each maps to your locales via the * wildcard, and the summary that names every record it created. You have submitted your sources and the job is running. The engine ID came back in the `202`, and its configuration is filling in. This page answers the question that decides whether you trust the result: **what, exactly, is filling in?** "An AI configured my engine" is the sentence that makes an engineer wary, and the wariness is the right instinct. It could mean a black box you cannot inspect. It could mean records scattered across locales you cannot account for. It could mean the agent read a thin source, found nothing, and quietly created almost nothing. So this page is concrete about all three: the agent produces three kinds of configuration, each maps to your locales by a rule you can predict, and the job hands back a summary that names every record it created. The output is **ordinary records you can read and edit** – not a verdict you have to take on faith. New to async provisioning? Start with the [Async Provisioning API overview](/docs/api/provisioning) for the mental model, and [Source types](/docs/api/provisioning/sources) for what makes a source worth submitting. This page is about what comes out the other side. **On this page** - [The three components](#the-three-components) - [How each maps to a locale](#how-each-maps-to-a-locale) - [The output summary](#the-output-summary) - [Reading a thin summary](#reading-a-thin-summary) - [Next steps](#next-steps) ## The three components The agent reads everything – crawled pages and raw content alike – and creates three kinds of engine configuration. They are not a new, provisioning-only format. They are the exact same primitives you would otherwise create by hand on an [engine](/docs/platform/engines), which is why everything the agent makes is editable afterward in the dashboard, the same way you would edit anything you created yourself. | Component | What it looks for | Example | | --- | --- | --- | | [**Brand voices**](/docs/platform/brand-voices) | Tone, style, formality level, writing conventions | "Use formal German (Sie-form). Keep sentences concise and direct." | | [**Glossary items**](/docs/platform/glossaries) | Product names, technical terms, brand-specific translations, non-translatable terms | "Acme" → non-translatable, "workspace" → "Arbeitsbereich" (de) | | [**Instructions**](/docs/platform/instructions) | Formatting rules, cultural conventions, domain-specific guidelines | "Always format dates as DD.MM.YYYY in German translations." | These are the three things that make a translation sound like your product rather than a generic rendering – the formality you have chosen, the names you never translate, the date format you always use. The agent's job is to find those decisions wherever they are stated in your sources and write them down as records. One consequence worth stating plainly, because it sets the ceiling on what you should expect back: the agent extracts what is **stated**, not what is implied. A source that says a rule out loud yields a record; a source that merely demonstrates good tone without naming a rule yields little. That is a property of the sources, not the engine – [Source types](/docs/api/provisioning/sources) covers how to pick sources that say their rules out loud. ## How each maps to a locale A localization engine's configuration is keyed by target locale, so a record is not just *what* a rule is – it is *where* the rule applies. The agent assigns each record a locale by a rule you can predict, and the `*` wildcard is the part worth understanding before you read the output. - **Brand voices and instructions use `*` when they apply across all languages.** A tone rule like "keep sentences concise and direct" is not specific to German; it is how your product writes in every language. The agent assigns it the `*` target locale, and it applies to every locale the engine translates into. A rule that genuinely is language-specific ("use Sie-form in German") is assigned to that locale instead. - **Glossary items are created per locale pair**, because a translation is always from one language into a specific other one – "workspace" → "Arbeitsbereich" is a fact about German, and only German. - **Non-translatable terms are the exception, and they use `*`.** A brand name you never translate – "Acme" – is non-translatable in *every* language, so it is stored once against `*` rather than re-entered for each locale pair. So when you see `*` in a record the job created, it is not a placeholder or a gap. It means "this applies everywhere" – a global tone rule, a global instruction, or a term that is never translated in any language. A specific locale code means the opposite: this rule is scoped to exactly that language. {% callout type="info" title="Why the wildcard is a feature, not a default to override" %} A skeptical reading of `*` is "the agent didn't bother to figure out which locale this belongs to." It is the reverse. A brand voice or a non-translatable term that is correct in every language *should* be global – pinning it to one locale would mean it silently fails to apply to the others. The wildcard is how the configuration says "this is true regardless of language," which is exactly what a tone rule or a brand name usually is. {% /callout %} ## The output summary When the job completes, it returns a summary that names everything the agent created. This is the receipt: every record, counted and identified, plus a list of anything that failed. ```json { "brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] }, "glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "gi_B2c3D4e5", "..."] }, "instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "ins_B2c3D4e5", "..."] }, "errors": [] } ``` Each component reports a `count` and the `ids` of the records created – `bv_` for brand voices, `gi_` for glossary items, `ins_` for instructions. Those are not opaque acknowledgements; they are the IDs of real records on the engine. You can take any `gi_` from this list, open it in the dashboard, and read or change exactly what the agent extracted. The summary is how you go from "the AI did something" to "here are the twenty specific things it did," which is the whole difference between a black box and **ordinary records you can read and edit**. The summary reaches you on the channel you set up when you created the job: in the [webhook](/docs/api/provisioning/webhooks) payload your callback URL receives on completion, where it arrives as the `summary` field. If you are watching the job over the [WebSocket](/docs/api/provisioning/realtime), that is a liveness feed – it streams crawling and configuring progress, not this summary object. The summary travels with the completion webhook; the WebSocket tells you when to go read it. {% callout type="info" title="A failed item does not fail the job." %} If a single record cannot be created, it does not sink the rest. The failure is recorded in the `errors` array, the records that succeeded are still applied to the engine, and the job still completes. You get a partially configured engine plus a precise list of what to revisit – not an empty engine and a stack trace. The job fails as a whole when the run produces nothing to work from – for example, every source fails to crawl; that failure case, and its `provisioning.failed` payload, lives on [Webhook delivery](/docs/api/provisioning/webhooks). {% /callout %} ## Reading a thin summary The summary tells you not only what was created but, by its counts, whether the run was worth it. A `count` of `0` for a component is not an error – the summary is well-formed and the engine exists – but it is information. Three brand voices and twelve glossary items is a configured engine. Zero of everything and an empty `errors` array is an engine that came back nearly blank, and the agent is telling you it found few rules to extract. When that happens, the cause is almost always upstream: the sources stated few concrete rules for the agent to lift. The summary is where you notice it; [Source types](/docs/api/provisioning/sources) is where you fix it. The honest expectation to carry into your first run is that the receipt only reflects what your sources actually said – a rich summary means rich sources, and a thin one means there was little to find. That is why the summary matters as much as the engine: it lets you verify the configuration instead of assuming it. Read the counts, open a few records by their IDs, confirm the agent caught what you expected – **ordinary records you can read and edit**, with a receipt that tells you precisely what to check. ## Next steps {% card-grid %} {% link-card title="Source types" href="/docs/api/provisioning/sources" icon="book" description="What makes a source worth submitting – and why a thin summary usually traces back to here." /%} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="Receive the summary at your callback URL on completion, and the error payload on failure." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Watch crawling and configuring steps live as the engine fills in – then read the summary from the completion webhook." /%} {% link-card title="Translate with your new engine" href="/docs/api/localization" icon="lightning" description="Once the records are in place, fan content out to every locale through the async Localization API." /%} {% /card-grid %} - [Track a job group](https://lingo.dev/en/docs/api/localization/job-groups): Poll one endpoint to read the aggregate status of every locale in a submission – how many are done, how many carry warnings, how many failed. You created a group, got a `groupId` back, and now several locales are translating in parallel. You need one question answered, repeatedly, until the work settles: how is the whole submission doing right now? Not locale by locale – the aggregate. How many are done, how many produced output with warnings, how many failed, how many are still running. That is what this endpoint returns. One poll, every locale's status, in a single response. New to async localization? Start with the [Async Localization API overview](/docs/api/localization). **On this page** - [Get a job group](#get-a-job-group) - [Response](#response) - [Group statuses](#group-statuses) - [How often to poll](#how-often-to-poll) - [When one locale fails](#when-one-locale-fails) ## Get a job group Retrieve the status of a job group and all its child jobs. ``` GET /jobs/localization/groups/:groupId ``` Authenticate with your API key in the `X-API-Key` header, the same key you used to [create the group](/docs/api/localization/create). The `groupId` is the `ljg_`-prefixed id from the 202 response. ## Response The response is a snapshot of the whole submission: the group's own `status`, four counts, and the child jobs with their individual states. This is the object you read on every poll. ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "processing", "sourceLocale": "en", "totalJobs": 3, "completedJobs": 1, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:04.000Z" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "processing", "warnings": [], "completedAt": null }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued", "warnings": [], "completedAt": null } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` The three count fields for terminal jobs – `completedJobs`, `completedWithWarningsJobs`, and `failedJobs` – sum to the number of locales that have finished. The rest of `totalJobs` are still `queued` or `processing`. In the snapshot above, 1 of 3 is done and 2 are still in flight, so a single read of the counts tells you the work isn't settled yet without scanning the `jobs` array. When that sum reaches `totalJobs`, the group has reached a terminal status. Each job's `warnings` array surfaces non-critical [pipeline](/docs/api/pipeline) stage failures – for example a pre-edit or back-translation step that fell through. A non-empty array means the job still produced output, but at least one optional stage did not complete. The translated `outputData` itself lives on the [single job](/docs/api/localization/jobs) – fetch that when you're ready to read a finished locale's content. ## Group statuses The group's `status` rolls up its child jobs into one value. You poll until it reaches a terminal state. | Group status | Meaning | | --- | --- | | `pending` | Group created, no jobs started yet | | `processing` | At least one job is in progress | | `completed` | All jobs completed successfully | | `completed_with_warnings` | All jobs produced output, but one or more optional [pipeline](/docs/api/pipeline) stages failed on at least one job | | `partial` | Some jobs completed, some failed | | `failed` | All jobs failed | The split between `completed`, `completed_with_warnings`, and `partial` is the point of this endpoint: it distinguishes "every locale shipped" from "every locale shipped, some with a warning" from "some locales shipped and some didn't" – three outcomes you'd otherwise have to reconstruct by reading each job. `partial` is not an error; it's a real state of the world that the group reports plainly so your code can branch on it. ## How often to poll {% callout type="info" title="Polling interval" %} For most jobs, processing takes 2–8 seconds per language. If you're polling instead of using webhooks or WebSocket, a 2-second interval is a reasonable starting point. {% /callout %} Polling is the simplest way to track a group, and for a short-lived batch it's perfectly fine. But it's the weaker option, and worth being honest about: every poll is a round-trip whether or not anything changed, and you learn a locale finished only on your next tick, not the moment it lands. If you want each result the instant it's ready, don't poll – be told. The platform delivers each completed locale to your [webhook URL](/docs/api/localization/webhooks) as it finishes, and a [WebSocket connection](/docs/api/localization/realtime) on the group pushes a full state snapshot on every change, so your UI updates without asking. Reach for polling when a webhook endpoint or a persistent connection is more than the job is worth; reach for push when latency to your UI matters. ## When one locale fails A skeptical reading of "translate into many locales at once" asks the obvious question first: what happens to the rest when one locale fails? Here is the answer, in the response. Each locale is an independent job. If German succeeds but Japanese fails, the German translation is finished and delivered normally – the failure does not roll it back. The failed job appears in the group with `status: "failed"`, `failedJobs` increments, and the group rolls up to `partial`: ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "partial", "sourceLocale": "en", "totalJobs": 3, "completedJobs": 2, "completedWithWarningsJobs": 0, "failedJobs": 1, "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:04.000Z" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:05.000Z" }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "failed", "warnings": [], "completedAt": null } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` Two locales shipped, one didn't, and the counts say so without you scanning anything. To retry, [submit a new request](/docs/api/localization/create) with only the failed locales and a fresh idempotency key. The full error description for a failed locale – the `errorMessage` – lives on the [single job](/docs/api/localization/jobs); the group gives you the count and the verdict. {% callout type="info" title="Partial failures are a normal state" %} `partial` means exactly what the counts show: some locales completed, some failed. The completed locales are already delivered. There's nothing to roll back and no re-spend on the locales that succeeded – you retry only what failed. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Read one locale's translated outputData, warnings, and error message" /%} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Get each locale's result pushed to you the moment it completes" /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream group snapshots into your UI as each locale lands" /%} {% /card-grid %} - [Recognize](https://lingo.dev/en/docs/api/recognize): Detect the language of arbitrary text in one POST and get back structured locale metadata – BCP-47 locale, language, region, script, a human-readable label, and text direction. Region and script come back only when the text actually shows them. You have a piece of text and no reliable record of what language it is in – a comment a user typed, a string from an uploaded file, the body of an inbound support ticket. Before you can route it, translate it, or even render it correctly, you need its locale: which language, which region, which script, and which way it reads. Recognize closes that gap in one call. You send the text; you get back a structured locale identity, as specific as the text allows. This page covers the whole endpoint – the request, the response and every field it returns, the language bindings, and what the response does when the text doesn't pin down a region or script. It is a synchronous call: you POST text, the request blocks while it analyzes the text, and the answer comes back in the same round-trip. Authentication is the shared `X-API-Key` header – see [Authentication](/docs/api/authentication) for how keys work – and any error follows the [standard error model](/docs/api/errors). ## Request ``` POST /process/recognize ``` | Parameter | Type | Description | | --- | --- | --- | | `text` | string | The text to analyze | | `labelLocale` | string (optional) | Locale for the human-readable label (default: `en`) | Only `text` is required. `labelLocale` controls the language of the human-readable `label` in the response – set it to `de` and the label for a French input comes back in German instead of English. It does not change what gets detected, only how the result is named back to you. ```json { "text": "Bonjour le monde", "labelLocale": "en" } ``` ## Response ```json { "locale": "fr", "language": "fr", "region": null, "script": null, "label": "French", "direction": "ltr" } ``` | Field | Type | Description | | --- | --- | --- | | `locale` | string | BCP-47 locale code at the most specific level of confidence | | `language` | string | ISO 639 language subtag | | `region` | string \| null | ISO 3166 region subtag, or `null` if indistinguishable | | `script` | string \| null | ISO 15924 script subtag, or `null` if default for the language | | `label` | string | Human-readable locale name in the requested `labelLocale` | | `direction` | `"ltr"` \| `"rtl"` | Text direction | Two things in that shape are worth reading closely, because they are what make the result usable rather than just informative. First, every code is a published standard, not a Lingo.dev invention. The `locale` is [BCP-47](https://www.rfc-editor.org/info/bcp47); `language` is an [ISO 639](https://www.iso.org/iso-639-language-code) subtag; `region` is [ISO 3166](https://www.iso.org/iso-3166-country-codes.html); `script` is [ISO 15924](https://unicode.org/iso15924/). So whatever you already use to parse locales – your i18n library, an `Intl` call, a CLDR lookup – consumes this output directly. You are not adapting to a proprietary code system; you are getting the same identifiers the rest of your stack already speaks. Second, `region` and `script` are nullable on purpose. They come back filled only when the text actually demonstrates them – which is the subject of the next two sections, and the property that keeps the endpoint from guessing. ## Region and script are returned only when the text shows them The obvious worry about any language detector is that it over-reaches: that it will stamp a region or a writing system onto text that never proved one, and you build logic on a guess. Recognize does the opposite. It reports a subtag only when the evidence supports it, and returns `null` when it does not. When regional markers are present in the text – Brazilian Portuguese vocabulary, say – the response includes the full tag (`pt-BR`). When the regional variant is indistinguishable, only the language subtag is returned (`pt`): ```json { "locale": "pt-BR", "language": "pt", "region": "BR", "script": null, "label": "Portuguese (Brazil)", "direction": "ltr" } ``` ```json { "locale": "pt", "language": "pt", "region": null, "script": null, "label": "Portuguese", "direction": "ltr" } ``` Same language, two honest answers. The first text carried enough to name the region; the second did not, so `region` is `null` and the `locale` collapses to `pt`. `script` follows the same rule from the other direction: it is `null` when the writing system is the default for the language – Latin for French, for instance – and named only when the script is the distinguishing fact. {% callout type="info" title="A null is information, not a gap" %} `region: null` does not mean detection failed. It means the text did not contain enough to distinguish a region, so the endpoint declined to invent one – and `locale` is the language subtag alone. Read it as "as specific as the text allows": branch on `locale` and let `null` route you to the language-level default rather than treating it as an error. {% /callout %} This is why `locale` is the field to build on. It is always the most specific tag the text supports – `pt-BR` when the evidence is there, `pt` when it is not – so reading `locale` gives you the right granularity automatically, without you having to reassemble it from the parts or second-guess a confident-looking region that was really a guess. ## `direction` is there so you can render before you translate Language detection is rarely the end goal – usually you detect in order to *do* something with the text, and the first thing you often do is show it. `direction` is in the response for exactly that: it tells you whether the text reads left-to-right or right-to-left, so you can set `dir="rtl"`, pick a layout, or choose a font before any translation step. Arabic text comes back `"rtl"`; the French example above comes back `"ltr"`. You do not have to maintain your own language-to-direction table – the endpoint that identifies the language also hands you the one rendering fact you need first. ## Examples A single POST with the text and an optional `labelLocale`. The response is the structured locale object above. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch( "https://api.lingo.dev/process/recognize", { method: "POST", headers: { "X-API-Key": "your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ text: "Bonjour le monde", labelLocale: "en", }), } ); const result = await response.json(); // { locale: "fr", language: "fr", label: "French", direction: "ltr", ... } ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/process/recognize", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "text": "Bonjour le monde", "labelLocale": "en", }, ) result = response.json() # {"locale": "fr", "language": "fr", "label": "French", "direction": "ltr", ...} ``` {% /tab %} {% tab label="PHP" %} ```php $response = file_get_contents( "https://api.lingo.dev/process/recognize", false, stream_context_create([ "http" => [ "method" => "POST", "header" => implode("\r\n", [ "X-API-Key: your_api_key", "Content-Type: application/json", ]), "content" => json_encode([ "text" => "Bonjour le monde", "labelLocale" => "en", ]), ], ]) ); $result = json_decode($response, true); // ["locale" => "fr", "language" => "fr", "label" => "French", ...] ``` {% /tab %} {% tab label="Java" %} ```java HttpClient client = HttpClient.newHttpClient(); String body = """ { "text": "Bonjour le monde", "labelLocale": "en" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.lingo.dev/process/recognize")) .header("X-API-Key", "your_api_key") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send( request, HttpResponse.BodyHandlers.ofString() ); // Parse response.body() as JSON ``` {% /tab %} {% tab label="C#" %} ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "X-API-Key", "your_api_key" ); var response = await client.PostAsJsonAsync( "https://api.lingo.dev/process/recognize", new { text = "Bonjour le monde", labelLocale = "en", } ); var result = await response.Content .ReadFromJsonAsync(); // { "locale": "fr", "language": "fr", "label": "French", ... } ``` {% /tab %} {% /tabs %} ## Next steps The common reason to detect a language is to act on it – most often, to translate it. Recognize tells you the source locale; the localization endpoints take it from there. {% card-grid %} {% link-card title="Localize" href="/docs/api/localize" icon="lightning" description="Feed the detected locale straight into a single-request translation through your configured engine." /%} {% link-card title="Async Localization API" href="/docs/api/localization" icon="lightning" description="Detected one source locale, need many targets? Fan one request out to up to 100 locales." /%} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="shield" description="Generate and manage the organization-scoped key this call authenticates with." /%} {% /card-grid %} - [Observe pipeline runs](https://lingo.dev/en/docs/api/pipeline/observability): Every enabled pipeline stage leaves one record on the job – which stage ran, whether it completed, failed, or was skipped, and what it cost. Read the steps[] array and you never have to trust the pipeline ran; you check. Every enabled pipeline stage leaves one record on the job, so you can read what ran instead of trusting that it did. You turned on a few [pipeline](/docs/api/pipeline) stages – maybe [pre-edit](/docs/api/pipeline/pre-edit) to clean the source and [back-translation](/docs/api/pipeline/back-translation) to catch drift – and a job came back `completed_with_warnings`. Which stage fell through? Did the human reviewer ever pick it up, or did it time out? What did the extra stages cost? A pipeline that runs several AI and human steps per locale is exactly the kind of thing that turns into a black box: output comes out, and you take it on faith that the stages in between did their work. They don't ask you to take it on faith. Every enabled stage writes one record to the job's `steps[]` array – which stage, what status, what cost, when it started and finished. **You read what each stage did; you don't trust that it ran.** That is the whole job of this page. {% inline-callout %} New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline). {% /inline-callout %} **On this page** - [Where the records live](#where-the-records-live) - [The steps array](#the-steps-array) - [Mapping a stepId to a stage](#mapping-a-stepid-to-a-stage) - [Step status: completed, failed, skipped](#step-status-completed-failed-skipped) - [How a step failure becomes a job warning](#how-a-step-failure-becomes-a-job-warning) ## Where the records live The `steps[]` array is a field on the localization job. You don't fetch it separately – it arrives whenever you read the job: ``` GET /jobs/localization/:jobId ``` Authenticate with your API key in the `X-API-Key` header. The full endpoint, the job-status values, and the `outputData` payload are [covered on the single-job page](/docs/api/localization/jobs); this page is about one field on that response – the per-stage trail – and what it tells you. So the rule is simple: every job you read already carries its own audit log. A job with no pipeline enabled shows a single record, because [core localization](/docs/api/pipeline) always runs. Turn on two optional stages and you get three records. The array grows with the pipeline, one entry per stage, in the order the stages ran. ## The steps array Each entry in `steps[]` is the record of one stage. These are the fields you read to audit a run – which stage, with what outcome, at what cost, and when: ```json "steps": [ { "stepId": "preEdit", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0012, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:01.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:02.000Z" }, { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:02.000Z", "startedAt": "2026-03-16T10:30:02.000Z", "completedAt": "2026-03-16T10:30:05.000Z" } ] ``` | Field | Description | | --- | --- | | `stepId` | Which pipeline stage this record is for. See [the mapping table below](#mapping-a-stepid-to-a-stage). | | `type` | The kind of step. `action` for an automated stage. | | `status` | `completed`, `failed`, or `skipped` for this stage – independent of the job's status. | | `errorMessage` | Why this stage failed. `null` unless `status` is `failed`. | | `costUsd` | What this stage cost, in USD – a JSON number, or `null`. | | `externalRefType`, `externalRefId`, `externalRefUrl` | A pointer to an external record for stages that hand work to a third party – the [human review](/docs/api/pipeline/human-review) stage. `null` for fully automated stages. | | `createdAt`, `startedAt`, `completedAt` | When the stage was created, picked up, and finished. | Each record also carries an `outputData` field – the content that stage produced, in the same shape as the job's `outputData`. That payload is the translation, not the audit trail, so it's [documented on the single-job page](/docs/api/localization/jobs) alongside the job-level `outputData`; the fields above are the ones you read to see what the pipeline did. Two things these records give you that a single `outputData` blob can't. First, **cost is itemized per stage**, not only totaled per job – so when you enable back-translation and the bill moves, you can read exactly which stage moved it. Second, **timing is per stage** – a `humanEdit` record whose `startedAt` and `completedAt` are hours apart tells you the wait was the human, not the engine. {% callout type="info" title="Read steps by stepId, not by position" %} The records appear in execution order, but don't index into the array by position – which stages ran depends on which you enabled, so position is not stable across jobs. Find a stage by its `stepId` (`steps.find(s => s.stepId === "humanEdit")`). The set of `stepId` values is fixed; the set present on any given job is whatever you turned on. {% /callout %} ## Mapping a stepId to a stage Each `stepId` names one pipeline stage. This is the lookup table from the value in the record to the stage it represents and the page that documents what that stage does: | `stepId` | Stage | | --- | --- | | `preEdit` | [Pre-localization AI edit](/docs/api/pipeline/pre-edit) | | `localize` | [Core localization](/docs/api/pipeline) | | `humanEdit` | [Post-localization human review](/docs/api/pipeline/human-review) | | `postEdit` | [Post-localization AI review](/docs/api/pipeline/ai-review) | | `rephrase` | [Rephrase for natural copy](/docs/api/pipeline/rephrase) | | `backTranslation` | [Back-translation check](/docs/api/pipeline/back-translation) | `localize` is the one `stepId` that appears on every job, pipeline or not – it is the [core translate step](/docs/api/pipeline), and it always runs. The other five appear only when you've [enabled that stage on the engine or in the request](/docs/api/pipeline/configure). ## Step status: completed, failed, skipped Each step carries its own `status`, set independently of the job and of every other step. Three values: | Step `status` | Meaning | | --- | --- | | `completed` | The stage ran and produced its output. | | `failed` | The stage ran and errored. `errorMessage` says why. | | `skipped` | The stage did not run to completion this time, even though it was enabled. | `completed` and `failed` read the way you'd expect. `skipped` is the one worth pausing on, because it is not the same as "disabled". A stage you never turned on produces no record at all. A `skipped` record means the stage *was* enabled but was passed over for a reason the pipeline defines – the clearest case is [human review](/docs/api/pipeline/human-review): if the review window closes with no human response, that stage is marked `skipped` and the AI translation carries forward as final. The record is still there, so the skip is visible rather than silent. {% callout type="info" title="A step status is not the job status" %} A `failed` step does not always mean a `failed` job. Most optional stages are non-critical: when one fails, its record reads `failed`, the engine carries the last good output forward, and the job still finishes with full `outputData`. The job status that results – `completed_with_warnings` – is [explained on the single-job page](/docs/api/localization/jobs). The step status tells you what happened to one stage; the job status tells you whether you got a translation. {% /callout %} ## How a step failure becomes a job warning When a non-critical stage fails, the failure shows up in two places at once, and they are two views of the same event. The `steps[]` record reads `failed` with an `errorMessage` – that's the detailed view. The same failure also surfaces as one entry in the job's top-level `warnings` array – that's the summary view your status-handling code branches on: ```json { "id": "ljb_A1b2C3d4E5f6G7h8", "status": "completed_with_warnings", "outputData": { "title": "Hallo" }, "warnings": [ { "step": "backTranslation", "message": "Back-translation check did not complete" } ], "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "completedAt": "2026-03-16T10:30:05.000Z" }, { "stepId": "backTranslation", "type": "action", "status": "failed", "errorMessage": "Back-translation check did not complete", "costUsd": 0.0031, "completedAt": "2026-03-16T10:30:11.000Z" } ] } ``` Each `warnings` entry is `{ step, message }`, where `step` is the same `stepId` you'd find in the failed record. So the two arrays line up: `warnings` is the short list of what went wrong, and `steps[]` is where you go for the detail behind each one. Read `warnings` to decide whether to flag the locale for a human; read the matching `steps[]` record when you want the `errorMessage`, the cost, and the timing behind it. This is the mechanism behind `completed_with_warnings`: the core translation succeeded, so you have usable `outputData`, but at least one non-critical stage left a `failed` record and a matching warning. Treat the output as shippable and the warnings as a quality signal worth surfacing. Only a job `status` of `failed` means there is no translation to read – and that decision, with the full job-status table, lives [on the single-job page](/docs/api/localization/jobs). {% callout type="info" title="Aggregate stage health is a separate surface" %} `steps[]` answers "what did the pipeline do on **this** job." When you want the trend across many jobs – how often pre-edit fails, how often back-translation corrects a translation – that's an aggregate question, and it's answered on the [Reports](/docs/platform/reports) page, not in a per-job response. Per-job records here; rollups there. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="The full job response these steps live on, plus the job-status values to branch on" /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Decide which stages run, per engine or per request - and so which steps you'll see" /%} {% link-card title="Reports" href="/docs/platform/reports" icon="book" description="Aggregate stage success rates and throughput across every job" /%} {% /card-grid %} - [Source types](https://lingo.dev/en/docs/api/provisioning/sources): Choose what to feed the provisioner. A link source points the crawler at a URL; a content source hands raw text straight to the AI agent. The configuration you get back is only as good as the sources you put in. Provisioning reads your existing material and turns it into engine configuration. The one decision that shapes the result is what you put in the request's `sources` array – because the engine you get back is only as good as what you point it at. Each entry is one of two kinds. A `link` source is a URL the platform fetches and crawls for you; a `content` source is raw text or markdown you pass in directly. You can mix both in the same request, up to ten sources total. This page covers the two kinds, what the platform does with each, and how to pick sources that produce a useful configuration rather than an empty one. The `sources` field itself lives on the create request – see [Create a provisioning job](/docs/api/provisioning/create) for the full payload and the 202 response. ## The two source kinds Every source is an object with a `type` and a `payload`. The `type` decides how the platform reads the `payload`. | Type | Payload | Reach for it when | | --- | --- | --- | | `link` | A URL to crawl | The context already lives on the web – your brand page, public docs, a published style guide, a glossary page. | | `content` | Raw text or markdown | The context lives in your head or a private doc – terminology lists, tone rules, product-name conventions, translation do's and don'ts. | ```json { "sources": [ { "type": "link", "payload": "https://acme.com/brand-guidelines" }, { "type": "link", "payload": "https://acme.com/docs/style-guide" }, { "type": "content", "payload": "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English." } ] } ``` Two links and one content block in the same array. The links point at pages that already hold the context; the content block carries rules that live nowhere public. Both feed the same extraction step. ## What the platform does with each The two kinds differ in one step – getting the text in front of the AI agent – and converge after that. A `link` source is fetched and converted to markdown before analysis. The platform crawls link sources **in parallel**, so ten URLs are not ten sequential round-trips – they are read concurrently, then handed to the agent as text. You give a URL; the platform does the fetching and the HTML-to-markdown reduction so the agent reads prose, not page markup. A `content` source skips that step. The text you send is passed to the AI agent directly, exactly as written. There is no crawl, no conversion, nothing between your words and the agent – which is why a content source is the most precise way to state a rule you already know. From there both kinds are the same input: the agent reads all of it and extracts brand voices, glossary items, and instructions. What it produces from that text, and the summary it returns, is its own subject – see [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="How deep does a link crawl go?" %} A `link` source is fetched and converted to markdown before the agent analyzes it. Whether the crawler follows links beyond the URL you supply – and to what depth – is not specified here. If you need a specific set of pages analyzed, the reliable approach is to list each one as its own `link` source rather than relying on a single URL to fan out. {% /callout %} ## Pick sources that carry signal This is the move that decides whether provisioning is worth running. The extraction is only as good as its input, and the failure here is quiet: a job against weak sources still completes, still creates an engine – but a nearly empty one, and you find out later when translations ignore conventions you assumed were captured. The completion arrives like any other – see [Webhook delivery](/docs/api/provisioning/webhooks) – so nothing flags the gap for you. {% callout type="info" title="Provide meaningful sources" %} The quality of the extracted configuration depends on the quality of your input. Link sources should point to pages with useful context – brand guidelines, style guides, product documentation, glossaries. Raw content sources should contain concrete terminology, tone guidance, or translation rules. Generic marketing pages or login screens produce little useful configuration. {% /callout %} The pattern behind the callout: the agent extracts what is **stated**, not what is implied. A page that says "we write in a friendly, direct German that uses Sie, never Du" yields a brand voice. A glossary page that lists "workspace → Arbeitsbereich" yields a glossary item. A polished landing page that *demonstrates* good tone without naming a single rule yields almost nothing, because there is no rule on it to lift. When in doubt, prefer the source that says the rule out loud – which is often a `content` block you write in a sentence rather than a page you hope the agent infers from. ## One weak source won't sink the job A natural worry follows from feeding several sources at once: if one URL is dead or one block is thin, does the whole request fail? It does not. Sources are read independently, and a per-item failure is recorded rather than fatal – a dead link or an unreadable block is skipped, and the agent works from what it could read. The job fails as a whole only when no source could be read at all, leaving nothing to analyze. The exact shapes of those outcomes – the per-item failures recorded on success, and the failure payload when nothing could be read – belong to [What the AI extracts](/docs/api/provisioning/extraction) and [Webhook delivery](/docs/api/provisioning/webhooks). So you can list a candidate set without auditing every URL first: the strong sources contribute, the weak ones drop out, and you read the output summary to see what actually landed. Point it at what you already have – then check what came back. ## Next steps {% card-grid %} {% link-card title="Create a provisioning job" href="/docs/api/provisioning/create" icon="gear" description="The full create request the sources array is part of, with the 202 response and engine ID." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="gear" description="Brand voices, glossary items, and instructions the agent builds from your sources, plus the output summary." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Watch crawling and configuring steps as the job reads your sources and builds the engine." /%} {% /card-grid %} - [Lock non-translatable keys](https://lingo.dev/en/docs/api/localization/locked-keys): Keep IDs, slugs, URLs, and enum codes byte-for-byte identical through translation. Declare them in lockedKeys and the engine excludes those values from translation, then merges the source value back verbatim into every locale's output. A real payload is rarely all prose. The same object that holds a `title` and a `body` also holds an `id`, a `slug`, an asset URL, a template name, an enum code – values that identify or wire your content and must come out of translation exactly as they went in. The risk is quiet: hand a model a field called `id` next to text it's translating, and it may decide `"post-42"` reads better localized, or normalize a URL, or "correct" an enum. One mutated identifier is a broken link or a failed lookup in production, in whichever locale the model felt helpful. `lockedKeys` removes the guesswork. You name the keys that must not change – by exact name or by glob – and the localization engine excludes those values from translation, then merges the source values back **verbatim into `outputData`** for every target locale. A locked value is not translated, normalized, or rewritten. Same identifier in, same identifier out, in every locale. `lockedKeys` is a field on the create-jobs request. See [Create jobs](/docs/api/localization/create) for the full request shape and the 202 response; this page covers only what to put in `lockedKeys` and how the matching works. ## Lock a key by name Pass `lockedKeys` alongside your `data`. Each entry is a pattern – at its simplest, the bare name of a key you want preserved. ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "id": "post-42", "title": "How async APIs reduce latency", "tags": ["performance", "infra"], "author": { "id": "u_abc", "name": "Sam" }, "body": "Async APIs let your app stay responsive while translations process in the background." }, "lockedKeys": ["id"] } ``` The bare pattern `id` matches the key `id` wherever it appears as a complete segment – here, both the top-level `id` and the nested `author.id`. Every German and French job's `outputData` keeps `"post-42"` and `"u_abc"` exactly. Only `title`, `name`, and `body` are translated; `tags` stays as-is because it holds no locked path, and its string values translate like any other text. That last point is worth pinning down, because it answers the first question a skeptic asks. {% callout type="info" title="Is a locked value translated?" %} No. A key you name in `lockedKeys` is excluded from translation, and its source value is merged back into `outputData` verbatim for every target locale. The value you sent comes back unchanged – not translated, not normalized, not rewritten. Locking is a guarantee about the result, expressed through `lockedKeys`, not a hint the model is asked to honor. {% /callout %} ## Match by name, anywhere – or by position A bare pattern is a key name, and it matches that name **as a complete segment, at any depth, anywhere in the tree**. If `audioSrc` appears in twelve places nested under different parents, the single pattern `audioSrc` locks all twelve. You don't enumerate paths to catch every occurrence – that's the common case, and it's one line. When you need positional control – lock one occurrence but not another, or every element of an array but nothing else – use a glob with `/` as the path separator. Array indices appear as ordinary segments, so `users/0/email` and `users/*/email` are both valid paths. | Pattern | What it locks | | ------------------------ | -------------------------------------------------------------- | | `audioSrc` | Every `audioSrc` leaf in the tree, at any depth | | `metadata` | The whole `metadata` subtree wherever it appears | | `metadata/author` | The `metadata/author` sequence anywhere it appears, plus below | | `users/*/email` | Every user's `email` – `*` is one segment, matches any index | | `users/0/email` | Only the first user's email | | `**/{audioSrc,imageSrc}` | Both leaf names via brace alternation | Two patterns above lock more than a single leaf, by design. `metadata` locks the entire subtree under that key – every value beneath it, translatable-looking or not, is preserved. `metadata/author` locks that sequence wherever it occurs **and everything below it**. Reach for a subtree lock when a whole block is structural – a config object, a raw embed – and for a leaf lock (`metadata/author/name`) when only one field inside an otherwise-translatable block must hold. {% callout type="info" title="Glob, not regex" %} `*` matches exactly one path segment; `**` spans any number of segments; `{a,b}` is brace alternation across alternatives. There is no character-class or partial-token matching – patterns operate on whole path segments, not substrings. Write `users/*/email`, not a regular expression. {% /callout %} ## What comes back Locking changes what the model translates – it does not change the shape of your result. `outputData` mirrors the input structure exactly: locked keys sit in their original positions holding their original values, and translatable strings around them are translated. Nothing is dropped, renamed, or reordered. For the input above, every locale's `outputData` carries `id: "post-42"` and `author.id: "u_abc"` unchanged, with `title`, `name`, and `body` in the target language. The full job response – `outputData`, per-stage `steps`, and status – is documented on [Get a single job](/docs/api/localization/jobs). ## One limit, named up front `lockedKeys` accepts **up to 100 patterns** per request. That's a ceiling on the number of patterns, not the number of keys they match – a single `audioSrc` or `users/*/email` can lock thousands of values across a large payload, and counts as one pattern. If you're approaching 100 distinct patterns, it's usually a sign that a broader glob (`**/{id,slug,href}`) or a subtree lock will express the same intent in far fewer lines. `lockedKeys` is also per-request and ad hoc: it locks keys for this job group only. So for terms that should never translate in *any* job – a product name, a trademarked feature, a unit that must stay literal – the durable home is a non-translatable entry in your engine's glossary, applied automatically on every call. See [Glossaries](/docs/platform/glossaries). Use `lockedKeys` for structural fields tied to a specific payload's shape; use the glossary for vocabulary that's constant across all your content. ## Next steps {% card-grid %} {% link-card title="Create jobs" href="/docs/api/localization/create" icon="lightning" description="The full create-jobs request and 202 response that lockedKeys is part of" /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Read outputData and confirm your locked values came back verbatim" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Mark vocabulary non-translatable across every job, not just one request" /%} {% /card-grid %} - [Create a provisioning job](https://lingo.dev/en/docs/api/provisioning/create): POST /jobs/provisioning with a name and up to 10 sources, and get back an engine ID you can use immediately. The 202 hands you the engine before the AI finishes configuring it from your content in the background. Submit the sources you already have, get an engine back. `POST /jobs/provisioning` takes a name for a new engine and up to 10 sources – links to crawl or raw text – and returns `202 Accepted` with the engine's ID. You do not wait for the AI to finish reading your content: the engine exists the moment the call returns, and its configuration is applied as the job runs. ``` POST /jobs/provisioning ``` This page covers the create call: its parameters, the request shape, and the `202` response. New to async provisioning? Start with the [Async Provisioning API overview](/docs/api/provisioning) for the mental model. What counts as a good source is its own page – [Source types](/docs/api/provisioning/sources) – and what the AI pulls out of them lives on [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="Authentication" %} Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the organization. See [Authentication](/docs/api/authentication) for details. {% /callout %} ## Parameters Only `engine.name` is required. Everything else shapes what the engine learns – or, if you omit it all, leaves you with a clean engine on defaults. | Parameter | Type | Description | | --- | --- | --- | | `engine.name` | string | Name for the new localization engine. | | `engine.description` | string (optional) | Free-text description for the engine. | | `locales` | string[] (optional) | BCP-47 target locales to configure for, e.g. `["es", "ja", "de"]`. | | `sources` | array (optional) | Up to 10 sources to analyze. Each is a `link` (a URL the platform crawls) or `content` (raw text or markdown). See [Source types](/docs/api/provisioning/sources). | | `callbackUrl` | string (optional) | HTTPS webhook URL for the completion result. HTTPS only – HTTP callback URLs are rejected. See [Webhook delivery](/docs/api/provisioning/webhooks). | ## Request A source is a `{ type, payload }` object. Point `link` sources at pages with real context – brand guidelines, style guides, product docs – and use `content` for terminology and tone rules you can paste in directly. The request below mixes both: two pages to crawl and one block of explicit rules. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch("https://api.lingo.dev/jobs/provisioning", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ engine: { name: "Acme Corp Engine", description: "Production localization engine for acme.com", }, locales: ["de", "fr", "ja", "es"], sources: [ { type: "link", payload: "https://acme.com/brand-guidelines" }, { type: "link", payload: "https://acme.com/docs/style-guide" }, { type: "content", payload: "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English.", }, ], callbackUrl: "https://your-app.com/webhooks/provisioning", }), }); const { jobId, engineId, status } = await response.json(); // 202 back right away. // status: "in_progress" – the AI is reading your sources. console.log(engineId); // "eng_X1y2Z3a4B5c6D7e8" – usable right now ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/jobs/provisioning", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "engine": { "name": "Acme Corp Engine", "description": "Production localization engine for acme.com", }, "locales": ["de", "fr", "ja", "es"], "sources": [ {"type": "link", "payload": "https://acme.com/brand-guidelines"}, {"type": "link", "payload": "https://acme.com/docs/style-guide"}, { "type": "content", "payload": "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English.", }, ], "callbackUrl": "https://your-app.com/webhooks/provisioning", }, ) result = response.json() # status: "in_progress" – the AI is reading your sources. print(result["engineId"]) # "eng_X1y2Z3a4B5c6D7e8" – usable right now ``` {% /tab %} {% /tabs %} ## Response (202 Accepted) The call returns without waiting for the crawl or the analysis – it hands you a job ID to track and an engine ID that is live from this point on. ```json { "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "status": "in_progress" } ``` | Field | Description | | --- | --- | | `jobId` | Provisioning job ID (`pjb_` prefix). Track the job by [connecting a WebSocket](/docs/api/provisioning/realtime) for live progress, or receive the result on your [webhook](/docs/api/provisioning/webhooks) when it finishes. | | `engineId` | The new engine's ID (`eng_` prefix). Usable immediately – the configuration the AI extracts is applied to it as the job runs. | | `status` | `in_progress` when you provide sources; `completed` when you do not (see below). | The detail that makes this an async call worth making rather than a wait: `engineId` comes back in the same `202`, and it points at a real engine right away. You can store it, send a [synchronous Localize](/docs/api/localize) request through it, or wire it into your app before the AI has finished reading a single source. As brand voices, glossary items, and instructions are extracted, the platform applies each one to that same engine – the engine exists before its configuration does. To know exactly what the job created, read [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="No sources? You get an engine, not a wait." %} Omit `sources` and there is nothing to crawl, so the engine is created with default model configuration and returned with `status: "completed"` in the same response. That is the fast path when you want an empty engine to configure yourself – one call, a ready `engineId`, no background job to track. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Source types" href="/docs/api/provisioning/sources" icon="gear" description="link vs content sources, and what makes a source worth analyzing." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="book" description="Brand voices, glossary items, and instructions – plus the summary the job returns." /%} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="Receive the completion result at your callback URL, and verify the signature." /%} {% /card-grid %} - [Configure the pipeline](https://lingo.dev/en/docs/api/pipeline/configure): Turn pipeline stages on per engine in the Pipeline tab, then override them for a single submission with a pipelineConfig object. Stages you omit inherit the engine config, so each request states only what differs. Set which pipeline stages run, in two layers: a default on the engine, and an optional override on a single request. You have decided which stages you want around the core translate step. Now there are two questions: where does that decision live, and what do you do when one job needs something different from the rest? The answer is two layers. The engine carries the default that every async job inherits. A `pipelineConfig` object on a single submission overrides that default for that submission only. Stages you leave out of the override inherit from the engine, so a request states only what differs. New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline) for what each stage does. This page is about turning them on and overriding them – not what they do once enabled. {% callout type="info" title="Async jobs only" %} Pipeline configuration applies to jobs created through the [Async Localization API](/docs/api/localization). The synchronous [`/localize` endpoint](/docs/api/localize) runs the core translate step only and ignores pipeline settings entirely – on either layer. {% /callout %} ## Engine-level defaults Open the engine's **Pipeline** tab in the dashboard and toggle each stage independently. That configuration is the default for the engine: every async job routed to it runs with these stages unless a request overrides them. Set it once, and you do not restate the pipeline on every call. Each stage is its own switch. You enable any combination – none of them, all of them, or anything between: - [**Pre-localization AI edit**](/docs/api/pipeline/pre-edit) – clean the source before translation. - [**Post-localization human review**](/docs/api/pipeline/human-review) – route to Internal or External review. You pick the mode, tier, and timeout in the same panel. - [**Post-localization AI review**](/docs/api/pipeline/ai-review) – stays disabled until human review is enabled; it reconciles the human edit with your engine rules. - [**Rephrase for natural copy**](/docs/api/pipeline/rephrase) – rewrite to read native. Independent of the other stages. - [**Back-translation check**](/docs/api/pipeline/back-translation) – verify meaning survived the round trip. Independent of the other stages. [Core localization](/docs/platform/engines) is not a switch – it always runs. The stages wrap around it. The default is what every job inherits, so the engine config is the shape a `pipelineConfig` override is merged into. Each stage is one key: ```json { "preEdit": { "enabled": true }, "humanEdit": { "enabled": true, "provider": "internal", "tier": "standard", "timeoutHours": 48 }, "postEdit": { "enabled": false }, "rephrase": { "enabled": false }, "backTranslation": { "enabled": true } } ``` | Key | Fields | Set on the stage page | | --- | --- | --- | | `preEdit` | `enabled` | [Pre-localization AI edit](/docs/api/pipeline/pre-edit) | | `humanEdit` | `enabled`, `provider` (`internal` \| `gengo`), `tier` (`standard` \| `pro`), `timeoutHours` | [Human review](/docs/api/pipeline/human-review) | | `postEdit` | `enabled` | [AI review](/docs/api/pipeline/ai-review) | | `rephrase` | `enabled` | [Rephrase for natural copy](/docs/api/pipeline/rephrase) | | `backTranslation` | `enabled` | [Back-translation check](/docs/api/pipeline/back-translation) | What each field controls – which review provider, which tier, how long the wait – is documented on the stage's own page. This page is about where the config lives and how the two layers combine. ## Per-request override Most jobs should run the engine default. The exception is a single submission that needs a different pipeline – a one-off batch of marketing copy that wants the rephrase stage your engine normally leaves off, or a legal payload that should skip it. Editing the engine to handle one batch would change every other job too. So you pass the difference on the request instead. Add a `pipelineConfig` object to the [`POST /jobs/localization`](/docs/api/localization/create) body, and it overrides the engine default for that submission alone. Nothing on the engine changes; the next job without an override is back on the default. ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "headline": "Ship in every language." }, "pipelineConfig": { "rephrase": { "enabled": true }, "backTranslation": { "enabled": false } } } ``` This is the inheritance rule, and it is what keeps the override small: **a stage you name is overridden; a stage you omit inherits the engine default.** The request above turns `rephrase` on and `backTranslation` off for this one job. `preEdit`, `humanEdit`, and `postEdit` are not named, so they run exactly as the engine has them configured. You state only what differs. {% callout type="warning" title="Include a stage and you specify all of it" %} The override is per stage, not per field. Each stage you include must be the complete object for that stage – you cannot send `humanEdit: { "tier": "pro" }` to change only the tier while inheriting the rest. Include the whole stage to override it, or omit it to inherit the engine default. There is no partial-stage merge inside a single stage object. {% /callout %} Two more things the override does not do, stated plainly because this is the part that looks like it can do anything: - It changes **that submission only**. It does not write back to the engine, so it is not how you make a lasting configuration change – that is the Pipeline tab. Use the override for the one-off; use the tab for the new normal. - It does not relax a stage's own runtime rules. [Post-localization AI review](/docs/api/pipeline/ai-review) only runs when human review produced output, so enabling `postEdit` does nothing on a job that has no human stage to reconcile – whichever layer you enabled it on. ## Confirm what ran Configuration sets which stages should run; the job's own record tells you which ones did. The job carries a `steps[]` array, and that array is how you confirm a per-request override actually took effect – not just that you sent it. Reading those records – the `stepId` for each stage, what a `skipped` step means, where non-critical failures surface – is its own page. ## Next steps You can set the default on the engine and override it on a request. From here, submit a job that carries an override, or read back the steps to confirm which stages ran. {% card-grid %} {% link-card title="Create localization jobs" href="/docs/api/localization/create" icon="lightning" description="Submit a job and pass pipelineConfig in the body to override stages for that request." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="gear" description="Read the per-stage steps on a job to confirm what ran, was skipped, or failed." /%} {% link-card title="Pipeline overview" href="/docs/api/pipeline" icon="gear" description="What each stage does and the order they run in around the core translate step." /%} {% link-card title="Engines" href="/docs/platform/engines" icon="gear" description="The engine the Pipeline tab lives on, and the config each stage wraps around." /%} {% /card-grid %} - [Async Provisioning API](https://lingo.dev/en/docs/api/provisioning): Configure a localization engine from the brand guidelines, style guides, and glossaries you already have. One request points an AI agent at your sources; it extracts brand voices, glossary items, and instructions and applies them to a new engine you can use immediately. You are standing up a localization engine. The engine that translates well is not the empty one – it is the one that carries your brand voice, your glossary, and your instructions, so every translation sounds like your product and never mangles a term you have already decided about. That knowledge usually exists before the engine does. It is sitting in a brand guidelines page, a style guide, a terminology sheet, a few paragraphs of rules a translator was once handed. Building the engine by hand means reading all of that and re-entering it as brand-voice, glossary, and instruction records, one at a time – tedious work that is easy to start and easy to leave half-finished. The async provisioning API closes that gap: **point it at what you already have.** You POST links and raw text in one request, get an engine ID back right away, and an AI agent crawls the sources, extracts brand voices, glossary items, and instructions, and applies each to the new engine as it finds it. The engine is usable from the moment you get the ID – the configuration fills in as the job runs. **On this page** - [The problem](#the-problem) - [How it works](#how-it-works) - [What you get back](#what-you-get-back) - [Where to go next](#where-to-go-next) ## The problem A localization engine is only as good as its configuration. Model selection gets you a translation; brand voices, glossary items, and instructions are what make that translation match how your product already speaks – the formality you have chosen, the product names you never translate, the date format you always use. Those are the same primitives you would otherwise create by hand: [brand voices](/docs/platform/brand-voices), [glossary items](/docs/platform/glossaries), and [instructions](/docs/platform/instructions) on an [engine](/docs/platform/engines). The catch is that all of it already lives somewhere. A team that has shipped a product in one language has a brand guidelines page, a style guide, a glossary of terms support agents are told never to translate. To configure the engine by hand, you read those documents and transcribe them into records – decision by decision, locale by locale. It is slow, and the slowest part is the least interesting: copying knowledge that is already written down into a different form. Provisioning removes that step. You hand the platform the documents themselves – as URLs to crawl or as raw text – and an AI agent does the reading and the transcription. It creates the brand-voice, glossary, and instruction records for you, on a real engine, applying each as it is identified. You review and adjust afterward in the dashboard, the same way you would edit anything you created yourself. The starting point is a configured engine, not a blank one. {% callout type="info" title="Provisioning configures an engine; it does not translate." %} This API builds and configures the engine. To translate with the engine once it exists, reach for the [async Localization API](/docs/api/localization) for many locales at once, or the [synchronous Localize endpoint](/docs/api/localize) for a single locale pair. Provisioning is the setup step that makes those calls carry your brand voice and glossary from the first translation. {% /callout %} ## How it works Three steps, and only the first one happens inside your request. The other two run on the platform, on their own time – which is why the call returns right away and the engine is usable before the work is done. {% steps %} {% step title="Submit your sources" %} POST a name for the new engine and an array of sources – URLs to crawl, raw text to analyze, or both – to `/jobs/provisioning`. The API creates the engine immediately and returns `202` with the engine ID (`eng_`) and the job ID (`pjb_`). Your application is free to continue; nothing about the response waits on extraction. See [Create a provisioning job](/docs/api/provisioning/create) for the full request and response shape, and [Source types](/docs/api/provisioning/sources) for what makes a source worth submitting. {% /step %} {% step title="The AI agent crawls and extracts" %} Link sources are crawled in parallel and converted to text; raw content is read directly. An AI agent then analyzes everything and extracts three kinds of configuration – brand voices, glossary items, and instructions – applying each to the engine as it is identified. A source that fails to crawl, or a single item that cannot be created, does not stop the rest. [What the AI extracts](/docs/api/provisioning/extraction) covers the three components and how they map to locales. {% /step %} {% step title="The engine is ready" %} When extraction finishes, the engine is fully configured and ready to translate through the [Localization API](/docs/api/localization). The platform reports completion – with a summary of everything that was created – to your [webhook URL](/docs/api/provisioning/webhooks), or live over the [job's WebSocket](/docs/api/provisioning/realtime) if you want to show progress while it runs. {% /step %} {% /steps %} {% callout type="info" title="The engine ID is usable immediately." %} The `eng_` ID in the `202` is a real engine the instant you receive it. You can store it, reference it, and translate against it right away – configuration is applied as the job runs, so a translation made early sees fewer extracted records than one made after the job completes. You never wait on provisioning to start using the engine. {% /callout %} {% callout type="info" title="Authentication" %} Every request – REST and WebSocket – authenticates with your `X-API-Key` header. Keys are organization-scoped and reach every engine in the org. See [Authentication](/docs/api/authentication) for the details, and [API Keys](/docs/platform/api-keys) to create one. {% /callout %} ## What you get back A skeptical reader is already asking the two questions that decide whether this is safe to depend on: what if a source is bad, and is this a black box I cannot correct? Neither answer is hidden. Provisioning does not return a proprietary blob – it creates ordinary brand-voice, glossary, and instruction records on a real engine, the exact same objects you would create by hand, each editable afterward in the dashboard. When the job finishes it hands back a summary that names every record it created and every failure it hit, so you can confirm the configuration instead of assuming it. [What the AI extracts](/docs/api/provisioning/extraction) walks through that summary and how a failed item is isolated to an `errors` list while the rest of the engine still configures. Sources are also optional. Submit a name with no `sources` and you get a clean engine on defaults to configure yourself – [Create a provisioning job](/docs/api/provisioning/create) covers that path alongside the `202` response shape. Provisioning is how you skip the manual setup, not a prerequisite for having an engine at all. {% callout type="warning" title="Generic pages produce generic config." %} The configuration is only as good as what you submit – brand guidelines and terminology lists give the agent something concrete to extract; a marketing homepage yields almost nothing. [Source types](/docs/api/provisioning/sources) covers what to point it at. {% /callout %} That is the trade provisioning makes: you spend one request and a short wait, and in exchange you skip transcribing knowledge you already wrote down – **point it at what you already have**, and start from a configured engine. The pages below are the parts of that sentence. ## Where to go next {% card-grid %} {% link-card title="Create a provisioning job" href="/docs/api/provisioning/create" icon="gear" description="POST /jobs/provisioning – parameters, a worked request, and the 202 response with your engine and job IDs." /%} {% link-card title="Source types" href="/docs/api/provisioning/sources" icon="book" description="link versus content sources, and what makes a source worth submitting." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="book" description="Brand voices, glossary items, and instructions – how they map to locales, and the output summary." /%} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="Receive the completed or failed result at your callback URL, and verify the signature." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Stream snapshot and progress events while the engine configures – connect any time, even after it finishes." /%} {% link-card title="Translate with your new engine" href="/docs/api/localization" icon="lightning" description="Once the engine is configured, fan content out to every locale through the async Localization API." /%} {% /card-grid %} - [Localization Pipeline](https://lingo.dev/en/docs/api/pipeline): Wrap the core translate step with optional stages – source cleanup, human review, AI reconciliation, idiomatic rephrase, and a back-translation drift check. Toggle each on the engine, run them inside the durable async job, and let a non-critical stage degrade to a warning instead of failing the whole job. A machine translation is a strong first pass. For a lot of content it is the whole job. But some content needs more before it ships: source text that has been cleaned of typos first, a human translator in the loop, copy that reads like a native wrote it, a check that the meaning survived the round trip. You could bolt those steps on yourself – call translate, run a grammar pass, route the result to a reviewer, wait for them, translate back to check for drift, reconcile the differences. The translating is the easy part. Orchestrating the waits, the ordering, and the failures between those steps is the hard part – and a reviewer who takes two days to respond is the hardest part of all. The pipeline is those steps, already wired. Each stage is an optional wrapper around the core translate step, **toggled on the [localization engine](/docs/platform/engines)** (or overridden per request), and run **inside the durable async job** that already owns retries and failure isolation. You pick the stages; the platform runs them in order and records what happened. The job is the single belief this cluster leaves you with: **wrap the translate step with exactly the stages you need.** {% callout type="warning" title="Async API only" %} Pipeline stages apply only to jobs created through the [Async Localization API](/docs/api/localization). The synchronous [`/localize` endpoint](/docs/api/localize) runs the core translate step and nothing else – any pipeline configuration on the engine is ignored. A human-review stage needs a workflow that can pause for two days; a single request/response call has nowhere to put that wait. The pipeline lives where the job is durable. {% /callout %} **On this page** - [Why a pipeline](#why-a-pipeline) - [Stages at a glance](#stages-at-a-glance) - [What a failed stage does to the job](#what-a-failed-stage-does-to-the-job) - [Where to go next](#where-to-go-next) ## Why a pipeline Raw translation has no opinion about what kind of content it is translating. Legal text wants to stay literally faithful to the source. Marketing copy wants to read like a native wrote it, not like it was translated. User-generated source text wants the typos cleaned up first, before a single error in the source poisons every target locale. Regulated content wants a qualified human to sign off. These are different jobs, and the pipeline lets one engine do any of them by composing stages instead of forcing one behavior. Enable none and you get plain translation. Enable a human-review stage and the job pauses for your team. Enable the rephrase stage and the output is rewritten to read native. Each stage page below names the content it is for – and, just as plainly, the content it is **not** for, so you do not enable a stage that works against your goal. You configure the defaults once on the engine's **Pipeline** tab, or override them for a single submission with a `pipelineConfig` object on the request – omitted stages inherit the engine's setting. The mechanics of both layers live on [Configure the pipeline](/docs/api/pipeline/configure). ## Stages at a glance The pipeline wraps the core localization step. Any combination of stages can be enabled, in this fixed order. Disabled stages are skipped entirely. Each stage has its own page with the full behavior, the failure mode, and the call to enable it. {% steps %} {% step title="Pre-localization AI edit" %} Optional. An AI agent cleans the source payload – typos, grammar, spelling – before anything is translated, so a single source error does not propagate to every target locale. Non-critical. See [Pre-localization AI edit](/docs/api/pipeline/pre-edit). {% /step %} {% step title="Core localization" %} Always runs. Your engine applies its [model config](/docs/platform/llm-models), [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) to produce the translation. This is the one stage you cannot turn off – everything else wraps it. {% /step %} {% step title="Post-localization human review" %} Optional. A human reviews the translation – your own team in the dashboard (Internal Review) or a professional translator from an external provider (External Review). The job pauses for their output on an event-driven wait, so a long review burns no compute while it waits. See [Human review](/docs/api/pipeline/human-review). {% /step %} {% step title="Post-localization AI review" %} Optional, and only runs after human review produces output. An AI agent reconciles the human's edits with your engine's glossary, brand voice, and instructions. This is not the same as AI Reviewers, which score quality without changing the text. See [AI review](/docs/api/pipeline/ai-review). {% /step %} {% step title="Rephrase for natural copy" %} Optional. An AI agent rewrites the translation to read as native, idiomatic copy in the target locale, preserving meaning, placeholders, and tags. Non-critical. For marketing copy; skip it where literal accuracy matters. See [Rephrase for natural copy](/docs/api/pipeline/rephrase). {% /step %} {% step title="Back-translation check" %} Optional. The output is translated back to the source, an AI compares it against the original, and drift is flagged `minor`, `major`, or `critical` – major and critical issues are auto-corrected. A classic human-QA technique, automated. See [Back-translation check](/docs/api/pipeline/back-translation). {% /step %} {% /steps %} ## What a failed stage does to the job The obvious objection to a six-stage pipeline: every stage you add is another thing that can break – so does enabling them make the job more likely to fail? No. A failure in a **non-critical** stage does not fail the job. Pre-edit and rephrase are non-critical: if either fails, the last good output carries forward unchanged and the job continues. The job degrades to a warning state instead of breaking, and every enabled stage leaves a record you can read back to see exactly what ran. That is the shape of the whole pipeline: **wrap the translate step with exactly the stages you need**, run them inside a job that already owns the failures, and read back a record for each stage. How a degraded job reports itself, and the per-stage inspection surface, are on [Observe pipeline runs](/docs/api/pipeline/observability). The pages below are the stages themselves – start with the one that matches the content you are translating. ## Where to go next {% card-grid %} {% link-card title="Pre-localization AI edit" href="/docs/api/pipeline/pre-edit" icon="lightning" description="Clean typos and grammar in the source before translating, so one source error does not reach every locale." /%} {% link-card title="Human review" href="/docs/api/pipeline/human-review" icon="chat" description="Internal or external reviewers, tiers, and the event-driven timeout – a paused job costs no compute while it waits." /%} {% link-card title="AI review (post-edit)" href="/docs/api/pipeline/ai-review" icon="shield" description="Reconcile a human reviewer's edits back to your glossary, brand voice, and instructions." /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="globe" description="Rewrite the translation to read native and idiomatic – for marketing copy, not literal content." /%} {% link-card title="Back-translation check" href="/docs/api/pipeline/back-translation" icon="shield" description="Translate the output back to the source, detect semantic drift, and auto-correct major issues." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Set engine-level defaults and override stages per request with pipelineConfig." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the per-stage steps array, the stepId table, and how completed_with_warnings reports a degraded job." /%} {% /card-grid %} - [Async Localization API](https://lingo.dev/en/docs/api/localization): Translate content into many locales without owning the retries. One request fans out to one independent job per locale; the platform handles retries, failure isolation, and delivery while your app stays responsive. Your content changes, and now it has to reach every locale you ship in. A training module gets a new lesson. A CMS entry is saved. A product description is edited. The translation should fan out to German, French, Japanese, and a dozen more – and your app should not block while it happens. The async localization API is built for exactly that moment: **one request, every locale, results as they land.** You POST your content once with the target locales, get a `202` back immediately, and the platform translates each locale as an independent background job. You keep your glossary, brand voice, and model configuration – the same engine the [synchronous API](/docs/api/localize) uses – and you stop owning the retries. **On this page** - [The problem](#the-problem) - [How it works](#how-it-works) - [The job group model](#the-job-group-model) - [Where to go next](#where-to-go-next) ## The problem Training platforms, content management systems, and e-learning tools often need to translate content into dozens of languages the moment it is created or updated. The [synchronous localization API](/docs/api/localize) works, but it forces a tradeoff at scale. Take a training module authored in English that needs to reach learners in 14 languages. With the synchronous API you have two options, and both cost you something: 1. **Fire 14 parallel calls** – one request per target locale, each carrying the same source payload. You can render each language as it returns, but you are managing 14 network round-trips with redundant data, and you own the retry logic when one of them fails. 2. **Translate all 14 in one synchronous call** – fewer round-trips, but now you wait for the slowest locale before you can display any of them. Either way, your application is tied up while translations process. If your server restarts mid-flight, the in-progress translations are gone. If one language fails, partial-failure handling is on you. And neither path gives your users a clean signal that translations are underway. The async API removes the tradeoff. One request creates a job group with one job per target locale. Each job runs **independently** through your localization engine as a durable background workflow – so a server restart on your side loses nothing, because the work is not running in your process. Results are delivered per-locale the moment each one finishes. Your app stays responsive. Failures stay isolated. The platform owns the retries and the delivery. {% callout type="info" title="One locale, and you can wait a second? Use sync." %} The async API earns its keep when you have many locales, long content, or a UI that should show progress. If you need a single locale pair and can wait for one round-trip, the [synchronous Localize endpoint](/docs/api/localize) is the simpler call – one request, translated data back in the response, no webhook endpoint to run. Reach for async when the job is too big, too slow, or too many-locale to block on. {% /callout %} ## How it works Three steps, and only the first one happens in your request/response cycle. The other two happen on the platform, on their own time. {% steps %} {% step title="Submit one request" %} POST your content and target locales to `/jobs/localization`. The API validates the payload, creates a job group with one job per locale, and returns `202` with the group ID and a job summary. Your application is free to continue immediately – nothing translates inside this call. See [Create jobs](/docs/api/localization/create) for the full request and response shape. {% /step %} {% step title="The platform processes each locale independently" %} Each job runs through your localization engine via a durable background workflow, applying the same model selection, glossary, brand voice, and instructions as the [synchronous API](/docs/api/localize). Each job can optionally run through a [pipeline](/docs/api/pipeline) of pre-edit, human review, post-edit, rephrase, and back-translation stages. A job moves from `queued` to `processing` to a terminal state: `completed`, `completed_with_warnings`, or `failed` – and one locale's outcome never blocks another's. {% /step %} {% step title="Receive results as each one lands" %} The moment a locale finishes, the platform delivers its result to your [webhook URL](/docs/api/localization/webhooks). For live progress in your UI – a "3 of 14 ready" counter that updates as jobs complete – connect to the group's [WebSocket](/docs/api/localization/realtime). If you would rather pull, [poll the group](/docs/api/localization/job-groups) on an interval. {% /step %} {% /steps %} {% callout type="info" title="Authentication" %} Every request – REST and WebSocket – authenticates with your `X-API-Key` header. Keys are organization-scoped and reach every engine in the org. See [Authentication](/docs/api/authentication) for the details, and [API Keys](/docs/platform/api-keys) to create one. {% /callout %} ## The job group model One submission produces one **group** holding one **job** per target locale. That shape is the whole mental model, and it is what makes the hard questions answerable. A skeptical reader is already running the list: what happens when a locale fails, and what happens to my app while all this is in flight? The group model answers both. - **Failures are isolated, because each locale is its own job.** If German succeeds and Japanese fails, the German translation is delivered normally and the Japanese job carries its own `errorMessage`. The group reports `partial`, and the work that succeeded still ships. A failure in one locale cannot roll back another that already completed. The full status semantics live on [Track a job group](/docs/api/localization/job-groups). - **In-flight work survives a restart, because it does not run in your process.** Each job is a durable background workflow on the platform. If your server reboots, nothing in progress is lost – you reconnect or poll, and the group is exactly where you left it. - **The group is a progress model you can wire to a UI.** Store the `groupId` from the `202`, then drive a progress indicator from [webhook](/docs/api/localization/webhooks) deliveries or [WebSocket](/docs/api/localization/realtime) snapshots. "3 of 14 languages ready" is a counter over the group's child jobs. The honest cost of this model: you take on a small amount of integration the synchronous call does not ask for. To receive results you run an HTTPS webhook endpoint, or hold a server-side WebSocket, and you handle each locale as it arrives rather than reading translated data straight out of one response. In exchange, the platform owns the retries, the failure isolation, and the delivery – and your app never blocks on a translation. That is the trade the async API is built to make: **one request, every locale, results as they land.** The next pages are the verbs of that sentence. ## Where to go next {% card-grid %} {% link-card title="Create jobs" href="/docs/api/localization/create" icon="lightning" description="POST /jobs/localization – parameters, request shape, the 202 response, and idempotent retries." /%} {% link-card title="Lock non-translatable keys" href="/docs/api/localization/locked-keys" icon="gear" description="Keep IDs, slugs, and asset URLs verbatim with lockedKeys and its pattern syntax." /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="book" description="Read group and per-locale status, including partial-failure handling." /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="book" description="Fetch one job's translated outputData, warnings, and per-stage step records." /%} {% link-card title="List jobs" href="/docs/api/localization/list" icon="book" description="Cursor pagination over your jobs, filtered by engine or status." /%} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="shield" description="Receive each completed or failed locale as it lands, and verify the signature." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream group snapshots into your UI – a progress counter that updates as each locale completes." /%} {% /card-grid %} - [Webhook signatures](https://lingo.dev/en/docs/api/webhooks): Every async callback is signed with the Standard Webhooks scheme. Verify the HMAC-SHA256 signature from the raw body before you trust a payload, return 200 fast, and let failed deliveries retry. When an async job finishes, Lingo.dev does not make you poll for it. It calls you back: a `POST` to the HTTPS endpoint you registered as your `callbackUrl`. That is the convenience. It is also the exposure – a public URL accepts whatever the internet sends it, and anyone who learns yours can `POST` a forged "job completed" event at your handler. So the rule for every callback is the same: **verify before you trust.** Each delivery carries a signature computed from a secret only you and Lingo.dev hold. Recompute it on your side, compare it in constant time, and a forged payload never reaches your business logic. This page is the one place that mechanism lives. Both [localization](/docs/api/localization/webhooks) and [provisioning](/docs/api/provisioning/webhooks) callbacks use it unchanged – those pages cover their own payload shapes and link back here for verification. **On this page** - [The three headers](#the-three-headers) - [The signing secret](#the-signing-secret) - [Verify a signature](#verify-a-signature) - [Why the raw body matters](#why-the-raw-body-matters) - [Reject replays](#reject-replays) - [Respond fast, process later](#respond-fast-process-later) - [Retry and backoff](#retry-and-backoff) ## The three headers Lingo.dev follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification, an open scheme several providers implement, so you are verifying against a published contract rather than a vendor's snowflake. Every delivery includes three headers: | Header | Description | | --- | --- | | `webhook-id` | A unique identifier for the delivery. | | `webhook-timestamp` | Unix timestamp in seconds when the delivery was sent. | | `webhook-signature` | The signature itself: `v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))}` | The signed content is the three parts joined by periods – `webhook-id`, then `webhook-timestamp`, then the **raw request body** – in that exact order. Reconstruct that string, run it through HMAC-SHA256 with your secret, base64-encode the result, and you have the value to compare against. The `webhook-signature` header can carry more than one space-separated signature, each tagged with a scheme version (`v1,...`). A verifier accepts the delivery if **any** signature matches. Iterating the list rather than reading a single value is the defensive way to parse this header, so the samples below loop over every signature present. ## The signing secret The secret is generated for your organization the first time you submit a job with a `callbackUrl`. It is prefixed `whsec_`, followed by base64-encoded key bytes: ``` whsec_Mf9aQ7n...base64...key...bytes ``` Strip the `whsec_` prefix and base64-decode the remainder to recover the raw key bytes – that decoded value is the HMAC key, not the prefixed string. Signing against the literal `whsec_...` text is the most common reason a correct-looking implementation never matches, so decode first. {% callout type="warning" title="Treat the secret like an API key" %} The signing secret is what separates a real callback from a forged one. Keep it server-side, out of source control, and out of any client bundle. Anyone who holds it can sign payloads your handler will accept. See [API Keys](/docs/platform/api-keys) for how Lingo.dev handles org-scoped credentials. {% /callout %} ## Verify a signature Verification is a single function you mount once in front of your handler. It does three things: recompute the expected signature from the raw body, compare it to what arrived using a constant-time check, and reject anything that does not match before your code runs. The same function guards every async event Lingo.dev sends you – localization completions, provisioning completions, every type, every product surface. {% tabs %} {% tab label="Node.js" %} ```javascript import crypto from "node:crypto"; function verifyWebhook(payload, headers, secret) { const msgId = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; // Reject timestamps outside a tolerance window (replay prevention) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp, 10)) > 300) { throw new Error("Webhook timestamp too old"); } // Recompute the expected signature over id.timestamp.body const content = `${msgId}.${timestamp}.${payload}`; const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64"); const expected = crypto .createHmac("sha256", secretBytes) .update(content) .digest("base64"); // A delivery may carry several signatures; accept if any matches for (const sig of signatures.split(" ")) { const [version, value] = sig.split(",", 2); if (version === "v1" && crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(value) )) { return JSON.parse(payload); } } throw new Error("Invalid webhook signature"); } ``` {% /tab %} {% tab label="Python" %} ```python import hmac, hashlib, base64, json, time def verify_webhook(payload: str, headers: dict, secret: str) -> dict: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] signatures = headers["webhook-signature"] # Reject timestamps outside a tolerance window (replay prevention) if abs(time.time() - int(timestamp)) > 300: raise ValueError("Webhook timestamp too old") # Recompute the expected signature over id.timestamp.body content = f"{msg_id}.{timestamp}.{payload}" secret_bytes = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode( hmac.new(secret_bytes, content.encode(), hashlib.sha256).digest() ).decode() # A delivery may carry several signatures; accept if any matches for sig in signatures.split(" "): version, value = sig.split(",", 1) if version == "v1" and hmac.compare_digest(expected, value): return json.loads(payload) raise ValueError("Invalid webhook signature") ``` {% /tab %} {% /tabs %} Compare with a constant-time function – `crypto.timingSafeEqual`, `hmac.compare_digest` – not `==`. A plain string comparison returns as soon as two bytes differ, and that timing difference is enough to leak the signature one byte at a time. Constant-time comparison closes that side channel, which is why both samples above use it. ## Why the raw body matters Notice that both functions sign `payload` – the body exactly as it arrived on the wire, before any JSON parsing. This is the detail that most often trips up an otherwise-correct integration, and it is worth stating plainly at the point where it bites: The signature is computed over the exact bytes Lingo.dev sent. The moment you parse the body to an object and re-serialize it, you may change whitespace, key order, or numeric formatting – and the recomputed HMAC no longer matches a signature taken over the original bytes. The payload is identical in meaning; the bytes are not. {% callout type="warning" title="Verify against the raw body, not the parsed object" %} Capture the raw request body before your framework parses it, and pass those bytes to the verifier. In Express, use `express.raw({ type: "application/json" })` on the webhook route. In FastAPI, read `await request.body()`. Parse only after the signature checks out – verification first, parsing second. {% /callout %} ## Reject replays A valid signed payload that an attacker captured can be replayed verbatim – the signature is still valid, because nothing in it changes between the first delivery and a copy sent an hour later. The `webhook-timestamp` header is what bounds that window: it records when the delivery was sent, so your verifier can reject anything older than a tolerance you choose. The samples above use five minutes. A timestamp check stops a stale replay: a copy captured and resent later than your tolerance fails the freshness test and never reaches your handler. ## Respond fast, process later Once a delivery is verified, return `200` immediately, then do the real work – database writes, downstream calls, cache invalidation – after you have responded. ```javascript app.post( "/webhooks/lingo", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET); } catch { return res.status(401).send("invalid signature"); } // Acknowledge first, process after - never block the response on slow work res.status(200).send("ok"); void handleEvent(event); } ); ``` The reason is mechanical, not stylistic. A slow handler holds the HTTP connection open; if it runs long enough to time out, the delivery counts as failed and gets retried – so heavy work inside the response path turns one event into several. Acknowledge fast, hand the work to a queue or a background task, and a single event stays a single event. The payload shapes you switch on inside `handleEvent` live with each product: [localization callbacks](/docs/api/localization/webhooks) and [provisioning callbacks](/docs/api/provisioning/webhooks). ## Retry and backoff Your endpoint will be down sometimes – a deploy, a timeout, a bad gateway. Lingo.dev does not drop the event when that happens. If your endpoint returns a non-2xx status or is unreachable, delivery is retried with exponential backoff starting at 30 seconds, **up to 5 attempts**. After the fifth attempt the delivery is marked failed and Lingo.dev stops trying – but the result is not lost. It stays retrievable from the job record, so a stretch of downtime costs you a callback, never the result itself. That job record is your backstop: build the webhook for the common case, and treat the stored job as the source of truth you can always fall back to. For a translation job, [poll it directly](/docs/api/localization/jobs). ## Next steps {% card-grid %} {% link-card title="Authentication" href="/docs/api/authentication" icon="shield" description="How API keys authenticate every request to the API" /%} {% link-card title="Localization webhooks" href="/docs/api/localization/webhooks" icon="lightning" description="The translation.completed and translation.failed payload shapes" /%} {% link-card title="Provisioning webhooks" href="/docs/api/provisioning/webhooks" icon="gear" description="Callback payloads for AI engine provisioning jobs" /%} {% /card-grid %} - [Errors & status codes](https://lingo.dev/en/docs/api/errors): Every Lingo.dev API error is one JSON shape and one HTTP status code. The full table, what each code means, and which ones are safe to retry. The call works in development. Now you are writing the part that runs in production – the catch block. An HTTP error from a third-party API is opaque on its own: a red status code, and no obvious answer to the only question that matters at 3am – is this my request, my key, my plan, or their servers? And of these, which ones do I retry, and which do I surface to the user? Lingo.dev answers that with structure. Every error – from every endpoint, sync or async – comes back as the same JSON object with the same status code drawn from one fixed table. The status code is not a label, it is an instruction: it tells you whether to fix the request, rotate the key, top up the account, back off, or retry. **Read the code, know the next move.** One error handler, keyed on the status, covers the whole API. **On this page** - [The error shape](#the-error-shape) - [Status codes](#status-codes) - [Which errors to retry](#which-errors-to-retry) - [402 vs 429: two different limits](#402-vs-429-two-different-limits) - [Where async job errors live](#where-async-job-errors-live) ## The error shape Every non-2xx response has the same body: a JSON object with a single `message` field describing what went wrong. ```json { "message": "Invalid API key" } ``` That is the whole contract. There is no envelope to unwrap, no per-endpoint error format to special-case. A 400 from `/process/localize` and a 404 from a job lookup return the same shape – only the status code and the `message` text differ. {% callout type="warning" title="Match on the status code, not the message text" %} The HTTP status code is the stable signal – branch your error handling on it. The `message` string is written for a human reading a log; treat it as a description, not a machine-readable error code, and don't pattern-match on its exact wording. {% /callout %} ## Status codes Seven status codes cover every response. They are grouped here by **who resolves them** – because that grouping is also your retry policy. **You sent something the request can't satisfy (fix the request, don't blind-retry):** | Status | Meaning | | --- | --- | | `400 Bad Request` | Request validation failed – a missing field, an invalid locale, an HTTP (not HTTPS) `callbackUrl`, a malformed payload. | | `401 Unauthorized` | The `X-API-Key` header is missing or invalid. See [Authentication](/docs/api/authentication). | | `403 Forbidden` | The key is valid but has no access to the requested resource. | | `404 Not Found` | The resource – an engine, a job, a job group – does not exist. | **Your organization has hit an account limit (resolve in billing):** | Status | Meaning | | --- | --- | | `402 Payment Required` | The organization has hit its credit limit. | | `429 Too Many Requests` | The organization has hit its daily token quota. Upgrade the plan to raise the limit. | **Something failed on our side (transient – retry):** | Status | Meaning | | --- | --- | | `500 Internal Server Error` | An unexpected failure – a database error, or the translation call failed across every configured model in the engine. | A `401` and a `403` look similar but are not the same problem: `401` means we could not identify the caller at all, `403` means we identified the key and it is not allowed in. The fix for `401` is the key itself ([rotate or check it](/docs/platform/api-keys)); the fix for `403` is the key's access. ## Which errors to retry A skeptical integrator's first question about any error table is the one it usually leaves unanswered: which of these do I retry? The grouping above is the answer. - **4xx – do not blind-retry.** A `400`, `401`, `403`, or `404` describes a condition in *your* request. Retrying the identical request reproduces the identical error. Fix the input, the key, or the resource id, then send it again. - **402 and 429 – back off, then resolve the limit.** These are not transient at the request level; the next request hits the same wall until the underlying limit moves. Stop retrying in a tight loop, surface the limit, and resolve it (top up, or upgrade the plan). - **500 – retry with backoff.** This is the one class that is genuinely transient. A `500` can mean every configured model timed out on that call; a retry may land on a healthy model. Use exponential backoff and a retry ceiling. {% callout type="info" title="The async API reports outcomes differently" %} This retry policy is for *synchronous* calls you make yourself. The [async localization API](/docs/api/localization) does not hand you a status code for the outcome of the work: a `POST` returns `202` once the request is accepted, and each target locale runs as an independent job via durable background workflows. You poll the job or receive a webhook for the result instead of catching a status code on your original call. See [where async job errors live](#where-async-job-errors-live). {% /callout %} ## 402 vs 429: two different limits The two account-level codes read alike – both sound like "you ran out" – and conflating them sends a developer to the wrong fix. They are distinct limits with distinct resolutions: - **`402 Payment Required`** – the organization has hit its **credit limit**. This is a billing boundary. The next call keeps failing until your organization's billing state changes. - **`429 Too Many Requests`** – the organization has hit its **daily token quota**. This is a usage ceiling that resets, and you raise it by upgrading the plan. The reason to keep them separate in your handler: a `402` is a billing action a person takes; a `429` is a quota you either wait out or lift by upgrading. Routing both to a generic "payment problem" message hides which lever the operator actually needs to pull. A 402 body looks like every other error – the status code is what tells you it is a credit limit: ```json { "message": "Organization has reached its credit limit" } ``` ## Where async job errors live There is a line worth drawing, because it is where a status-code handler stops being the right tool. The status codes on this page are **transport-level**: they describe whether the API accepted and could serve your HTTP request. A `202` from the async API means your request was accepted – not that the translation succeeded. An asynchronous job can be accepted cleanly and still fail later, when a model times out mid-run. That failure is not an HTTP status code on your original call; it is captured on the job itself. So async failures surface in three places, none of them this table: - **Per-job status.** A failed locale carries `status: "failed"` and an `errorMessage` on the job. See [job statuses](/docs/api/localization/jobs). - **Group status.** When some locales succeed and others fail, the group reports `partial` – the succeeded locales still ship. See [tracking a job group](/docs/api/localization/job-groups). - **Webhook delivery.** A failure is delivered as a `translation.failed` event with an `error` field. See [webhook delivery](/docs/api/localization/webhooks). There is one more distinction that catches people: a non-critical [pipeline](/docs/api/pipeline) stage failing does **not** fail the job. The job completes with `completed_with_warnings` and per-step warnings instead of an error. That is a pipeline observability concern, not an error code – see [observe pipeline runs](/docs/api/pipeline/observability). ## Next steps A clean error handler starts with the two codes you will see first while integrating – authentication, and the points where async work reports its own outcome. {% card-grid %} {% link-card title="Authentication" href="/docs/api/authentication" icon="shield" description="Fix 401 and 403 – how the X-API-Key header and org scoping work" /%} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="gear" description="Rotate or re-issue a key when you hit a 401" /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Where a partial async failure surfaces – succeeded locales still ship" /%} {% /card-grid %} - [Authentication](https://lingo.dev/en/docs/api/authentication): Every Lingo.dev API request carries one header, X-API-Key. Keys are organization-scoped and shown once at creation – here is how to send one and where to keep it. Every request to the API has to prove who is asking and which organization's engines it may reach. Lingo.dev does that with one header on every request: `X-API-Key`. There is no token exchange, no session, no OAuth dance to script around – you attach the same header to a sync `localize` call and to an async job submission alike. That simplicity has a flip side worth knowing before your first call: the key is **organization-scoped** and shown **once**. This page covers what that header looks like, what the key can reach, and where to keep it. For what the API returns when the header is wrong, see [Errors and status codes](/docs/api/errors). {% callout type="info" title="New to the API?" %} Start with the [Overview](/docs/api) for the base URL and the engine mental model. This page assumes you have an API key from the dashboard and only need to send it. {% /callout %} ## The header Send your key in the `X-API-Key` header on every request: ```bash X-API-Key: your_api_key ``` In context – the same header rides every endpoint, sync or async: ```bash curl https://api.lingo.dev/jobs/localization \ -H "X-API-Key: your_api_key" \ -H "Content-Type: application/json" \ -d '{ "sourceLocale": "en", "targetLocales": ["de", "ja"], "data": { "greeting": "Welcome aboard" } }' ``` A correct key returns the endpoint's normal response – here, a `202` with a `groupId`. A missing or invalid header returns `401`; that response, and the rest of the codes, live on the [Errors](/docs/api/errors) page. ## What one key reaches A key belongs to an organization, not to a single engine. One key reaches **every** localization engine in that organization, so you do not mint a separate credential per engine – the same `X-API-Key` works whether you target your marketing engine or your docs engine, and you can leave `engineId` off to hit the org default. That reach is convenient, and it is also the thing to weigh: a leaked key reaches everything the organization can. So treat it like any production secret. Load it from an environment variable or a secrets manager, never commit it, and keep it server-side – the key authenticates calls made from your backend, not from a browser where anyone can read it. The same rule covers the realtime [WebSocket](/docs/api/localization/realtime) surface: it authenticates with the same key, so you open those connections server-side too. Generate and manage keys in the [API Keys](/docs/platform/api-keys) section of the dashboard. ## Stored once, or not at all The key is displayed a single time, at the moment you create it. After you close that dialog it cannot be retrieved. {% callout type="warning" title="Copy your key before you leave the page" %} API keys are shown only once at creation. Store the key in your secrets manager or environment the moment it appears – it cannot be recovered afterward. If a key is lost or you suspect it leaked, generate a new one in the [API Keys](/docs/platform/api-keys) dashboard. {% /callout %} This is the one place the easy path and the safe path point the same way: storing the key properly at creation is both the fastest thing to do and the only thing that keeps a production integration from getting stuck behind a credential nobody can read back. ## Next steps You can now authenticate any call. Two things pair naturally with it: knowing what comes back when a request is rejected, and seeing the endpoints that header unlocks. {% card-grid %} {% link-card title="Errors and status codes" href="/docs/api/errors" icon="shield" description="What 401, 403, and the rest mean – and how to handle them" /%} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="gear" description="Generate, name, and manage keys for your organization" /%} {% link-card title="Overview" href="/docs/api" icon="book" description="The base URL, the engine model, and the two API modes" /%} {% /card-grid %} ## Docs – Api/localization - [Create localization jobs](https://lingo.dev/en/docs/api/localization/create): POST /jobs/localization to translate one payload into up to 100 locales in a single request. Returns 202 with a group ID and one job per locale; pass an idempotency key so a retry returns the existing group instead of creating a second one. Create a localization job group: one request that fans your content out to every target locale you name. You have a payload of strings and a list of locales, and you want to translate them all without writing the fan-out yourself. `POST /jobs/localization` takes the whole payload and up to 100 target locales in a single request, then returns `202 Accepted` right away with a group ID and one job per locale. One request, every locale – the platform creates the jobs and processes each one independently. ``` POST /jobs/localization ``` This page covers the create call: its parameters, the request shape, the `202` response, and how to make the call safe to retry. New to async localization? Start with the [Async Localization API overview](/docs/api/localization) for the mental model. Once a group exists, [tracking a job group](/docs/api/localization/job-groups) tells you what each locale's status means. {% callout type="info" title="Authentication" %} Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the organization. See [Authentication](/docs/api/authentication) for details. {% /callout %} ## Parameters `sourceLocale`, `targetLocales`, and `data` are required. Everything else tunes behavior or makes the call safer to repeat. | Parameter | Type | Description | | --- | --- | --- | | `sourceLocale` | string | BCP-47 source locale (e.g. `en`). | | `targetLocales` | string[] | BCP-47 target locales (e.g. `["de", "fr", "ja"]`). 1–100 per request. One job is created per locale. | | `data` | object | Key-value content to translate. Nested objects and arrays are allowed at any depth. | | `context` | string (optional) | Broad context for this translation payload, such as the product surface, audience, or purpose. Applies to every job created for the request. | | `hints` | object (optional) | Per-key context as arrays of breadcrumb strings, to disambiguate short or reused strings. | | `callbackUrl` | string (optional) | HTTPS webhook URL for this group. Overrides the organization default. HTTP is rejected. | | `idempotencyKey` | string (optional) | Client-generated key. Send the same request twice with the same key and the existing group is returned instead of a new one. Scoped per engine. | | `engineId` | string (optional) | Localization engine to run the jobs through. Falls back to the organization's default engine when omitted. | | `pipelineConfig` | object (optional) | Per-request [pipeline](/docs/api/pipeline/configure) overrides. Stages you omit inherit from the engine config. | | `lockedKeys` | string[] (optional) | Keys or glob patterns whose values are excluded from translation and merged back verbatim into `outputData`. Up to 100 patterns. See [Lock non-translatable keys](/docs/api/localization/locked-keys). | ## Request The `data` field accepts flat key-value pairs or nested structures with objects and arrays at any depth. The engine translates every string value, preserves non-string values (numbers, booleans, `null`) untouched, and returns the exact shape you sent. So you can hand it the same object your app already stores – no flattening, no reshaping. {% tabs %} {% tab label="Flat data" %} ```json { "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "lesson_title": "Introduction to Machine Learning", "lesson_summary": "This lesson covers the fundamentals of ML, including supervised and unsupervised learning." }, "callbackUrl": "https://your-app.com/webhooks/translations", "idempotencyKey": "course_101-v3" } ``` {% /tab %} {% tab label="Nested data" %} ```json { "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "id": "course_101", "title": "Introduction to Machine Learning", "steps": [ { "heading": "What is ML?", "body": "Machine learning is a subset of artificial intelligence." }, { "heading": "Supervised Learning", "body": "Training a model with labeled data." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } }, "callbackUrl": "https://your-app.com/webhooks/translations" } ``` {% /tab %} {% /tabs %} {% callout type="warning" title="HTTPS required" %} The `callbackUrl` must use HTTPS. HTTP URLs are rejected with a `400` error. {% /callout %} That nested payload mixes translatable text with values that must survive untouched – `id`, `course_101`, `difficulty`. Strings are translated; the rest is preserved by type. When you need a *string* held back too (a slug, an asset URL, an enum code), name it in [`lockedKeys`](/docs/api/localization/locked-keys) and it is merged back verbatim into every locale's output. ## Response (202 Accepted) The call returns immediately. It does not wait for translation – it hands you the group ID and the per-locale job IDs, then the platform processes each job independently in the background. ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "pending", "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "queued" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "queued" }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued" } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` | Field | Description | | --- | --- | | `groupId` | `ljg_`-prefixed identifier for the whole group. Store this – it is the handle for [tracking](/docs/api/localization/job-groups) and live progress. | | `status` | Group status at creation, normally `pending`. | | `jobs` | One entry per target locale: `id` (`ljb_`-prefixed), `targetLocale`, and the job's `status`. | | `createdAt` | ISO 8601 timestamp. | Three locales in, three jobs back, each `queued` and ready to run. What each status means as the jobs progress – and what happens when one locale fails while the others ship – lives on [Track a job group](/docs/api/localization/job-groups). ## Examples The same request from Node and Python. Both fire one POST and read the group ID and job count straight off the `202`. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch("https://api.lingo.dev/jobs/localization", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ sourceLocale: "en", targetLocales: ["de", "fr", "ja"], data: { title: "Introduction to Machine Learning", steps: [ { heading: "What is ML?", body: "Machine learning is a subset of AI." }, { heading: "Supervised Learning", body: "Training with labeled data." }, ], }, callbackUrl: "https://your-app.com/webhooks/translations", }), }); const { groupId, jobs } = await response.json(); // 202 Accepted – the call returns without waiting for translation. console.log(groupId); // "ljg_A1b2C3d4E5f6G7h8" console.log(jobs.length); // 3 – one queued job per target locale ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/jobs/localization", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "sourceLocale": "en", "targetLocales": ["de", "fr", "ja"], "data": { "title": "Introduction to Machine Learning", "steps": [ {"heading": "What is ML?", "body": "Machine learning is a subset of AI."}, {"heading": "Supervised Learning", "body": "Training with labeled data."}, ], }, "callbackUrl": "https://your-app.com/webhooks/translations", }, ) result = response.json() # 202 Accepted – the call returns without waiting for translation. print(result["groupId"]) # "ljg_A1b2C3d4E5f6G7h8" print(len(result["jobs"])) # 3 – one queued job per target locale ``` {% /tab %} {% /tabs %} ## Make the call safe to retry The natural place to fire this request is a save hook or an event handler – exactly the code that runs twice when a retry fires or a duplicate event arrives. Without protection, two calls mean two job groups, and the same content is queued for translation twice. Pass an `idempotencyKey` and that stops being a risk. Send the same request twice with the same key and the platform returns the *existing* group instead of creating a new one – no second set of jobs. Keys are scoped per engine, so the same key against a different engine is a different group. {% callout type="info" title="Pick a key that means something" %} A good key combines content identity with version: `{contentId}-v{contentVersion}`. The same content at the same version always resolves to the same group, so a retry is automatically a no-op. Bump the version when the content changes and you get a fresh group. {% /callout %} ```javascript const key = `${content.id}-v${content.version}`; async function submit() { const response = await fetch("https://api.lingo.dev/jobs/localization", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ sourceLocale: "en", targetLocales: ["de", "fr", "ja", "ko", "pt-BR"], data: { title: content.title, steps: content.steps }, callbackUrl: "https://your-app.com/webhooks/translations", idempotencyKey: key, }), }); return (await response.json()).groupId; } const first = await submit(); const again = await submit(); // same key – duplicate submission console.log(first === again); // true – same group returned, no second set of jobs ``` This is the one POST that fans a payload out to every locale, and it is safe to fire from the same code path that retries. Store the `groupId`; that is what you carry into tracking and live progress. ## Next steps {% card-grid %} {% link-card title="Lock non-translatable keys" href="/docs/api/localization/locked-keys" icon="lightning" description="Hold IDs, slugs, asset URLs, and enum codes back from translation with key and glob patterns." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Override pipeline stages per request, or set engine-level defaults that every job inherits." /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Read group and per-locale status, and handle the case where one locale fails while the rest ship." /%} {% /card-grid %} - [Live progress over WebSocket](https://lingo.dev/en/docs/api/localization/realtime): Stream per-locale localization progress into your UI over a WebSocket where every message carries the full group state – so you render the snapshot instead of reconciling deltas, and never miss an event. You created a job group. Somewhere a user is watching a spinner, and "translating into 14 languages…" is true but useless – it never moves. You want the count to climb in front of them: 3 ready, then 4, then a locale that failed, then done. Polling the [job group](/docs/api/localization/job-groups) gets you there, but it is chatty, and each poll hands you a fresh snapshot you have to diff against the last one to know what actually changed. The WebSocket inverts that. Connect once and the server pushes an event every time a locale resolves – and **every message carries the full group state**, so you render the snapshot, you never reconcile a delta. Drop a frame, reconnect, restart the tab: the next message is the whole truth again. ``` GET /jobs/localization/groups/:groupId/ws ``` New to async localization? Start with the [Overview](/docs/api/localization). The `groupId` here is the one you got back when you [created the jobs](/docs/api/localization/create). **On this page** - [Message types](#message-types) - [Message payloads](#message-payloads) - [Wiring it into your UI](#wiring-it-into-your-ui) - [Keep your API key server-side](#keep-your-api-key-server-side) ## Message types Four message types travel over the socket. Each one tells you what just happened and hands you the current state of the whole group alongside it. | Type | When | Key fields | | --- | --- | --- | | `snapshot` | On initial connection | Full group state | | `job.completed` | A locale finishes successfully | `jobId`, `locale`, plus full group state | | `job.failed` | A locale fails | `jobId`, `locale`, `error`, plus full group state | | `group.completed` | Every job has resolved | `groupId`, `status`, plus full group state. The server closes the connection after this message. | Every message contains a `snapshot` object with the current group state: `totalJobs`, `completedJobs`, `completedWithWarningsJobs`, `failedJobs`, and a `jobs` map keyed by job ID, each with its `locale` and `status`. Those counts are the same ones the [job group endpoint](/docs/api/localization/job-groups) reports – so a snapshot off the socket and a poll off the REST endpoint agree on how far the group has progressed. {% callout type="info" title="render the snapshot, never reconcile" %} You never need to track which events you have already seen, replay missed messages, or merge a partial update into local state. Read `snapshot` on every message and paint your UI from it. A reconnect re-sends `snapshot` first, so a client that just joined and a client that has been listening the whole time converge on the same state. {% /callout %} ## Message payloads These are the exact frames the server sends. The IDs are real shapes (`ljg_` for the group, `ljb_` for each job); the `snapshot` is abbreviated with `"..."` only where it repeats the structure already shown. On connect, the server sends the current state: ```json { "type": "snapshot", "snapshot": { "groupId": "ljg_A1b2C3d4E5f6G7h8", "totalJobs": 3, "completedJobs": 1, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": { "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" }, "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" }, "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" } } } } ``` As each locale finishes, the event names the locale that changed and includes the updated snapshot: ```json { "type": "job.completed", "jobId": "ljb_B2c3D4e5F6g7H8i9", "locale": "fr", "snapshot": { "groupId": "ljg_A1b2C3d4E5f6G7h8", "totalJobs": 3, "completedJobs": 2, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": { "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" }, "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" }, "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" } } } } ``` A failure is a normal message, not a dropped connection. `job.failed` carries the locale and an `error`, and the same full snapshot – the failed locale shows `status: "failed"` in the `jobs` map, every other locale keeps streaming, and the socket runs on to `group.completed`: ```json { "type": "job.failed", "jobId": "ljb_C3d4E5f6G7h8I9j0", "locale": "ja", "error": "Model timeout after 30 seconds", "snapshot": { "...": "..." } } ``` When every job has resolved, the server sends a final event and closes the connection: ```json { "type": "group.completed", "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "completed", "snapshot": { "...": "..." } } ``` The terminal `status` is `completed` when every locale succeeded, `completed_with_warnings` when every locale produced output but one or more optional [pipeline](/docs/api/pipeline) stages failed on at least one of them, `partial` when some locales succeeded and some failed, and `failed` when all of them failed. For what each of those means for the group as a whole, see [Track a job group](/docs/api/localization/job-groups). {% callout type="info" title="Render from snapshot on anything you do not recognize" %} Switch on the message types you know, and fall through to re-rendering from `snapshot` on anything you do not recognize. Every message carries a full snapshot, so a client that defaults to painting from it stays correct even on a frame it has no specific branch for. {% /callout %} ## Wiring it into your UI The group is your progress model. When you [created the jobs](/docs/api/localization/create), the 202 handed you a `groupId` and a `jobs` array – one entry per locale. Seed your progress record from that response and you have the shape the socket will fill in: the total to count toward, and a counter starting at zero. ```javascript const { groupId, jobs } = await response.json(); await db.translationProgress.create({ contentId: content.id, groupId, totalLanguages: jobs.length, completedLanguages: 0, }); ``` Then open the socket against that `groupId`, and on every message read `snapshot` and repaint. Watch the counter climb as locales land, and stop when `group.completed` arrives: ```javascript import WebSocket from "ws"; const groupId = "ljg_A1b2C3d4E5f6G7h8"; const ws = new WebSocket( `wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`, { headers: { "X-API-Key": process.env.LINGO_API_KEY } } ); ws.on("message", (raw) => { const event = JSON.parse(raw); const { snapshot } = event; switch (event.type) { case "snapshot": console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`); break; case "job.completed": console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`); break; case "job.failed": console.error(`${event.locale} failed: ${event.error}`); break; case "group.completed": console.log(`All translations done: ${event.status}`); ws.close(); break; } }); ``` Running against a three-locale group, that prints the run as it happens: ``` 1/3 complete fr ready (2/3) ja failed: Model timeout after 30 seconds All translations done: partial ``` The counter moved on its own, one locale failed without taking the stream down, and `partial` told you where the run landed – exactly what your spinner needs to become a real progress bar. Notice the loop never accumulates state: each branch reads from the `snapshot` on the message in hand, so the same code is correct on first connect, on every update, and on reconnect. ## Keep your API key server-side The socket authenticates with your API key, the same [organization-scoped key](/docs/platform/api-keys) the REST endpoints use. That means the browser is the wrong place to open it – an API key in client JavaScript reaches every engine in your organization, for anyone who views source. {% callout type="warning" title="Connect from your backend, not the browser" %} Open the WebSocket from your server, where the key already lives, then fan the events out to the browser over your own channel – a WebSocket or server-sent events stream you control. Your frontend gets live progress; your key never leaves your infrastructure. {% /callout %} This mirrors the [webhook](/docs/api/localization/webhooks) model: the connection that touches Lingo.dev is server-side, and what reaches the user is whatever your own app chooses to forward. ## Where this fits The WebSocket is the live view – it is bound to one group and closes when that group is done. For durable, server-to-server delivery that survives a tab closing or a deploy, pair it with [webhooks](/docs/api/localization/webhooks): the socket drives the UI while the run is on screen, the webhook records each result the moment it lands. Wire both from the same [create call](/docs/api/localization/create) and your users see progress as it happens while your backend keeps the output regardless of who is watching. {% card-grid %} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Durable server-to-server delivery of each locale as it completes" /%} {% link-card title="Create jobs" href="/docs/api/localization/create" icon="lightning" description="Submit content for translation and get the groupId you connect to here" /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Group statuses and what partial completion means for the group" /%} {% /card-grid %} - [Webhook delivery](https://lingo.dev/en/docs/api/localization/webhooks): When an async localization job finishes, Lingo POSTs the result to your callbackUrl – one request per locale, translation.completed with the data or translation.failed with the error. Return 200 first, process after. You [created a job group](/docs/api/localization/create) and got a 202 back in milliseconds. The translations are now running in the background, one job per locale. You could [poll each job](/docs/api/localization/jobs) until it finishes – but you'd rather not run a polling loop just to learn that German is ready. You want your server told the moment each locale lands. That is what the webhook does. When you pass a `callbackUrl` while creating jobs, Lingo POSTs the result to that URL as each job reaches a terminal state – **one POST per locale, the moment it lands.** A locale that translates cleanly arrives as `translation.completed` with the data. A locale that fails arrives as `translation.failed` with the error. You are told either way, per language, without asking. This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with provisioning and lives on the [webhook signature verification](/docs/api/webhooks) page, linked at each point you'll need it. **On this page** - [How delivery works](#how-delivery-works) - [The completed payload](#the-completed-payload) - [The failed payload](#the-failed-payload) - [Handling a webhook](#handling-a-webhook) - [When delivery is the wrong tool](#when-delivery-is-the-wrong-tool) ## How delivery works Each locale in a group is an independent job. The instant one reaches a terminal state, its result is delivered to your `callbackUrl` on its own – Lingo does not wait for the slowest locale, and does not batch the group into a single call. Fourteen target locales means up to fourteen POSTs, arriving as each language finishes, in whatever order they finish. Set the destination per request with `callbackUrl` when you [create the job group](/docs/api/localization/create), or set an organization default in the dashboard that every group inherits. A per-request `callbackUrl` overrides the org default for that group. {% callout type="warning" title="HTTPS only" %} `callbackUrl` must use HTTPS. An HTTP URL is rejected with a 400 when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point. {% /callout %} Two payload shapes cross the wire, distinguished by their `type` field: `translation.completed` and `translation.failed`. Both name the job and group they belong to and the locale they carry, so a single handler can route on `type` and update the right record. {% callout type="info" title="Handle unknown event types gracefully" %} Today the wire carries `translation.completed` and `translation.failed`. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler. {% /callout %} ## The completed payload When a job finishes successfully, the payload carries the translated `data` – the same shape you'd get from [fetching the job](/docs/api/localization/jobs), pushed to you instead of polled. The `data` mirrors the structure you submitted: every string translated, every non-string value (numbers, booleans, `null`) preserved, nesting intact. ```json { "type": "translation.completed", "jobId": "ljb_A1b2C3d4E5f6G7h8", "groupId": "ljg_A1b2C3d4E5f6G7h8", "sourceLocale": "en", "targetLocale": "de", "data": { "id": "course_101", "title": "Einführung in maschinelles Lernen", "steps": [ { "heading": "Was ist ML?", "body": "Maschinelles Lernen ist ein Teilbereich der künstlichen Intelligenz." }, { "heading": "Überwachtes Lernen", "body": "Trainieren eines Modells mit gelabelten Daten." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } } } ``` | Field | Description | | --- | --- | | `type` | `translation.completed` | | `jobId` | The job that finished (`ljb_` prefix) | | `groupId` | The group it belongs to (`ljg_` prefix) | | `sourceLocale` | The source locale you submitted | | `targetLocale` | The locale this payload was translated into | | `data` | Translated content, matching the structure of the `data` you submitted | A job that produces output is not a failure – so a job that finished as `completed_with_warnings` (output produced, but an optional [pipeline](/docs/api/pipeline) stage fell through) is delivered as `translation.completed`, with usable `data`. The webhook tells you the locale is ready; the per-step warnings that explain the fall-through live on the [single job](/docs/api/localization/jobs), which you fetch by `jobId` when you want them. ## The failed payload A locale can fail – a model can time out, every configured model can be unavailable. When a job reaches `failed`, you are still told. The payload type is `translation.failed`, and it carries an `error` string in place of `data`: ```json { "type": "translation.failed", "jobId": "ljb_C3d4E5f6G7h8I9j0", "groupId": "ljg_A1b2C3d4E5f6G7h8", "sourceLocale": "en", "targetLocale": "ja", "error": "Model timeout after 30 seconds" } ``` | Field | Description | | --- | --- | | `type` | `translation.failed` | | `jobId` | The job that failed | | `groupId` | The group it belongs to | | `sourceLocale` | The source locale you submitted | | `targetLocale` | The locale that failed | | `error` | Human-readable failure description | The failure is scoped to one locale. If you submitted `de`, `fr`, and `ja`, a `ja` failure is delivered as its own `translation.failed` POST while `de` and `fr` arrive as `translation.completed` – the German and French translations ship regardless. The group's [partial-failure status](/docs/api/localization/job-groups) reflects the mix. To recover the failed locale, submit a new job for just that locale with a fresh idempotency key. ## Handling a webhook A skeptical reader's first thought here is the right one: *my handler does real work – a database write, a cache bust, a fan-out to connected clients – so won't that hold the connection open long enough to time the webhook out?* It would, so don't make Lingo wait for it. **Return 200 first, then process.** Acknowledge receipt immediately and do the real work after the response is sent. A handler that returns promptly keeps delivery healthy; a handler that blocks on downstream work invites a retry it didn't need. ```javascript app.post("/webhooks/translations", verifyWebhook, async (req, res) => { // Acknowledge first - one POST per locale, the moment it lands. res.status(200).send("ok"); const { type, jobId, groupId, targetLocale, data } = req.body; if (type === "translation.completed") { await db.content.update({ where: { groupId }, data: { [`content_${targetLocale}`]: data }, }); // Advance your own progress model - your UI can poll this or receive it over SSE. await db.translationProgress.increment({ where: { groupId }, data: { completedLanguages: { increment: 1 } }, }); } if (type === "translation.failed") { console.error(`Translation failed: ${jobId} (${targetLocale})`, req.body.error); } }); ``` The `verifyWebhook` middleware is the one piece this page doesn't define. Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so it isn't a scheme you have to reverse-engineer. How you verify it – and the retry schedule behind a non-2xx response – is documented in full on [webhook signature verification](/docs/api/webhooks), shared with provisioning. Wire that middleware in before you trust a payload: an unverified body is an unauthenticated one. {% callout type="warning" title="Verify before you trust the body" %} Your endpoint is a public URL; anyone can POST to it. Verify the signature against the raw request body before acting on any payload. The how – headers, the HMAC, the `whsec_` secret – is on the [signature verification](/docs/api/webhooks) page. {% /callout %} ## When delivery is the wrong tool The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away. If your endpoint was down when a result was delivered, the platform retries – and if every retry is exhausted, the result isn't lost. It stays [retrievable by `jobId`](/docs/api/localization/jobs); the job's `callbackStatus` records whether the push ultimately succeeded. The retry schedule itself is on the [signature and delivery](/docs/api/webhooks) page. The webhook saves you a polling loop in the common case; the job record is always there underneath it in the uncommon one. And if what you want is live progress in a UI – a counter ticking from 3 of 14 to 4 of 14 as locales land, rather than a per-locale callback to your server – that is the job-group WebSocket, not the webhook. {% card-grid %} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream group progress to a UI with full-state snapshots, instead of per-locale callbacks to your server." /%} {% link-card title="Webhook signature verification" href="/docs/api/webhooks" icon="shield" description="Verify the signature, read the headers, and handle the retry schedule – shared across all webhook deliveries." /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Fetch any result by jobId, including warnings – the source of truth behind every delivery." /%} {% /card-grid %} - [List jobs](https://lingo.dev/en/docs/api/localization/list): Page through historical localization jobs with GET /jobs/localization – cursor pagination plus engineId and status filters, newest first, nextCursor null on the last page. Webhooks and the [live WebSocket](/docs/api/localization/realtime) tell you about a job the moment it resolves. But neither helps the next morning, after a deploy, or when you want every locale that failed in the last hour. The moment passed; the event is gone. The jobs are not – each one is a durable record on the platform, long after the process that submitted it has moved on. `GET /jobs/localization` is how you reach back for those records. It returns your jobs newest first, in pages you walk with a cursor, narrowed by the engine they ran on or the status they ended in. This is the catch-up channel: the durable record you query when you weren't listening live. ``` GET /jobs/localization ``` New to async localization? Start with the [Overview](/docs/api/localization). This page assumes you already have jobs to look through. Like every endpoint, it authenticates with your [`X-API-Key`](/docs/api/authentication). ## Filters and pagination ``` GET /jobs/localization?engineId=eng_abc123&status=completed&limit=20&cursor=... ``` | Parameter | Type | Description | | --- | --- | --- | | `engineId` | string (optional) | Return only jobs that ran on this localization engine (`eng_...`). | | `status` | string (optional) | Return only jobs in this state: `queued`, `processing`, `completed`, `completed_with_warnings`, or `failed`. | | `limit` | number (optional) | Page size. Default 20, maximum 100. | | `cursor` | string (optional) | Opaque cursor from the previous response's `nextCursor`. Omit it for the first page. | Both filters are optional and combine: `engineId=eng_abc123&status=failed` returns the failed jobs for one engine and nothing else. That combination answers a question you will actually ask in an incident – _show me everything that failed on this engine_ – without pulling back every job in the organization to filter client-side. The `cursor` is a position in the result stream, not a page number. You don't compute it; you receive it. Each response hands you a `nextCursor`, and you pass that value back to fetch the page after it. ## Response Each page is an `items` array plus a `nextCursor`. **`nextCursor` is `null` on the last page** – that is your loop's exit condition, not an error. ```json { "items": [ { "id": "ljb_C3d4E5f6G7h8I9j0", "groupId": "ljg_A1b2C3d4E5f6G7h8", "targetLocale": "ja", "status": "completed", "warnings": [], "createdAt": "2026-03-16T10:30:00.000Z", "completedAt": "2026-03-16T10:30:06.000Z" } ], "nextCursor": "eyJ0IjoiMjAyNi0wMy0xNlQxMDozMDowMC4wMDBaIiwiaSI6ImxqYl9CMmMzRDRlNUY2ZzdIOGk5In0" } ``` Each item is a summary – enough to locate a job and read its outcome: which locale, which group, what status, when it was created and finished. It deliberately does not carry the translated output. To pull the full `outputData` and per-stage `steps` for one of these jobs, take its `id` and call [Get a single job](/docs/api/localization/jobs). List to find; fetch to read. {% callout type="info" title="Handle unknown status values gracefully" %} Match on the status values you know and fall through to a default branch for the rest, rather than crashing the consumer on a value it hasn't seen. Tolerating an unrecognized value is the defensive default for any string enum you don't own – it keeps your reader running instead of throwing on input it can't classify. {% /callout %} ## Page through every result The exit condition is the whole point: keep requesting until `nextCursor` comes back `null`. Pass the `nextCursor` from one response as the `cursor` of the next, and the loop terminates on its own. {% tabs %} {% tab label="Node.js" %} ```javascript async function listFailedJobs(engineId) { const failed = []; let cursor = undefined; // first page: no cursor do { const url = new URL("https://api.lingo.dev/jobs/localization"); url.searchParams.set("engineId", engineId); url.searchParams.set("status", "failed"); url.searchParams.set("limit", "100"); // fewer round-trips if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { "X-API-Key": process.env.LINGO_API_KEY }, }); const { items, nextCursor } = await response.json(); failed.push(...items); cursor = nextCursor; // null on the last page -> loop ends } while (cursor); return failed; // every failed job for this engine } ``` {% /tab %} {% tab label="Python" %} ```python import requests def list_failed_jobs(engine_id: str) -> list: failed = [] cursor = None # first page: no cursor while True: params = {"engineId": engine_id, "status": "failed", "limit": 100} if cursor: params["cursor"] = cursor response = requests.get( "https://api.lingo.dev/jobs/localization", headers={"X-API-Key": "your_api_key"}, params=params, ) body = response.json() failed.extend(body["items"]) cursor = body["nextCursor"] # None on the last page if cursor is None: # loop ends on its own break return failed # every failed job for this engine ``` {% /tab %} {% /tabs %} Raising `limit` to 100 cuts the number of round-trips for a large backlog; it does not change the result, only how many pages you walk to read it. There is no offset to drift and no page count to keep in sync – the cursor carries your place, and `null` tells you when you've read everything. ## Next steps You have the `id` of a job. The catch-up channel got you here; from here you read the result, or wire up the live channels so next time you hear it as it happens. {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Take an id from the list and pull its full outputData and per-stage steps." /%} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Know the group? Fetch it directly with rolled-up per-locale counts." /%} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Get each result POSTed to you the moment a locale completes." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream status as it happens, no polling - the as-it-happens channel." /%} {% /card-grid %} - [Get a single job](https://lingo.dev/en/docs/api/localization/jobs): GET /jobs/localization/:jobId returns one locale's translated outputData plus the per-stage record of how it was produced, with explicit job-status values for your code to branch on. Read one locale's translated output and the per-stage record of how it was produced. You reach for this once you hold a `jobId` – returned in the [202 from create](/docs/api/localization/create), carried in a [webhook](/docs/api/localization/webhooks), or listed under a [job group](/docs/api/localization/job-groups). The group endpoint tells you _how many_ locales are done. This endpoint tells you _what_ a single locale produced, and what happened along the way. ``` GET /jobs/localization/:jobId ``` {% inline-callout %} New to async localization? Start with the [Overview](/docs/api/localization). {% /inline-callout %} That distinction is the whole job of this page. A group response is a scoreboard – counts and per-job status, [covered on the job-group page](/docs/api/localization/job-groups). A single job is **the full record of one locale**: the translated `outputData`, the terminal `status`, any `warnings`, and a `steps[]` trail of every stage the [pipeline](/docs/api/pipeline) ran. When you're ready to write the German copy into your database, this is the call that hands you the German copy. ## Authentication Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the org. See [Authentication](/docs/api/authentication) for details. ## Response The `outputData` field mirrors the structure of the input `data`, with every string value translated and every non-string value (numbers, booleans, `null`) preserved in place. Same keys, same nesting, same array order – only the strings change. ```json { "id": "ljb_A1b2C3d4E5f6G7h8", "groupId": "ljg_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "outputData": { "id": "course_101", "title": "Einführung in maschinelles Lernen", "steps": [ { "heading": "Was ist ML?", "body": "Maschinelles Lernen ist ein Teilbereich der künstlichen Intelligenz." }, { "heading": "Überwachtes Lernen", "body": "Trainieren eines Modells mit gelabelten Daten." } ], "metadata": { "author": "Dr. Smith", "difficulty": "beginner" } }, "errorMessage": null, "warnings": [], "callbackStatus": "delivered", "createdAt": "2026-03-16T10:30:00.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:04.000Z", "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:01.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:04.000Z" } ] } ``` The `metadata` block above survived untouched – `Dr. Smith` and `beginner` are non-string leaves the engine left alone. The `outputData` you read back fits the shape you sent, so the same code that built the payload can consume the translation. | Field | Description | | --- | --- | | `id` | This job's own ID (`ljb_…`). The value you passed in the path. | | `groupId` | The parent job group (`ljg_…`) this job belongs to. Pass it to [the job-group endpoint](/docs/api/localization/job-groups) to see every sibling locale at once. | | `targetLocale` | The BCP-47 locale this job translated into – one job exists per target locale. This is the field you branch on to route `outputData` to the right column or file. | | `status` | `queued`, `processing`, `completed`, `completed_with_warnings`, or `failed`. | | `outputData` | Translated content matching the input structure. Present when `status` is `completed` or `completed_with_warnings`. | | `errorMessage` | Error description. Present when `status` is `failed`, otherwise `null`. | | `warnings` | Non-critical [pipeline](/docs/api/pipeline) stage failures. Each entry is `{ step, message }`. Empty unless `status` is `completed_with_warnings`. | | `callbackStatus` | Webhook delivery state: `pending`, `delivered`, or `failed`. `null` if no callback URL is configured. | | `createdAt` | When the job was accepted (the timestamp on the `202` that created it). | | `startedAt` | When the engine began translating this locale. Set once the job leaves `queued`. | | `completedAt` | When the job reached a terminal state. Set once `status` is `completed`, `completed_with_warnings`, or `failed`. | | `steps` | Per-stage execution records. Always contains the `localize` step, plus one entry per enabled optional pipeline stage. Full record shape in [Observe pipeline runs](/docs/api/pipeline/observability). | {% callout type="info" title="outputData is null until the job finishes" %} While `status` is `queued` or `processing`, `outputData` is empty and `errorMessage` is `null` – there is nothing to read yet. Read `outputData` only after `status` reaches `completed` or `completed_with_warnings`; on `failed`, read `errorMessage` instead. Branch on `status` first, then touch the payload. {% /callout %} ## Job status values A job moves from `queued` to `processing` to exactly one terminal state. Branch on `status` before you read anything else – it tells you which fields are populated. | Status | Meaning | What to read | | --- | --- | --- | | `queued` | Accepted, not yet started. | Nothing yet – poll, or wait for the webhook. | | `processing` | The engine is translating this locale. | Nothing yet. | | `completed` | Translation finished, every enabled stage succeeded. | `outputData`. | | `completed_with_warnings` | Translation finished and `outputData` is complete, but a non-critical pipeline stage fell through. | `outputData`, then `warnings`. | | `failed` | The job did not produce a translation. | `errorMessage`. | {% callout type="info" title="completed_with_warnings still ships a translation" %} `completed_with_warnings` is not a soft failure. You get full `outputData` – the core translate step succeeded. What changed is that a non-critical stage (for example [pre-edit](/docs/api/pipeline/pre-edit) or [back-translation](/docs/api/pipeline/back-translation)) did not complete, and each failure is logged in `warnings` as `{ step, message }`. Treat the output as usable; treat `warnings` as a quality signal worth surfacing to whoever reviews translations. Only `failed` means there is no translation to read. {% /callout %} {% callout type="info" title="Handle unknown status values" %} The five status values above are the contract today. Pipeline stages evolve, so treat `status` as an open set: branch on the values you know and route anything unexpected to a default that reads `outputData` if present and logs otherwise. A `switch` with no fallback is the line that breaks on the day a new state ships. {% /callout %} ## The steps array `steps[]` is the per-stage trail behind a single job – one record for every stage the engine ran, in order. Every job carries at least the `localize` step, because core translation always runs. Each optional [pipeline](/docs/api/pipeline) stage you enabled adds one more record. So a job with no extra stages shows a single `localize` step; a job with pre-edit and back-translation enabled shows three. This is what makes a job auditable rather than a black box. You don't have to trust that a stage ran – you read its record: which stage (`stepId`), whether it `completed`, `failed`, or was `skipped`, what it cost (`costUsd`), and when it started and finished. For human-review stages, `externalRef*` points at the external record. ```json "steps": [ { "stepId": "preEdit", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0012, "createdAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:02.000Z" }, { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "createdAt": "2026-03-16T10:30:02.000Z", "completedAt": "2026-03-16T10:30:05.000Z" } ] ``` A `failed` entry here does not necessarily fail the job. When a non-critical stage fails, its `steps[]` record reads `failed`, the same failure surfaces in the job's top-level `warnings`, and the job still reaches `completed_with_warnings` with full `outputData`. The full record shape – every field, every `stepId`, the completed/failed/skipped semantics – lives on one canonical page: [Observe pipeline runs](/docs/api/pipeline/observability). This page shows you where to find it on a job; that page specifies it. ## Reading a completed job A typical consumer branches on `status`, writes `outputData` on success, and logs `errorMessage` on failure. The copy-paste call below returns the payload shown above. {% tabs %} {% tab label="Node.js" %} ```javascript const jobId = "ljb_A1b2C3d4E5f6G7h8"; const response = await fetch(`https://api.lingo.dev/jobs/localization/${jobId}`, { headers: { "X-API-Key": process.env.LINGO_API_KEY }, }); const job = await response.json(); switch (job.status) { case "completed": case "completed_with_warnings": // outputData is populated; warnings may carry non-critical stage failures await db.content.update({ where: { id: job.outputData.id }, data: { [`content_${job.targetLocale}`]: job.outputData }, }); if (job.warnings.length) console.warn(job.targetLocale, job.warnings); break; case "failed": console.error(`${job.targetLocale} failed: ${job.errorMessage}`); break; default: // queued or processing - nothing to read yet; also catches future states break; } ``` {% /tab %} {% tab label="Python" %} ```python import os, requests job_id = "ljb_A1b2C3d4E5f6G7h8" response = requests.get( f"https://api.lingo.dev/jobs/localization/{job_id}", headers={"X-API-Key": os.environ["LINGO_API_KEY"]}, ) job = response.json() if job["status"] in ("completed", "completed_with_warnings"): # outputData is populated; warnings may carry non-critical stage failures save_translation(job["targetLocale"], job["outputData"]) if job["warnings"]: print(job["targetLocale"], job["warnings"]) elif job["status"] == "failed": print(f"{job['targetLocale']} failed: {job['errorMessage']}") else: # queued or processing - nothing to read yet; also catches future states pass ``` {% /tab %} {% /tabs %} {% callout type="info" title="Polling vs push" %} This endpoint is a point-in-time read. For most jobs the engine takes 2–8 seconds per locale, so if you poll, a 2-second interval is a reasonable start. To avoid polling entirely, register a [webhook](/docs/api/localization/webhooks) and fetch the job only when it tells you the locale is done, or watch the whole group over the [WebSocket](/docs/api/localization/realtime). Either way, a final `GET` here is the canonical read of `outputData`. {% /callout %} When this endpoint returns an error – an unknown `jobId`, a missing key – it follows the standard JSON error model. See [Errors and status codes](/docs/api/errors). ## Next steps {% card-grid %} {% link-card title="Track a job group" href="/docs/api/localization/job-groups" icon="lightning" description="Read aggregate counts and handle partial failures across every locale" /%} {% link-card title="List jobs" href="/docs/api/localization/list" icon="lightning" description="Page through jobs with cursor pagination and filter by status or engine" /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Read per-locale status as each job finishes, without polling" /%} {% /card-grid %} - [Track a job group](https://lingo.dev/en/docs/api/localization/job-groups): Poll one endpoint to read the aggregate status of every locale in a submission – how many are done, how many carry warnings, how many failed. You created a group, got a `groupId` back, and now several locales are translating in parallel. You need one question answered, repeatedly, until the work settles: how is the whole submission doing right now? Not locale by locale – the aggregate. How many are done, how many produced output with warnings, how many failed, how many are still running. That is what this endpoint returns. One poll, every locale's status, in a single response. New to async localization? Start with the [Async Localization API overview](/docs/api/localization). **On this page** - [Get a job group](#get-a-job-group) - [Response](#response) - [Group statuses](#group-statuses) - [How often to poll](#how-often-to-poll) - [When one locale fails](#when-one-locale-fails) ## Get a job group Retrieve the status of a job group and all its child jobs. ``` GET /jobs/localization/groups/:groupId ``` Authenticate with your API key in the `X-API-Key` header, the same key you used to [create the group](/docs/api/localization/create). The `groupId` is the `ljg_`-prefixed id from the 202 response. ## Response The response is a snapshot of the whole submission: the group's own `status`, four counts, and the child jobs with their individual states. This is the object you read on every poll. ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "processing", "sourceLocale": "en", "totalJobs": 3, "completedJobs": 1, "completedWithWarningsJobs": 0, "failedJobs": 0, "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:04.000Z" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "processing", "warnings": [], "completedAt": null }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued", "warnings": [], "completedAt": null } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` The three count fields for terminal jobs – `completedJobs`, `completedWithWarningsJobs`, and `failedJobs` – sum to the number of locales that have finished. The rest of `totalJobs` are still `queued` or `processing`. In the snapshot above, 1 of 3 is done and 2 are still in flight, so a single read of the counts tells you the work isn't settled yet without scanning the `jobs` array. When that sum reaches `totalJobs`, the group has reached a terminal status. Each job's `warnings` array surfaces non-critical [pipeline](/docs/api/pipeline) stage failures – for example a pre-edit or back-translation step that fell through. A non-empty array means the job still produced output, but at least one optional stage did not complete. The translated `outputData` itself lives on the [single job](/docs/api/localization/jobs) – fetch that when you're ready to read a finished locale's content. ## Group statuses The group's `status` rolls up its child jobs into one value. You poll until it reaches a terminal state. | Group status | Meaning | | --- | --- | | `pending` | Group created, no jobs started yet | | `processing` | At least one job is in progress | | `completed` | All jobs completed successfully | | `completed_with_warnings` | All jobs produced output, but one or more optional [pipeline](/docs/api/pipeline) stages failed on at least one job | | `partial` | Some jobs completed, some failed | | `failed` | All jobs failed | The split between `completed`, `completed_with_warnings`, and `partial` is the point of this endpoint: it distinguishes "every locale shipped" from "every locale shipped, some with a warning" from "some locales shipped and some didn't" – three outcomes you'd otherwise have to reconstruct by reading each job. `partial` is not an error; it's a real state of the world that the group reports plainly so your code can branch on it. ## How often to poll {% callout type="info" title="Polling interval" %} For most jobs, processing takes 2–8 seconds per language. If you're polling instead of using webhooks or WebSocket, a 2-second interval is a reasonable starting point. {% /callout %} Polling is the simplest way to track a group, and for a short-lived batch it's perfectly fine. But it's the weaker option, and worth being honest about: every poll is a round-trip whether or not anything changed, and you learn a locale finished only on your next tick, not the moment it lands. If you want each result the instant it's ready, don't poll – be told. The platform delivers each completed locale to your [webhook URL](/docs/api/localization/webhooks) as it finishes, and a [WebSocket connection](/docs/api/localization/realtime) on the group pushes a full state snapshot on every change, so your UI updates without asking. Reach for polling when a webhook endpoint or a persistent connection is more than the job is worth; reach for push when latency to your UI matters. ## When one locale fails A skeptical reading of "translate into many locales at once" asks the obvious question first: what happens to the rest when one locale fails? Here is the answer, in the response. Each locale is an independent job. If German succeeds but Japanese fails, the German translation is finished and delivered normally – the failure does not roll it back. The failed job appears in the group with `status: "failed"`, `failedJobs` increments, and the group rolls up to `partial`: ```json { "groupId": "ljg_A1b2C3d4E5f6G7h8", "status": "partial", "sourceLocale": "en", "totalJobs": 3, "completedJobs": 2, "completedWithWarningsJobs": 0, "failedJobs": 1, "jobs": [ { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:04.000Z" }, { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "completed", "warnings": [], "completedAt": "2026-03-16T10:30:05.000Z" }, { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "failed", "warnings": [], "completedAt": null } ], "createdAt": "2026-03-16T10:30:00.000Z" } ``` Two locales shipped, one didn't, and the counts say so without you scanning anything. To retry, [submit a new request](/docs/api/localization/create) with only the failed locales and a fresh idempotency key. The full error description for a failed locale – the `errorMessage` – lives on the [single job](/docs/api/localization/jobs); the group gives you the count and the verdict. {% callout type="info" title="Partial failures are a normal state" %} `partial` means exactly what the counts show: some locales completed, some failed. The completed locales are already delivered. There's nothing to roll back and no re-spend on the locales that succeeded – you retry only what failed. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Read one locale's translated outputData, warnings, and error message" /%} {% link-card title="Webhook delivery" href="/docs/api/localization/webhooks" icon="lightning" description="Get each locale's result pushed to you the moment it completes" /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/localization/realtime" icon="lightning" description="Stream group snapshots into your UI as each locale lands" /%} {% /card-grid %} - [Lock non-translatable keys](https://lingo.dev/en/docs/api/localization/locked-keys): Keep IDs, slugs, URLs, and enum codes byte-for-byte identical through translation. Declare them in lockedKeys and the engine excludes those values from translation, then merges the source value back verbatim into every locale's output. A real payload is rarely all prose. The same object that holds a `title` and a `body` also holds an `id`, a `slug`, an asset URL, a template name, an enum code – values that identify or wire your content and must come out of translation exactly as they went in. The risk is quiet: hand a model a field called `id` next to text it's translating, and it may decide `"post-42"` reads better localized, or normalize a URL, or "correct" an enum. One mutated identifier is a broken link or a failed lookup in production, in whichever locale the model felt helpful. `lockedKeys` removes the guesswork. You name the keys that must not change – by exact name or by glob – and the localization engine excludes those values from translation, then merges the source values back **verbatim into `outputData`** for every target locale. A locked value is not translated, normalized, or rewritten. Same identifier in, same identifier out, in every locale. `lockedKeys` is a field on the create-jobs request. See [Create jobs](/docs/api/localization/create) for the full request shape and the 202 response; this page covers only what to put in `lockedKeys` and how the matching works. ## Lock a key by name Pass `lockedKeys` alongside your `data`. Each entry is a pattern – at its simplest, the bare name of a key you want preserved. ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "id": "post-42", "title": "How async APIs reduce latency", "tags": ["performance", "infra"], "author": { "id": "u_abc", "name": "Sam" }, "body": "Async APIs let your app stay responsive while translations process in the background." }, "lockedKeys": ["id"] } ``` The bare pattern `id` matches the key `id` wherever it appears as a complete segment – here, both the top-level `id` and the nested `author.id`. Every German and French job's `outputData` keeps `"post-42"` and `"u_abc"` exactly. Only `title`, `name`, and `body` are translated; `tags` stays as-is because it holds no locked path, and its string values translate like any other text. That last point is worth pinning down, because it answers the first question a skeptic asks. {% callout type="info" title="Is a locked value translated?" %} No. A key you name in `lockedKeys` is excluded from translation, and its source value is merged back into `outputData` verbatim for every target locale. The value you sent comes back unchanged – not translated, not normalized, not rewritten. Locking is a guarantee about the result, expressed through `lockedKeys`, not a hint the model is asked to honor. {% /callout %} ## Match by name, anywhere – or by position A bare pattern is a key name, and it matches that name **as a complete segment, at any depth, anywhere in the tree**. If `audioSrc` appears in twelve places nested under different parents, the single pattern `audioSrc` locks all twelve. You don't enumerate paths to catch every occurrence – that's the common case, and it's one line. When you need positional control – lock one occurrence but not another, or every element of an array but nothing else – use a glob with `/` as the path separator. Array indices appear as ordinary segments, so `users/0/email` and `users/*/email` are both valid paths. | Pattern | What it locks | | ------------------------ | -------------------------------------------------------------- | | `audioSrc` | Every `audioSrc` leaf in the tree, at any depth | | `metadata` | The whole `metadata` subtree wherever it appears | | `metadata/author` | The `metadata/author` sequence anywhere it appears, plus below | | `users/*/email` | Every user's `email` – `*` is one segment, matches any index | | `users/0/email` | Only the first user's email | | `**/{audioSrc,imageSrc}` | Both leaf names via brace alternation | Two patterns above lock more than a single leaf, by design. `metadata` locks the entire subtree under that key – every value beneath it, translatable-looking or not, is preserved. `metadata/author` locks that sequence wherever it occurs **and everything below it**. Reach for a subtree lock when a whole block is structural – a config object, a raw embed – and for a leaf lock (`metadata/author/name`) when only one field inside an otherwise-translatable block must hold. {% callout type="info" title="Glob, not regex" %} `*` matches exactly one path segment; `**` spans any number of segments; `{a,b}` is brace alternation across alternatives. There is no character-class or partial-token matching – patterns operate on whole path segments, not substrings. Write `users/*/email`, not a regular expression. {% /callout %} ## What comes back Locking changes what the model translates – it does not change the shape of your result. `outputData` mirrors the input structure exactly: locked keys sit in their original positions holding their original values, and translatable strings around them are translated. Nothing is dropped, renamed, or reordered. For the input above, every locale's `outputData` carries `id: "post-42"` and `author.id: "u_abc"` unchanged, with `title`, `name`, and `body` in the target language. The full job response – `outputData`, per-stage `steps`, and status – is documented on [Get a single job](/docs/api/localization/jobs). ## One limit, named up front `lockedKeys` accepts **up to 100 patterns** per request. That's a ceiling on the number of patterns, not the number of keys they match – a single `audioSrc` or `users/*/email` can lock thousands of values across a large payload, and counts as one pattern. If you're approaching 100 distinct patterns, it's usually a sign that a broader glob (`**/{id,slug,href}`) or a subtree lock will express the same intent in far fewer lines. `lockedKeys` is also per-request and ad hoc: it locks keys for this job group only. So for terms that should never translate in *any* job – a product name, a trademarked feature, a unit that must stay literal – the durable home is a non-translatable entry in your engine's glossary, applied automatically on every call. See [Glossaries](/docs/platform/glossaries). Use `lockedKeys` for structural fields tied to a specific payload's shape; use the glossary for vocabulary that's constant across all your content. ## Next steps {% card-grid %} {% link-card title="Create jobs" href="/docs/api/localization/create" icon="lightning" description="The full create-jobs request and 202 response that lockedKeys is part of" /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Read outputData and confirm your locked values came back verbatim" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Mark vocabulary non-translatable across every job, not just one request" /%} {% /card-grid %} ## Docs – Api/pipeline - [Back-translation check](https://lingo.dev/en/docs/api/pipeline/back-translation): The backTranslation pipeline stage translates each output back into the source locale, has an AI compare it against your original, and flags drift as minor, major, or critical – auto-correcting the major and critical cases. A meaning check you can read, not a quality you have to trust. The translations came back. German, French, Japanese, all populated, all well-formed. They look fine – but "looks fine" in a language you do not read is faith, not verification, and the one thing a translation can quietly get wrong is the thing you most need it to get right: the meaning. The `backTranslation` stage closes that gap without a second pair of human eyes. It translates each output back into the source locale, has an AI compare that round trip against the words you originally sent, and flags where the meaning drifted. You are not trusting that the meaning survived the round trip – you are watching the pipeline check that it did. ``` stepId: backTranslation ``` This page covers that one stage: what each step does, how drift is graded, and which cases get fixed automatically. New to the pipeline? Start with the [pipeline overview](/docs/api/pipeline) for how stages wrap the core translate step. To turn it on, see [Configure the pipeline](/docs/api/pipeline/configure); to read what a run produced, see [Observe pipeline runs](/docs/api/pipeline/observability). **On this page** - [How the check works](#how-the-check-works) - [How drift is graded](#how-drift-is-graded) - [A borrowed technique, automated](#a-borrowed-technique-automated) - [When to enable it](#when-to-enable-it) - [Reading the result](#reading-the-result) ## How the check works The stage runs after the core translation – and after [rephrase](/docs/api/pipeline/rephrase), when that is enabled – so it always verifies the current best output, the exact text that would otherwise ship. It is three steps. {% steps %} {% step title="Reverse translate" %} The translated output is translated back into the source locale, using the same [engine configuration](/docs/platform/engines) – [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), [instructions](/docs/platform/instructions) – adapted for the reverse direction. This is a real second translation, not a lookup. {% /step %} {% step title="Detect drift" %} An AI agent compares the back-translation against the original source you sent and grades any divergence by severity: `minor`, `major`, or `critical`. {% /step %} {% step title="Correct if needed" %} When the grade is `major` or `critical`, a further AI pass adjusts the forward translation to resolve the drift. `minor` divergences are recorded for observability and left as they are – the output is not rewritten over a wording nuance. {% /step %} {% /steps %} So the check does not just tell you the meaning drifted – on the cases that matter, it repairs the forward translation before that translation reaches your users. ## How drift is graded A round trip never returns the source verbatim. Two translators – or one AI in two directions – will phrase the same idea differently, and that is expected, not a defect. The grade is what separates a harmless rewording from a meaning that moved: | Severity | What it means | What the stage does | | --- | --- | --- | | `minor` | Wording differs, meaning intact – a synonym, a reordered clause, a stylistic choice. | Recorded, not corrected. | | `major` | The meaning shifted enough to matter – a changed emphasis, a dropped qualifier, an altered claim. | A correction pass adjusts the forward translation. | | `critical` | The meaning is wrong – a negation flipped, a number changed, a term inverted. | A correction pass adjusts the forward translation. | The split is the point. A check that "fixed" every reworded sentence would churn good translations and bury the real problems; a check that flagged everything and fixed nothing would just hand you a longer report. Grading lets the stage leave the harmless cases alone and spend a correction pass only where the meaning actually moved. {% callout type="info" title="What gets auto-corrected, and what does not" %} Only `major` and `critical` drift triggers a correction pass. `minor` drift is surfaced for observability and the output is left unchanged. If you want every divergence in front of a person regardless of grade, that is what [human review](/docs/api/pipeline/human-review) is for – back-translation is the automated guard, not a replacement for a reviewer. {% /callout %} ## A borrowed technique, automated Back-translation is not a Lingo.dev invention. It is a classic quality-assurance method in human translation: a second translator renders the target text back into the source language, an editor compares the two, and the comparison surfaces where meaning was lost. It catches the failure that a single forward pass hides – a translation that reads fluently and means something subtly different. The pipeline runs that same method with LLMs in place of the second translator and the editor. The technique is the established one; what is automated is the labor and the latency. That is why the stage exists – not to add an AI flourish, but to put a recognized meaning check inline in your job, on every locale, every time. ## When to enable it Back-translation earns its place wherever a wrong meaning is expensive and you cannot read the target locale yourself to catch it. Reach for it when: - The content is **legal, medical, financial, or technical** – domains where a flipped negation or a shifted qualifier is a real liability, not a style note. - You ship into **locales no one on your team reads**, so the back-translation is your only window onto whether the meaning held. - The output is **high-stakes and low-volume** – a contract clause, a dosage instruction, a compliance notice – where the extra checking cost is trivial against the cost of being wrong. {% callout type="warning" title="It runs a second translation – so it costs more" %} It performs a full reverse translation and an AI comparison on every output, plus a correction pass on top of that whenever drift is graded `major` or `critical` – so a job with back-translation on does more model work than a forward-only translation. Each enabled stage records its own cost on the job, so you can see exactly what the check added. Turn it on where meaning fidelity is worth that cost, and leave it off for high-volume, low-risk strings where a forward translation is enough. {% /callout %} It pairs naturally with [rephrase](/docs/api/pipeline/rephrase): rephrase makes the copy read native, back-translation confirms that the native-sounding rewrite still says what the source said. Both are toggled independently – see [Configure the pipeline](/docs/api/pipeline/configure). ## Reading the result Like every pipeline stage, an enabled back-translation check writes its own record into the job's `steps` array. Fetch the job with [`GET /jobs/localization/:jobId`](/docs/api/localization/jobs) and the `backTranslation` step tells you the check ran and how it resolved: ```json { "id": "ljb_C3d4E5f6G7h8I9j0", "status": "completed", "outputData": { "clause": "Diese Vereinbarung darf nicht ohne schriftliche Zustimmung übertragen werden." }, "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "createdAt": "2026-04-17T10:00:00Z", "startedAt": "2026-04-17T10:00:00Z", "completedAt": "2026-04-17T10:00:09Z" }, { "stepId": "backTranslation", "type": "action", "status": "completed", "errorMessage": null, "createdAt": "2026-04-17T10:00:09Z", "startedAt": "2026-04-17T10:00:09Z", "completedAt": "2026-04-17T10:00:21Z" } ] } ``` A `completed` `backTranslation` step means the check ran end to end; if it graded `major` or `critical` drift, the `outputData` you read is the corrected forward translation, not the pre-check one. The full breakdown – the severities the check flagged, and what a stage `failed` or `skipped` means for the job as a whole – lives on [Observe pipeline runs](/docs/api/pipeline/observability), the canonical home for reading `steps` and `warnings`. That is the whole stage: a reverse translation, a graded comparison, and a correction where the meaning moved – so the fidelity of every output is verified inline, not assumed. Turn it on per request or set it as an engine default, then read the step to confirm the round trip held. ## Next steps {% card-grid %} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable backTranslation per request or as an engine default, alongside the other stages." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the steps array, the flagged severities, and what completed, failed, and skipped mean." /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="globe" description="The stage back-translation verifies when both are on – native-sounding copy, meaning confirmed." /%} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="Fetch outputData and the steps array where the back-translation result shows up." /%} {% /card-grid %} - [Rephrase for natural copy](https://lingo.dev/en/docs/api/pipeline/rephrase): The rephrase pipeline stage rewrites an accurate translation so it reads like native copy in the target locale – keeping placeholders, tags, and meaning intact. Non-critical and opt-in; enable it for marketing copy, skip it where literal accuracy matters. The translation is accurate, the glossary terms are right, and it still reads like a translation. Rephrase is the pipeline stage that closes that last gap: an AI agent rewrites the current output so it reads like native copy in the target locale, while preserving the meaning, the placeholders, and the tags. This page covers the `rephrase` stage on its own – what it rewrites, what it leaves untouched, what happens when the pass fails, and the one decision it puts in front of you: enable it or skip it. New to the pipeline? Start with the [Async Localization Pipeline overview](/docs/api/pipeline) for how the stages fit together. Rephrase is async-only – it runs for jobs created through the [Async Localization API](/docs/api/localization), never for the synchronous [`/localize`](/docs/api/localize) call. ## What it does A literal translation can carry the source's phrasing into the target locale – grammatically correct, but recognizably translated. The rephrase stage rewrites the current best output so it reads like native copy: natural and idiomatic in the target locale, preferring an idiomatic equivalent over a word-for-word rendering. It preserves the original meaning and intent, and it applies your engine's [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) – the same configuration that produced the translation governs the rewrite. It runs after the AI and human refinement steps, on whatever output reached it. So it works the same whether or not [human review](/docs/api/pipeline/human-review) and [AI post-edit](/docs/api/pipeline/ai-review) are enabled – it rewrites the current best version either way. When the [back-translation check](/docs/api/pipeline/back-translation) is also enabled, it verifies the rephrased output, not the pre-rephrase one. A literal pass keeps you close to the source wording; rephrase moves the copy toward how a native writer would phrase it. Therefore the two aren't interchangeable – which you want is the decision below. ## Your placeholders and tags survive The obvious worry about a stage whose job is to rewrite text: will it touch the parts that are not prose? It does not. Rephrase keeps placeholders, variables, tags, and formatting exactly as-is – it rewrites the words around them, not the tokens your app depends on. So a string like this keeps every interpolation and every tag, and only the human-readable copy changes: ``` Source (en): "Hi {firstName}, you have {count} new messages." Translated (de): "Hallo {firstName}, du hast {count} neue Nachrichten." After rephrase (de):"Hey {firstName}, {count} neue Nachrichten warten auf dich." ``` `{firstName}`, `{count}`, and the `` tags are identical across all three. The copy reads more natural in German; the structure the runtime relies on is unchanged. ## When it fails, the job does not Rephrase is a **non-critical** stage. An AI rewrite can fail or time out – and when it does, the translation you already paid for is not lost. The previous output carries forward unchanged and the job continues. You are not gambling an accurate translation on a stylistic pass. A failed rephrase does not fail the job. It surfaces as a step record with `status: failed`, the job finishes as `completed_with_warnings`, and the pre-rephrase translation is what lands in `outputData`: ```json { "id": "ljb_C3d4E5f6G7h8I9j0", "status": "completed_with_warnings", "outputData": { "greeting": "Hallo {firstName}, du hast {count} neue Nachrichten." }, "warnings": [ { "step": "rephrase", "message": "" } ], "steps": [ { "stepId": "localize", "type": "action", "status": "completed" }, { "stepId": "rephrase", "type": "action", "status": "failed" } ] } ``` The `de` translation shipped. The exact `message` text is illustrative here – what is fixed is the shape: a `warnings[]` entry with `step` and `message`, the `rephrase` step recorded as `failed`, and the pre-rephrase output preserved in `outputData`. Read the `step` field to see the copy was not polished on this run, so you can re-run that locale if natural phrasing matters for it. See [Observe pipeline runs](/docs/api/pipeline/observability) for the full `steps[]` and `warnings` shape, and how non-critical failures roll up to `completed_with_warnings`. {% callout type="info" title="Non-critical means best-effort, by design" %} Enabling rephrase cannot lower the reliability of a job. The worst case is the translation you would have shipped without the stage, plus a warning. That is what lets you turn it on broadly and treat the polished copy as an upgrade rather than a dependency. {% /callout %} ## When to enable, when to skip Rephrase optimizes for one thing: reading like a native original. That is exactly right for some content and exactly wrong for other content, so this is a per-content decision, not a global default. **Enable it for** marketing copy, landing pages, product descriptions, and onboarding – anywhere reading like native copy matters more than staying close to the source phrasing. **Skip it for** technical and legal content, where literal accuracy is the priority. Rephrase rewrites wording to sound natural; in a contract clause, an API reference, or a compliance string, the wording closer to the source is the safer one. For that content, leave rephrase off and let the [core localization](/docs/api/pipeline) step's output stand. {% callout type="warning" title="Natural phrasing is a trade, not a free win" %} Rephrase moves the copy away from the source wording on purpose. That is the point for marketing, and the risk for anything where the precise wording carries legal or technical weight. If you are unsure which side a given payload is on, skip rephrase for it – literal is the safer default. {% /callout %} ## Turning it on Rephrase is configured like every other stage – an engine-level default plus an optional per-request override – so the full mechanics live on [Configure the pipeline](/docs/api/pipeline/configure). The short version: toggle it in the engine's **Pipeline** tab to apply it to every job, or set it for a single submission with `pipelineConfig`: ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "headline": "Ship global products faster." }, "pipelineConfig": { "rephrase": { "enabled": true } } } ``` Stages you omit inherit the engine config – so the override above turns rephrase on for this submission without touching any other stage. That is what lets you keep rephrase off on the engine for literal content and switch it on per request for the marketing payloads that want it. ## Next steps {% card-grid %} {% link-card title="Back-translation check" href="/docs/api/pipeline/back-translation" icon="shield" description="Verify the rephrased output preserves the source meaning - reverse-translate, detect drift, auto-correct." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Set rephrase as an engine default or override it per request alongside the other stages." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the rephrase step record and see how a non-critical failure becomes completed_with_warnings." /%} {% link-card title="Pipeline overview" href="/docs/api/pipeline" icon="gear" description="How rephrase fits with pre-edit, human review, AI post-edit, and back-translation." /%} {% /card-grid %} - [AI review](https://lingo.dev/en/docs/api/pipeline/ai-review): postEdit runs after human review and reconciles the human's edit back to your engine config – glossary, brand voice, and instructions. It only runs when the human stage produced output, and it changes the translation rather than scoring it. After a human review, reconcile the edit back to your engine config. You turned on [human review](/docs/api/pipeline/human-review), and a translator fixed a German string by hand. Good – but in fixing it, they may have quietly replaced a glossary term, used a register your brand voice rules forbid, or worded something the engine's instructions tell it to word differently. The human's edit is now authoritative, and it has overridden the configuration you tuned the engine to hold. The post-edit stage (`postEdit`) is the AI pass that closes that gap: it takes the human's edit as input and reconciles it back to your engine config before the job finishes. ``` postEdit → runs after humanEdit, reconciles its output to engine config ``` {% inline-callout %} New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline). {% /inline-callout %} That is the whole job of this stage, and it is a narrow one. It acts on the human's edit, not the source, and it does not score the result – it adjusts the human's output so it conforms to the same [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) every other translation on the engine already follows. ## What it adjusts A human translator brings judgment the engine cannot – nuance, context, a fix for a phrasing that read wrong. That judgment is exactly why you enabled the [human review](/docs/api/pipeline/human-review) stage. But a human working a queue does not have your full engine configuration in their head, so an edit can introduce variations that conflict with engine-level rules. The post-edit pass reconciles those two without discarding either. It reviews the human's output against three layers of engine config and adjusts the text to align with them: - [**Glossary**](/docs/platform/glossaries) – the source terms you map to exact translations per locale, and the terms that must never be translated. If a human edit swapped an approved term for a synonym, this pass brings it back. - [**Brand voice**](/docs/platform/brand-voices) – the tone and register the engine holds steady across languages. An edit that drifted formal-to-casual is reconciled to the voice you defined. - [**Instructions**](/docs/platform/instructions) – the standing rules the engine follows on every translation, applied here to the human's edit as they are to a fresh one. The human's intent survives. What changes is the places where their wording collided with a rule the rest of your translations obey. The output that carries forward to the [remaining stages](/docs/api/pipeline) is the human's edit, reconciled. ## When it runs The post-edit stage is only available when [human review](/docs/api/pipeline/human-review) is enabled, because the human's edit is its input – there is nothing to reconcile without one. Two consequences follow, and the second one is the one that surprises people: - **It depends on `humanEdit`.** In the engine config, `postEdit` cannot be enabled until human review is on. Enable human review first; post-edit reconciles its output. - **It is skipped when the human stage produces no output, even with `postEdit.enabled: true`.** Human review is event-driven and has a timeout – default 48 hours – and on timeout the AI translation becomes final with no human edit. If the human stage times out or is otherwise skipped, there is no edit to reconcile, so post-edit is skipped too. You will see a `skipped` step record, not a failure. {% callout type="warning" title="Set postEdit.enabled but saw nothing happen?" %} The most common reason is that the human stage produced no output. Post-edit runs only on the human's edit – if [human review](/docs/api/pipeline/human-review) timed out (default 48h) or was skipped, there is nothing for this stage to reconcile, and it is skipped regardless of `postEdit.enabled`. Check the human stage's outcome first: a `skipped` `humanEdit` step means `postEdit` was skipped for the same reason. The per-stage record that shows this lives on [Observe pipeline runs](/docs/api/pipeline/observability). {% /callout %} ## Not the same as AI Reviewers This is the distinction worth getting right before you turn the stage on, because the names are close and the behavior is opposite. {% callout type="warning" title="Not the same as AI Reviewers" %} The post-edit stage **modifies the translation output** to conform with your engine rules. [AI Reviewers](/docs/platform/ai-reviewers) are a separate feature that **scores translation quality asynchronously without altering the output**. One changes the text; the other grades it and leaves it alone. You can run both together: post-edit reconciles the human's edit, then AI Reviewers score the reconciled result. {% /callout %} If your goal is to keep human edits inside your engine's rules, that is post-edit – it acts on the output. If your goal is a quality signal you can monitor and report on without touching the translation, that is [AI Reviewers](/docs/platform/ai-reviewers). They answer different questions, and using one does not replace the other. ## Configuration and observability Post-edit is one stage in the engine pipeline, toggled like the others. Its schema entry is `postEdit { enabled }`, with the standing rule that it cannot be enabled unless `humanEdit` is. To turn it on for every job, set it on the engine's Pipeline tab; to turn it on for a single submission, pass it in the request's `pipelineConfig`. Both layers, and the rule that ties `postEdit` to `humanEdit`, are specified on [Configure the pipeline](/docs/api/pipeline/configure). When the stage runs, it appears as a `postEdit` entry in the job's `steps[]` array – `completed` when it reconciled the edit, `skipped` when the human stage gave it nothing to work with. The full step-record shape and the completed/failed/skipped semantics live on one canonical page: [Observe pipeline runs](/docs/api/pipeline/observability). ## Next steps {% card-grid %} {% link-card title="Human review" href="/docs/api/pipeline/human-review" icon="gear" description="The stage that produces the edit post-edit reconciles – Internal vs External, tiers, and the 48h timeout." /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="gear" description="The next optional stage – rewrite the reconciled output to read like native copy." /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable postEdit on the engine or per request, and the rule that ties it to human review." /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="shield" description="Score translation quality without altering the output – the feature this stage is not." /%} {% /card-grid %} - [Human review](https://lingo.dev/en/docs/api/pipeline/human-review): Pause an async localization job on a human reviewer – your own team or an external professional – and resume with their edit. Event-driven, per-locale, bounded by a timeout so a silent reviewer never strands the job. Most of your content can ship the moment the engine returns it. Some can't. A regulated disclosure, a medical instruction, a headline that carries the brand – for those, you want a person to read the translation and sign off before it goes live, not after a customer files a complaint. The usual way to get a human into an automated flow is the painful way: hold the request open while someone reads, or build your own review queue, or hand-wire a translation vendor's API. This stage does it inside the job. When `humanEdit` is enabled, the async job runs the engine, then **pauses on a person** – your own team or an external professional – and resumes with their edit, carrying that output forward to any stages that follow. It is the third stage of the [localization pipeline](/docs/api/pipeline) and, like every stage, applies only to jobs created through the [Async Localization API](/docs/api/localization). New to the pipeline? Start with the [overview](/docs/api/pipeline). **On this page** - [How the pause works](#how-the-pause-works) - [Internal Review](#internal-review) - [Permissions](#permissions) - [External Review](#external-review) - [Timeout](#timeout) - [Enabling the stage](#enabling-the-stage) ## How the pause works After the core translate step, the job hands the AI translation to a human for review. The reviewer reads it, then either approves it as-is or submits an edited version. Only then does the job continue. The human output – approved or edited – becomes the input to every later stage and, ultimately, the job's `outputData`. The obvious objection to pausing a job for minutes, hours, or a day is cost: a request held open is a resource burning while nothing happens. This stage does not work that way. {% callout type="info" title="The wait is event-driven, not a held-open connection" %} The workflow resumes on an event – a reviewer submitting in the dashboard (Internal Review) or a callback from the translation provider (External Review). It is not polling on a tight loop and it is not holding a connection open, so a long timeout consumes no compute in the background. A job can wait 48 hours for a human the same way it waits on a model: it is parked, not spinning. {% /callout %} Two review modes are available, picked per engine. They differ only in who does the reading – the pause, resume, and carry-forward behavior is identical. ## Internal Review Your own team reviews translations directly in the Lingo.dev dashboard. Pending reviews land on the **Human Reviewer** page of your organization (`/orgs//human-reviewer`), and reviewers are notified when new items arrive. A reviewer claims an item, then approves the translation as-is or submits an edited version. The job resumes immediately with their output. A second reviewer can't pick up an item someone is already working. **Claims are exclusive** – one reviewer holds an item at a time, so two people never edit the same translation and quietly overwrite each other. The string is locked to the claimant until they release it or submit. Internal Review is the default mode for new engines. Reach for it when your team has the language expertise in-house and you want full control over the final wording, with no third party in the loop. ## Permissions Internal review is not open to everyone in the organization – the **Human Reviewer** page is permission-gated, so a translation in review is visible only to the people you grant access. An organization admin assigns access through roles (**Settings → Roles**): | Permission | What it unlocks | | --- | --- | | **Review translations** (`engine:review_translations`) | See and work the review queue – claim a pending translation, edit it, approve it as-is, or submit the edited version | | **Manage reviews** (`org:manage_reviews`) | Review history and reviewer statistics for all internal reviews across the organization | The two permissions are deliberately separate. A reviewer needs only **Review translations** – that single grant is the whole job: claim, edit, approve, submit. **Manage reviews** is for whoever oversees the operation; it adds the history and stats view across the org but does **not** include queue access on its own. Grant both to a lead who reviews and reports; grant only the first to someone who only works the queue. ## External Review When you don't have in-house reviewers for a locale, the translation is submitted to a qualified professional translator through an external provider instead. The job pauses the same way; it resumes when the provider returns the edited translation. Nothing about your code changes – the difference is who reads the string, not how the job behaves. External Review has two tiers, and the split is about how much accuracy the content demands: | Tier | Best for | | --- | --- | | **Standard** | Accurate translation with a human voice – marketing copy, UI strings, help content | | **Pro** | Professional use with an even higher accuracy bar – legal, medical, and regulated content | This is the candor worth being explicit about: External Review is a real human translator on the other end, with the turnaround and cost that implies. It is not a faster model. Use it where a person's judgment is the point – regulated text, high-stakes copy – and lean on the AI-only path for the bulk of your content that doesn't need it. ## Timeout A human stage introduces a risk the AI stages don't have: the human might never respond. A reviewer is on vacation, the provider is backed up, the item is forgotten. Left unbounded, the job would wait forever. So the wait is bounded. You set how long the workflow waits for the human output – the same `timeoutHours` setting applies to both review modes. If the timeout expires with no response, the stage is marked `skipped` and **the job continues with the AI translation as the final output**. The default is **48 hours**. State the cost plainly: on timeout you ship the unreviewed AI translation. That is the correct fallback for most content – a translation that shipped beats a job stuck open indefinitely – but it is a real tradeoff. For content where a human must sign off no matter what, set a generous timeout and alert on the skip; for content where review is a nice-to-have, a short timeout keeps the pipeline moving. {% callout type="info" title="Timeout is per stage, not per job" %} The timeout governs only how long this stage waits for a human. It is independent of how long the rest of the job takes. Because the wait is event-driven, a long timeout costs you latency-to-final-output if the reviewer is slow – never background compute. {% /callout %} ## Enabling the stage `humanEdit` is configured like every pipeline stage: an engine-level default in the engine's **Pipeline** tab, optionally overridden per request. The full two-layer model lives on [Configure the pipeline](/docs/api/pipeline/configure); the shape specific to this stage is: ```json { "humanEdit": { "enabled": true, "provider": "internal", "tier": "standard", "timeoutHours": 48 } } ``` `provider` selects the mode – `internal` for your own team, the external provider otherwise. `tier` (`standard` or `pro`) applies to External Review and is ignored for Internal. `timeoutHours` is the bound from the section above. To override for a single submission, pass this block inside `pipelineConfig` on the [create call](/docs/api/localization/create); omit it and the job inherits the engine's setting. When the stage runs, it records itself on the job under `stepId: "humanEdit"` with a status of `completed`, `failed`, or `skipped` – the same step record every stage produces. Reading those records is covered in [Observe pipeline runs](/docs/api/pipeline/observability). {% callout type="warning" title="A human edit can drift from your engine rules" %} A human translator may phrase something in a way that conflicts with your [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), or [instructions](/docs/platform/instructions) – they're translating well, not memorizing your config. To reconcile the human's edit back to your engine rules automatically, enable the next stage, [AI review](/docs/api/pipeline/ai-review). It runs only after a human stage that actually produced output. {% /callout %} ## Next steps {% card-grid %} {% link-card title="AI review (post-edit)" href="/docs/api/pipeline/ai-review" icon="gear" description="Reconcile the human edit back to your engine's glossary, brand voice, and instructions" /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Engine-level defaults and per-request pipelineConfig overrides for every stage" /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the humanEdit step record – completed, failed, or skipped on timeout" /%} {% link-card title="Engines" href="/docs/platform/engines" icon="gear" description="Where you pick the review mode, tier, and timeout for a pipeline" /%} {% /card-grid %} - [Pre-localization AI edit](https://lingo.dev/en/docs/api/pipeline/pre-edit): An optional pipeline stage that cleans the source payload – typos, grammar, spelling – before any locale is translated, so one source error doesn't reach every target. Non-critical, so it can never fail the job. A typo in your source is a typo you only get to fix once – before it multiplies. An async job fans one source payload out to every target locale, and each locale translates the text it was handed. So a misspelling, a dropped word, or a broken sentence in the source doesn't stay one problem. It becomes one problem in German, the same problem in French, and the same problem in every other locale the job touches – each one now needing its own correction after the fact. Pre-localization AI edit (`preEdit`) closes that gap at the source. It is the first stage of the [async localization pipeline](/docs/api/pipeline): before the core translate step runs, an AI agent reviews the source payload and corrects typos, grammar mistakes, and spelling errors. The cleaned source is what gets translated – so you fix it once, before it fans out, instead of catching the same error in a dozen outputs. This is an [async pipeline](/docs/api/pipeline) stage, so it runs only on jobs created through the [Async Localization API](/docs/api/localization). The synchronous [`/localize` endpoint](/docs/api/localize) runs the core translate step alone and ignores pipeline settings. ## What the stage does `preEdit` operates on the **source**, not the translation. An AI agent reads your source payload and rewrites it to remove surface errors – typos, grammar, spelling – then hands the corrected text to the core localization step. Every target locale translates from that cleaned source. Its scope is deliberately narrow, and that is the point. This is a copy-cleaning pass, not a rewrite: it targets the kind of surface noise that makes source text ambiguous to a translation model, so the model spends its attention on translating rather than guessing what a mangled sentence meant. Cleaner source produces more consistent translations across locales – because every locale starts from the same corrected text instead of each model independently interpreting the same error. For idiomatic, native-sounding *output* – rewriting the translation itself to read like a native copywriter wrote it – that is a different stage. See [Rephrase for natural copy](/docs/api/pipeline/rephrase). `preEdit` cleans the input; `rephrase` polishes the output. ## It can't make the job worse The first question a careful engineer asks about an AI step that edits their content before translation is the right one: *what happens when that step gets it wrong, or doesn't run at all?* `preEdit` is a **non-critical** stage. If the pre-edit call fails or times out, the original source is passed through unchanged and the job continues to the translate step exactly as if the stage were off. A failure here costs you the cleanup on that job – not the job. The translation still ships. {% callout type="info" title="What if pre-edit fails or times out?" %} The job does not fail. Non-critical stages fall back to their input: on a `preEdit` failure, the unedited source is translated as-is, and the job runs to completion. The job status becomes `completed_with_warnings`, the `preEdit` step is recorded as `failed`, and the reason lands in the job's `warnings` array – so you can see it happened without it blocking delivery. Reading those step records is covered on [Observe pipeline runs](/docs/api/pipeline/observability). {% /callout %} So the honest framing of the floor: enabling `preEdit` cannot make a job fail that would otherwise have succeeded. The worst case is that it doesn't help on a given job and quietly steps aside. ## What it is not Worth stating plainly, at the point you'd most want it to be more: `preEdit` is **best-effort**, and it is a copy-cleaning pass over surface errors – not a proofreader that understands your domain or a fact-checker that validates your claims. It corrects typos, grammar, and spelling. It does not verify that a price is right, that a product name is current, or that a sentence says what you meant it to say. If your source is factually wrong, `preEdit` will faithfully clean the grammar of a wrong sentence and translate it cleanly into every locale. For terms that must stay exactly as written regardless of any AI pass – product names, trademarks, code identifiers – pin them at the source. Mark them non-translatable in your engine's [glossary](/docs/platform/glossaries), or, for structural fields in a specific payload, exclude them with [`lockedKeys`](/docs/api/localization/locked-keys). Those are guarantees about the data; `preEdit` is a best-effort cleanup around them. ## When to enable it `preEdit` earns its extra pass when your source is likely to carry noise, and it's redundant when your source is already clean. - **Enable it** when source content is user-generated, machine-extracted, scraped, OCR'd, or otherwise authored outside an editorial process – the cases where surface errors are common and the multiply-across-locales cost is real. - **Skip it** for curated content that has already passed editorial or human review. If the source is clean, there is nothing for the stage to correct, and you would be paying for an AI pass that has no work to do. Each enabled stage is one more step the job runs and one more line on its cost – worth it where source quality is uncertain, wasted where it isn't. That is the whole trade: spend one pass up front, on the jobs where source quality is uncertain, to fix it once before it fans out – instead of correcting the same error across every locale after delivery. You toggle `preEdit` on the engine's **Pipeline** tab, where it applies to every async job routed to that engine, or override it for a single submission with `pipelineConfig` on the create-jobs request. Both layers, and how an omitted stage inherits the engine default, are covered on [Configure the pipeline](/docs/api/pipeline/configure). ## Next steps {% card-grid %} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Enable preEdit as an engine default or override it per request with pipelineConfig" /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="book" description="Read the preEdit step record and find warnings when a non-critical stage falls back" /%} {% link-card title="Rephrase for natural copy" href="/docs/api/pipeline/rephrase" icon="chat" description="The output-side counterpart - rewrite the translation to read native, not clean the input" /%} {% link-card title="Localization Pipeline" href="/docs/api/pipeline" icon="lightning" description="All the stages that wrap the core translate step, and how they fit together" /%} {% /card-grid %} - [Observe pipeline runs](https://lingo.dev/en/docs/api/pipeline/observability): Every enabled pipeline stage leaves one record on the job – which stage ran, whether it completed, failed, or was skipped, and what it cost. Read the steps[] array and you never have to trust the pipeline ran; you check. Every enabled pipeline stage leaves one record on the job, so you can read what ran instead of trusting that it did. You turned on a few [pipeline](/docs/api/pipeline) stages – maybe [pre-edit](/docs/api/pipeline/pre-edit) to clean the source and [back-translation](/docs/api/pipeline/back-translation) to catch drift – and a job came back `completed_with_warnings`. Which stage fell through? Did the human reviewer ever pick it up, or did it time out? What did the extra stages cost? A pipeline that runs several AI and human steps per locale is exactly the kind of thing that turns into a black box: output comes out, and you take it on faith that the stages in between did their work. They don't ask you to take it on faith. Every enabled stage writes one record to the job's `steps[]` array – which stage, what status, what cost, when it started and finished. **You read what each stage did; you don't trust that it ran.** That is the whole job of this page. {% inline-callout %} New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline). {% /inline-callout %} **On this page** - [Where the records live](#where-the-records-live) - [The steps array](#the-steps-array) - [Mapping a stepId to a stage](#mapping-a-stepid-to-a-stage) - [Step status: completed, failed, skipped](#step-status-completed-failed-skipped) - [How a step failure becomes a job warning](#how-a-step-failure-becomes-a-job-warning) ## Where the records live The `steps[]` array is a field on the localization job. You don't fetch it separately – it arrives whenever you read the job: ``` GET /jobs/localization/:jobId ``` Authenticate with your API key in the `X-API-Key` header. The full endpoint, the job-status values, and the `outputData` payload are [covered on the single-job page](/docs/api/localization/jobs); this page is about one field on that response – the per-stage trail – and what it tells you. So the rule is simple: every job you read already carries its own audit log. A job with no pipeline enabled shows a single record, because [core localization](/docs/api/pipeline) always runs. Turn on two optional stages and you get three records. The array grows with the pipeline, one entry per stage, in the order the stages ran. ## The steps array Each entry in `steps[]` is the record of one stage. These are the fields you read to audit a run – which stage, with what outcome, at what cost, and when: ```json "steps": [ { "stepId": "preEdit", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0012, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:01.000Z", "startedAt": "2026-03-16T10:30:01.000Z", "completedAt": "2026-03-16T10:30:02.000Z" }, { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "externalRefType": null, "externalRefId": null, "externalRefUrl": null, "createdAt": "2026-03-16T10:30:02.000Z", "startedAt": "2026-03-16T10:30:02.000Z", "completedAt": "2026-03-16T10:30:05.000Z" } ] ``` | Field | Description | | --- | --- | | `stepId` | Which pipeline stage this record is for. See [the mapping table below](#mapping-a-stepid-to-a-stage). | | `type` | The kind of step. `action` for an automated stage. | | `status` | `completed`, `failed`, or `skipped` for this stage – independent of the job's status. | | `errorMessage` | Why this stage failed. `null` unless `status` is `failed`. | | `costUsd` | What this stage cost, in USD – a JSON number, or `null`. | | `externalRefType`, `externalRefId`, `externalRefUrl` | A pointer to an external record for stages that hand work to a third party – the [human review](/docs/api/pipeline/human-review) stage. `null` for fully automated stages. | | `createdAt`, `startedAt`, `completedAt` | When the stage was created, picked up, and finished. | Each record also carries an `outputData` field – the content that stage produced, in the same shape as the job's `outputData`. That payload is the translation, not the audit trail, so it's [documented on the single-job page](/docs/api/localization/jobs) alongside the job-level `outputData`; the fields above are the ones you read to see what the pipeline did. Two things these records give you that a single `outputData` blob can't. First, **cost is itemized per stage**, not only totaled per job – so when you enable back-translation and the bill moves, you can read exactly which stage moved it. Second, **timing is per stage** – a `humanEdit` record whose `startedAt` and `completedAt` are hours apart tells you the wait was the human, not the engine. {% callout type="info" title="Read steps by stepId, not by position" %} The records appear in execution order, but don't index into the array by position – which stages ran depends on which you enabled, so position is not stable across jobs. Find a stage by its `stepId` (`steps.find(s => s.stepId === "humanEdit")`). The set of `stepId` values is fixed; the set present on any given job is whatever you turned on. {% /callout %} ## Mapping a stepId to a stage Each `stepId` names one pipeline stage. This is the lookup table from the value in the record to the stage it represents and the page that documents what that stage does: | `stepId` | Stage | | --- | --- | | `preEdit` | [Pre-localization AI edit](/docs/api/pipeline/pre-edit) | | `localize` | [Core localization](/docs/api/pipeline) | | `humanEdit` | [Post-localization human review](/docs/api/pipeline/human-review) | | `postEdit` | [Post-localization AI review](/docs/api/pipeline/ai-review) | | `rephrase` | [Rephrase for natural copy](/docs/api/pipeline/rephrase) | | `backTranslation` | [Back-translation check](/docs/api/pipeline/back-translation) | `localize` is the one `stepId` that appears on every job, pipeline or not – it is the [core translate step](/docs/api/pipeline), and it always runs. The other five appear only when you've [enabled that stage on the engine or in the request](/docs/api/pipeline/configure). ## Step status: completed, failed, skipped Each step carries its own `status`, set independently of the job and of every other step. Three values: | Step `status` | Meaning | | --- | --- | | `completed` | The stage ran and produced its output. | | `failed` | The stage ran and errored. `errorMessage` says why. | | `skipped` | The stage did not run to completion this time, even though it was enabled. | `completed` and `failed` read the way you'd expect. `skipped` is the one worth pausing on, because it is not the same as "disabled". A stage you never turned on produces no record at all. A `skipped` record means the stage *was* enabled but was passed over for a reason the pipeline defines – the clearest case is [human review](/docs/api/pipeline/human-review): if the review window closes with no human response, that stage is marked `skipped` and the AI translation carries forward as final. The record is still there, so the skip is visible rather than silent. {% callout type="info" title="A step status is not the job status" %} A `failed` step does not always mean a `failed` job. Most optional stages are non-critical: when one fails, its record reads `failed`, the engine carries the last good output forward, and the job still finishes with full `outputData`. The job status that results – `completed_with_warnings` – is [explained on the single-job page](/docs/api/localization/jobs). The step status tells you what happened to one stage; the job status tells you whether you got a translation. {% /callout %} ## How a step failure becomes a job warning When a non-critical stage fails, the failure shows up in two places at once, and they are two views of the same event. The `steps[]` record reads `failed` with an `errorMessage` – that's the detailed view. The same failure also surfaces as one entry in the job's top-level `warnings` array – that's the summary view your status-handling code branches on: ```json { "id": "ljb_A1b2C3d4E5f6G7h8", "status": "completed_with_warnings", "outputData": { "title": "Hallo" }, "warnings": [ { "step": "backTranslation", "message": "Back-translation check did not complete" } ], "steps": [ { "stepId": "localize", "type": "action", "status": "completed", "errorMessage": null, "costUsd": 0.0184, "completedAt": "2026-03-16T10:30:05.000Z" }, { "stepId": "backTranslation", "type": "action", "status": "failed", "errorMessage": "Back-translation check did not complete", "costUsd": 0.0031, "completedAt": "2026-03-16T10:30:11.000Z" } ] } ``` Each `warnings` entry is `{ step, message }`, where `step` is the same `stepId` you'd find in the failed record. So the two arrays line up: `warnings` is the short list of what went wrong, and `steps[]` is where you go for the detail behind each one. Read `warnings` to decide whether to flag the locale for a human; read the matching `steps[]` record when you want the `errorMessage`, the cost, and the timing behind it. This is the mechanism behind `completed_with_warnings`: the core translation succeeded, so you have usable `outputData`, but at least one non-critical stage left a `failed` record and a matching warning. Treat the output as shippable and the warnings as a quality signal worth surfacing. Only a job `status` of `failed` means there is no translation to read – and that decision, with the full job-status table, lives [on the single-job page](/docs/api/localization/jobs). {% callout type="info" title="Aggregate stage health is a separate surface" %} `steps[]` answers "what did the pipeline do on **this** job." When you want the trend across many jobs – how often pre-edit fails, how often back-translation corrects a translation – that's an aggregate question, and it's answered on the [Reports](/docs/platform/reports) page, not in a per-job response. Per-job records here; rollups there. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Get a single job" href="/docs/api/localization/jobs" icon="lightning" description="The full job response these steps live on, plus the job-status values to branch on" /%} {% link-card title="Configure the pipeline" href="/docs/api/pipeline/configure" icon="gear" description="Decide which stages run, per engine or per request - and so which steps you'll see" /%} {% link-card title="Reports" href="/docs/platform/reports" icon="book" description="Aggregate stage success rates and throughput across every job" /%} {% /card-grid %} - [Configure the pipeline](https://lingo.dev/en/docs/api/pipeline/configure): Turn pipeline stages on per engine in the Pipeline tab, then override them for a single submission with a pipelineConfig object. Stages you omit inherit the engine config, so each request states only what differs. Set which pipeline stages run, in two layers: a default on the engine, and an optional override on a single request. You have decided which stages you want around the core translate step. Now there are two questions: where does that decision live, and what do you do when one job needs something different from the rest? The answer is two layers. The engine carries the default that every async job inherits. A `pipelineConfig` object on a single submission overrides that default for that submission only. Stages you leave out of the override inherit from the engine, so a request states only what differs. New to the pipeline? Start with the [Pipeline overview](/docs/api/pipeline) for what each stage does. This page is about turning them on and overriding them – not what they do once enabled. {% callout type="info" title="Async jobs only" %} Pipeline configuration applies to jobs created through the [Async Localization API](/docs/api/localization). The synchronous [`/localize` endpoint](/docs/api/localize) runs the core translate step only and ignores pipeline settings entirely – on either layer. {% /callout %} ## Engine-level defaults Open the engine's **Pipeline** tab in the dashboard and toggle each stage independently. That configuration is the default for the engine: every async job routed to it runs with these stages unless a request overrides them. Set it once, and you do not restate the pipeline on every call. Each stage is its own switch. You enable any combination – none of them, all of them, or anything between: - [**Pre-localization AI edit**](/docs/api/pipeline/pre-edit) – clean the source before translation. - [**Post-localization human review**](/docs/api/pipeline/human-review) – route to Internal or External review. You pick the mode, tier, and timeout in the same panel. - [**Post-localization AI review**](/docs/api/pipeline/ai-review) – stays disabled until human review is enabled; it reconciles the human edit with your engine rules. - [**Rephrase for natural copy**](/docs/api/pipeline/rephrase) – rewrite to read native. Independent of the other stages. - [**Back-translation check**](/docs/api/pipeline/back-translation) – verify meaning survived the round trip. Independent of the other stages. [Core localization](/docs/platform/engines) is not a switch – it always runs. The stages wrap around it. The default is what every job inherits, so the engine config is the shape a `pipelineConfig` override is merged into. Each stage is one key: ```json { "preEdit": { "enabled": true }, "humanEdit": { "enabled": true, "provider": "internal", "tier": "standard", "timeoutHours": 48 }, "postEdit": { "enabled": false }, "rephrase": { "enabled": false }, "backTranslation": { "enabled": true } } ``` | Key | Fields | Set on the stage page | | --- | --- | --- | | `preEdit` | `enabled` | [Pre-localization AI edit](/docs/api/pipeline/pre-edit) | | `humanEdit` | `enabled`, `provider` (`internal` \| `gengo`), `tier` (`standard` \| `pro`), `timeoutHours` | [Human review](/docs/api/pipeline/human-review) | | `postEdit` | `enabled` | [AI review](/docs/api/pipeline/ai-review) | | `rephrase` | `enabled` | [Rephrase for natural copy](/docs/api/pipeline/rephrase) | | `backTranslation` | `enabled` | [Back-translation check](/docs/api/pipeline/back-translation) | What each field controls – which review provider, which tier, how long the wait – is documented on the stage's own page. This page is about where the config lives and how the two layers combine. ## Per-request override Most jobs should run the engine default. The exception is a single submission that needs a different pipeline – a one-off batch of marketing copy that wants the rephrase stage your engine normally leaves off, or a legal payload that should skip it. Editing the engine to handle one batch would change every other job too. So you pass the difference on the request instead. Add a `pipelineConfig` object to the [`POST /jobs/localization`](/docs/api/localization/create) body, and it overrides the engine default for that submission alone. Nothing on the engine changes; the next job without an override is back on the default. ```json { "sourceLocale": "en", "targetLocales": ["de", "fr"], "data": { "headline": "Ship in every language." }, "pipelineConfig": { "rephrase": { "enabled": true }, "backTranslation": { "enabled": false } } } ``` This is the inheritance rule, and it is what keeps the override small: **a stage you name is overridden; a stage you omit inherits the engine default.** The request above turns `rephrase` on and `backTranslation` off for this one job. `preEdit`, `humanEdit`, and `postEdit` are not named, so they run exactly as the engine has them configured. You state only what differs. {% callout type="warning" title="Include a stage and you specify all of it" %} The override is per stage, not per field. Each stage you include must be the complete object for that stage – you cannot send `humanEdit: { "tier": "pro" }` to change only the tier while inheriting the rest. Include the whole stage to override it, or omit it to inherit the engine default. There is no partial-stage merge inside a single stage object. {% /callout %} Two more things the override does not do, stated plainly because this is the part that looks like it can do anything: - It changes **that submission only**. It does not write back to the engine, so it is not how you make a lasting configuration change – that is the Pipeline tab. Use the override for the one-off; use the tab for the new normal. - It does not relax a stage's own runtime rules. [Post-localization AI review](/docs/api/pipeline/ai-review) only runs when human review produced output, so enabling `postEdit` does nothing on a job that has no human stage to reconcile – whichever layer you enabled it on. ## Confirm what ran Configuration sets which stages should run; the job's own record tells you which ones did. The job carries a `steps[]` array, and that array is how you confirm a per-request override actually took effect – not just that you sent it. Reading those records – the `stepId` for each stage, what a `skipped` step means, where non-critical failures surface – is its own page. ## Next steps You can set the default on the engine and override it on a request. From here, submit a job that carries an override, or read back the steps to confirm which stages ran. {% card-grid %} {% link-card title="Create localization jobs" href="/docs/api/localization/create" icon="lightning" description="Submit a job and pass pipelineConfig in the body to override stages for that request." /%} {% link-card title="Observe pipeline runs" href="/docs/api/pipeline/observability" icon="gear" description="Read the per-stage steps on a job to confirm what ran, was skipped, or failed." /%} {% link-card title="Pipeline overview" href="/docs/api/pipeline" icon="gear" description="What each stage does and the order they run in around the core translate step." /%} {% link-card title="Engines" href="/docs/platform/engines" icon="gear" description="The engine the Pipeline tab lives on, and the config each stage wraps around." /%} {% /card-grid %} ## Docs – Api/provisioning - [Provisioning live progress](https://lingo.dev/en/docs/api/provisioning/realtime): Stream a provisioning job's progress over a WebSocket while the AI crawls your sources and configures the engine. The server sends a snapshot on connect, then crawling, configuring, completed, and failed events as the workflow advances – and you can connect at any point, even after the job has finished. You [created a provisioning job](/docs/api/provisioning/create) and got back a `pjb_` job ID and an `eng_` engine ID in milliseconds. The engine is usable already, but it is still filling in: an AI agent is crawling your sources and writing brand voices, glossary items, and instructions onto it. While that runs you want to show the work – a "Crawling your style guide… configuring the engine… done" line, the way an install wizard does, instead of a spinner that says nothing. The WebSocket gives you exactly that feed. Connect to the job and the server pushes a snapshot of the current state, then a `provisioning.progress` event each time the workflow moves to a new step. And because the server sends the current state on connect and closes a finished job right after, **you can connect any time, even after it finishes** – there is no window you have to catch. ``` GET /jobs/provisioning/:jobId/ws ``` The `jobId` is the `pjb_` value from the [create call](/docs/api/provisioning/create). New to async provisioning? Start with the [Overview](/docs/api/provisioning) for the mental model. **On this page** - [Message types](#message-types) - [Snapshot on connect](#snapshot-on-connect) - [Progress events](#progress-events) - [Connecting after the job finishes](#connecting-after-the-job-finishes) - [Wiring it into your UI](#wiring-it-into-your-ui) - [Keep your API key server-side](#keep-your-api-key-server-side) ## Message types Two message types travel over the socket. The first arrives once, on connect; the second arrives repeatedly, as the job advances. | Type | When | Key fields | | --- | --- | --- | | `provisioning.snapshot` | On initial connection | `jobId`, `status`, `errorMessage` | | `provisioning.progress` | As each workflow step starts or completes | `jobId`, `step`, `detail` | This is a liveness feed, not a results feed: it tells you where the job is and whether it failed, not which records the AI created. The summary of everything provisioned – the brand-voice, glossary, and instruction IDs – arrives separately, in the [completion webhook](/docs/api/provisioning/webhooks) or by reading the job once it is done. Keep the socket for the progress bar; reach for the webhook for the payload. ## Snapshot on connect The instant you connect, the server reads the job's current state from the database and sends it. No progress event is required first – the snapshot stands on its own. ```json { "type": "provisioning.snapshot", "jobId": "pjb_A1b2C3d4E5f6G7h8", "status": "in_progress", "errorMessage": null } ``` | Field | Description | | --- | --- | | `status` | `in_progress`, `completed`, or `failed`. | | `errorMessage` | The failure description when `status` is `failed`, otherwise `null`. | The snapshot is the one message you are guaranteed to receive. If the job is still running you will get progress events after it; if the job has already finished you will get the snapshot and nothing more (see [below](#connecting-after-the-job-finishes)). ## Progress events As the workflow runs, the server broadcasts a `provisioning.progress` event each time it enters a new step. Each event names the `step` and carries a human-readable `detail`. ```json { "type": "provisioning.progress", "jobId": "pjb_A1b2C3d4E5f6G7h8", "step": "crawling", "detail": "Crawling source URLs..." } ``` | `step` | When | Example `detail` | | --- | --- | --- | | `crawling` | Source URLs are being fetched | `"Crawling source URLs..."` or `"Retrying crawl (attempt 2)..."` | | `configuring` | The AI agent is analyzing content and writing engine config | `"AI agent analyzing content and configuring engine..."` or `"Retrying configuration (attempt 2)..."` | | `completed` | The job finished successfully | `"Provisioning complete"` | | `failed` | The job failed | An error message describing the failure | {% callout type="info" title="A retry is not a failure" %} The `crawling` and `configuring` steps can fire more than once – a transient fetch or analysis error retries, and the retry surfaces as a progress event with a `detail` like `"Retrying crawl (attempt 2)..."`. That is the job recovering, not the job failing. Treat only the `failed` step as terminal; its `detail` carries the actual reason. {% /callout %} {% callout type="info" title="Handle steps you do not recognize" %} New `step` values may be added over time. Switch on the steps you know, treat `completed` and `failed` as the two that close the socket, and ignore anything else as informational – a forward-compatible client keeps working without an update. {% /callout %} ## Connecting after the job finishes The hard question with any progress socket is what happens if you connect late – after the crawl is done, after a deploy reconnected the tab, after the job has already failed. Here the answer is built into how the snapshot works. If the job has already reached `completed` or `failed`, the server sends the snapshot with that final `status` (and `errorMessage`, if it failed) and closes the connection immediately. There are no progress events to replay, because the final state is the snapshot. A job still in flight keeps the connection open and streams progress; a finished job hands you the outcome and hangs up. Either way, the first message tells you where things stand. **Connect any time, even after it finishes** – you cannot connect too early and you cannot connect too late. ## Wiring it into your UI Open the socket against the `pjb_` job ID, read the snapshot to set your initial state, then update on each progress event and close when the job reaches `completed` or `failed`: ```javascript import WebSocket from "ws"; const jobId = "pjb_A1b2C3d4E5f6G7h8"; const ws = new WebSocket( `wss://api.lingo.dev/jobs/provisioning/${jobId}/ws`, { headers: { "X-API-Key": process.env.LINGO_API_KEY } } ); ws.on("message", (raw) => { const event = JSON.parse(raw); switch (event.type) { case "provisioning.snapshot": console.log(`status: ${event.status}`); break; case "provisioning.progress": console.log(`${event.step}: ${event.detail}`); if (event.step === "completed" || event.step === "failed") { ws.close(); } break; } }); ``` Run against a job that crawls cleanly and that prints the configuration happening, step by step: ``` status: in_progress crawling: Crawling source URLs... configuring: AI agent analyzing content and configuring engine... completed: Provisioning complete ``` That is the whole arc on screen: the job opens `in_progress`, you watch it crawl and then configure, and `completed` tells you the engine is fully provisioned. The same loop is correct on a late connect – a finished job sends one snapshot with its final `status` and the socket closes, so the code that handles the live run handles the replay without a special case. ## Keep your API key server-side The socket authenticates with your API key – the same [organization-scoped key](/docs/platform/api-keys) the REST endpoints use. That key reaches every engine in your organization, so the browser is the wrong place to open the connection: anyone who views source would see it. {% callout type="warning" title="Connect from your backend, not the browser" %} Open the WebSocket from your server, where the key already lives, then forward the progress to the browser over your own channel – a WebSocket or server-sent events stream you control. Your frontend shows the engine configuring; your key never leaves your infrastructure. {% /callout %} This mirrors the [webhook](/docs/api/provisioning/webhooks) model: the connection that touches Lingo.dev runs server-side, and what reaches the user is whatever your own app chooses to forward. ## Where this fits The WebSocket is the live view – it is bound to one job and closes when that job is done. For a durable, server-to-server record of the result that survives a closed tab or a deploy, pair it with the [completion webhook](/docs/api/provisioning/webhooks): the socket drives the progress bar while the job is on screen, the webhook delivers the summary of everything the AI created the moment it lands. Wire both from the same [create call](/docs/api/provisioning/create). {% card-grid %} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="The completion and failure payloads, with the full summary of brand voices, glossary items, and instructions - plus signature verification." /%} {% link-card title="Create a provisioning job" href="/docs/api/provisioning/create" icon="gear" description="POST /jobs/provisioning - where the pjb_ job ID you connect to here comes from." /%} {% link-card title="Translate with your new engine" href="/docs/api/localization" icon="lightning" description="Once the job completes, fan content out to every locale through the async Localization API." /%} {% /card-grid %} - [Webhook delivery](https://lingo.dev/en/docs/api/provisioning/webhooks): When a provisioning job ends, Lingo POSTs the result to your callbackUrl – provisioning.completed carries the summary of every brand voice, glossary item, and instruction the AI created, provisioning.failed carries the error. Return 200 first, process after. You [created a provisioning job](/docs/api/provisioning/create) and got a `202` back: an engine ID, and `status: "in_progress"`. The AI agent is now crawling your sources and applying brand voices, glossary items, and instructions to that engine in the background. The work could take a moment or a while, depending on how many links it has to crawl. You could hold a [live WebSocket](/docs/api/provisioning/realtime) open and watch it work – but you'd rather not keep a connection open just to learn the agent is done and find out what it built. That is what the webhook does. When you pass a `callbackUrl` while creating the job, Lingo POSTs the terminal result to that URL the moment the job ends – **told when the engine is ready, with the inventory of what got built.** A job that finishes arrives as `provisioning.completed` with the summary of every record the AI created. A job that fails arrives as `provisioning.failed` with the reason. Either way, your setup flow is told, without asking. This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with [localization](/docs/api/localization/webhooks) and lives on the [webhook signature verification](/docs/api/webhooks) page, linked at each point you'll need it. **On this page** - [How delivery works](#how-delivery-works) - [The completed payload](#the-completed-payload) - [The failed payload](#the-failed-payload) - [Handling a webhook](#handling-a-webhook) - [When delivery is the wrong tool](#when-delivery-is-the-wrong-tool) ## How delivery works A provisioning job ends exactly once. The instant it reaches a terminal state – every source crawled and analyzed, or the run abandoned – its result is delivered to your `callbackUrl` as a single `POST`. A localization group fans out into one job per target locale, each delivering its own callback; a provisioning job is one job, so it is one delivery. Set the destination with `callbackUrl` when you [create the job](/docs/api/provisioning/create). Two payload shapes cross the wire, distinguished by their `type` field: `provisioning.completed` and `provisioning.failed`. Both name the `jobId` and `engineId` they belong to, so a single handler can route on `type` and update the right record. {% callout type="warning" title="HTTPS only" %} `callbackUrl` must use HTTPS. An HTTP URL is rejected when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point. {% /callout %} {% callout type="info" title="Handle unknown event types gracefully" %} Today the wire carries `provisioning.completed` and `provisioning.failed`. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler. {% /callout %} ## The completed payload When the job finishes, the payload carries the `summary` – the same inventory you would get by reading the job, pushed to you instead of polled. It names every brand voice, glossary item, and instruction the AI created on your engine, and lists any per-item failures it hit along the way. ```json { "type": "provisioning.completed", "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "summary": { "brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] }, "glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "..."] }, "instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "..."] }, "errors": [] } } ``` | Field | Description | | --- | --- | | `type` | `provisioning.completed` | | `jobId` | The provisioning job that finished (`pjb_` prefix) | | `engineId` | The engine it configured (`eng_` prefix) | | `summary` | What the AI created on the engine – counts and IDs per component, plus per-item failures in `errors` | The `summary` is the same object the job carries, and its field-by-field meaning – what each component is, how items map to locales, what lands in `errors` – is documented once on [What the AI extracts](/docs/api/provisioning/extraction). Here it is enough to know the completed payload hands you the IDs of everything the agent built, so your handler can record them or surface them in your dashboard without re-fetching the job. {% callout type="info" title="A non-empty errors array still arrives as completed." %} Per-item failures do not fail the job. If a single source would not crawl or one record could not be created, it lands in `summary.errors` and the rest are still applied to the engine – and the payload is still `provisioning.completed`, not `provisioning.failed`. The completed event means the job ran to the end; read `errors` to see what to fix. A `provisioning.failed` payload is sent when the run produced no usable engine at all. {% /callout %} ## The failed payload A provisioning job fails when the run produces nothing to work with – for example, every source fails to crawl, so the agent has no content to analyze. When that happens, you are still told. The payload type is `provisioning.failed`, and it carries an `error` string in place of the summary: ```json { "type": "provisioning.failed", "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "error": "All sources failed to crawl. No content available for analysis." } ``` | Field | Description | | --- | --- | | `type` | `provisioning.failed` | | `jobId` | The provisioning job that failed | | `engineId` | The engine that was created but left unconfigured | | `error` | Human-readable reason the job could not complete | Here is the part a skeptical reader is right to ask about: *if the job failed, did I lose the engine too?* You did not. The `engineId` in this payload is the same engine you received in the `202` – it still exists, created the moment you made the call, just without the configuration the failed run would have added. A failure costs you the extraction, never the engine. Adjust what you submitted and try again, or configure the engine by hand from the dashboard. When a job fails on crawling, the sources are usually the reason – [Source types](/docs/api/provisioning/sources) covers what makes a source worth pointing at. ## Handling a webhook A skeptical reader's first thought here is the right one: *my handler does real work – a database write, a notification, a dashboard refresh – so won't that hold the connection open long enough to time the webhook out?* It would, so don't make Lingo wait for it. **Return 200 first, then process.** Acknowledge receipt, then do the real work after the response is sent. The full delivery contract – why you acknowledge first, and the retry schedule that follows if you don't – is on the [signature and delivery](/docs/api/webhooks) page; the handler below shows the shape it takes for a provisioning payload. ```javascript app.post("/webhooks/provisioning", verifyWebhook, async (req, res) => { // Acknowledge first - the job ends once, so this fires once. res.status(200).send("ok"); const { type, jobId, engineId } = req.body; if (type === "provisioning.completed") { const { summary } = req.body; await db.engines.update({ where: { engineId }, data: { status: "ready", brandVoiceCount: summary.brandVoices.count, glossaryCount: summary.glossaryItems.count, instructionCount: summary.instructions.count, }, }); } if (type === "provisioning.failed") { console.error(`Provisioning failed: ${jobId} (${engineId})`, req.body.error); await db.engines.update({ where: { engineId }, data: { status: "needs_configuration" }, }); } }); ``` The `verifyWebhook` middleware is the one piece this page doesn't define. Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com/) spec – three headers, an HMAC over the raw body, a `whsec_` secret minted the first time you submit a job with a callback. Provisioning and [localization](/docs/api/localization/webhooks) callbacks use that scheme unchanged, so it lives once on [webhook signature verification](/docs/api/webhooks). Wire the middleware in before you trust a payload – an unverified body is an unauthenticated one. {% callout type="warning" title="Verify before you trust the body" %} Your endpoint is a public URL; anyone can `POST` to it. Verify the signature against the raw request body before acting on any payload – before you mark an engine ready or store the IDs it claims to have created. The how – the headers, the HMAC, the `whsec_` secret – is on the [signature verification](/docs/api/webhooks) page. {% /callout %} ## When delivery is the wrong tool The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away. If your endpoint was down when the result was delivered, the platform retries on the same schedule every Lingo webhook uses – and the result is not trapped in the callback. The records the AI created are the engine's actual configuration; the completed summary is a report of work that already happened on a real engine, not the only copy of it. So a stretch of downtime costs you a notification, never the engine. The retry schedule itself is on the [signature and delivery](/docs/api/webhooks) page. And if what you want is live progress while the engine configures – a crawling-then-configuring status in a UI, rather than a single callback to your server when it ends – that is the provisioning job WebSocket, not the webhook. It streams a snapshot on connect and progress events as the run advances, and you can connect at any point, even after the job has finished. {% card-grid %} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Stream snapshot and progress events while the engine configures, instead of one callback when it ends. Connect any time, even after it finishes." /%} {% link-card title="Webhook signature verification" href="/docs/api/webhooks" icon="shield" description="Verify the signature, read the headers, and handle the retry schedule – shared across all webhook deliveries." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="book" description="The summary's field-by-field meaning: brand voices, glossary items, instructions, and what lands in errors." /%} {% /card-grid %} - [What the AI extracts](https://lingo.dev/en/docs/api/provisioning/extraction): An AI agent turns your sources into three kinds of engine configuration – brand voices, glossary items, and instructions. This page covers what it looks for, how each maps to your locales via the * wildcard, and the summary that names every record it created. You have submitted your sources and the job is running. The engine ID came back in the `202`, and its configuration is filling in. This page answers the question that decides whether you trust the result: **what, exactly, is filling in?** "An AI configured my engine" is the sentence that makes an engineer wary, and the wariness is the right instinct. It could mean a black box you cannot inspect. It could mean records scattered across locales you cannot account for. It could mean the agent read a thin source, found nothing, and quietly created almost nothing. So this page is concrete about all three: the agent produces three kinds of configuration, each maps to your locales by a rule you can predict, and the job hands back a summary that names every record it created. The output is **ordinary records you can read and edit** – not a verdict you have to take on faith. New to async provisioning? Start with the [Async Provisioning API overview](/docs/api/provisioning) for the mental model, and [Source types](/docs/api/provisioning/sources) for what makes a source worth submitting. This page is about what comes out the other side. **On this page** - [The three components](#the-three-components) - [How each maps to a locale](#how-each-maps-to-a-locale) - [The output summary](#the-output-summary) - [Reading a thin summary](#reading-a-thin-summary) - [Next steps](#next-steps) ## The three components The agent reads everything – crawled pages and raw content alike – and creates three kinds of engine configuration. They are not a new, provisioning-only format. They are the exact same primitives you would otherwise create by hand on an [engine](/docs/platform/engines), which is why everything the agent makes is editable afterward in the dashboard, the same way you would edit anything you created yourself. | Component | What it looks for | Example | | --- | --- | --- | | [**Brand voices**](/docs/platform/brand-voices) | Tone, style, formality level, writing conventions | "Use formal German (Sie-form). Keep sentences concise and direct." | | [**Glossary items**](/docs/platform/glossaries) | Product names, technical terms, brand-specific translations, non-translatable terms | "Acme" → non-translatable, "workspace" → "Arbeitsbereich" (de) | | [**Instructions**](/docs/platform/instructions) | Formatting rules, cultural conventions, domain-specific guidelines | "Always format dates as DD.MM.YYYY in German translations." | These are the three things that make a translation sound like your product rather than a generic rendering – the formality you have chosen, the names you never translate, the date format you always use. The agent's job is to find those decisions wherever they are stated in your sources and write them down as records. One consequence worth stating plainly, because it sets the ceiling on what you should expect back: the agent extracts what is **stated**, not what is implied. A source that says a rule out loud yields a record; a source that merely demonstrates good tone without naming a rule yields little. That is a property of the sources, not the engine – [Source types](/docs/api/provisioning/sources) covers how to pick sources that say their rules out loud. ## How each maps to a locale A localization engine's configuration is keyed by target locale, so a record is not just *what* a rule is – it is *where* the rule applies. The agent assigns each record a locale by a rule you can predict, and the `*` wildcard is the part worth understanding before you read the output. - **Brand voices and instructions use `*` when they apply across all languages.** A tone rule like "keep sentences concise and direct" is not specific to German; it is how your product writes in every language. The agent assigns it the `*` target locale, and it applies to every locale the engine translates into. A rule that genuinely is language-specific ("use Sie-form in German") is assigned to that locale instead. - **Glossary items are created per locale pair**, because a translation is always from one language into a specific other one – "workspace" → "Arbeitsbereich" is a fact about German, and only German. - **Non-translatable terms are the exception, and they use `*`.** A brand name you never translate – "Acme" – is non-translatable in *every* language, so it is stored once against `*` rather than re-entered for each locale pair. So when you see `*` in a record the job created, it is not a placeholder or a gap. It means "this applies everywhere" – a global tone rule, a global instruction, or a term that is never translated in any language. A specific locale code means the opposite: this rule is scoped to exactly that language. {% callout type="info" title="Why the wildcard is a feature, not a default to override" %} A skeptical reading of `*` is "the agent didn't bother to figure out which locale this belongs to." It is the reverse. A brand voice or a non-translatable term that is correct in every language *should* be global – pinning it to one locale would mean it silently fails to apply to the others. The wildcard is how the configuration says "this is true regardless of language," which is exactly what a tone rule or a brand name usually is. {% /callout %} ## The output summary When the job completes, it returns a summary that names everything the agent created. This is the receipt: every record, counted and identified, plus a list of anything that failed. ```json { "brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] }, "glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "gi_B2c3D4e5", "..."] }, "instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "ins_B2c3D4e5", "..."] }, "errors": [] } ``` Each component reports a `count` and the `ids` of the records created – `bv_` for brand voices, `gi_` for glossary items, `ins_` for instructions. Those are not opaque acknowledgements; they are the IDs of real records on the engine. You can take any `gi_` from this list, open it in the dashboard, and read or change exactly what the agent extracted. The summary is how you go from "the AI did something" to "here are the twenty specific things it did," which is the whole difference between a black box and **ordinary records you can read and edit**. The summary reaches you on the channel you set up when you created the job: in the [webhook](/docs/api/provisioning/webhooks) payload your callback URL receives on completion, where it arrives as the `summary` field. If you are watching the job over the [WebSocket](/docs/api/provisioning/realtime), that is a liveness feed – it streams crawling and configuring progress, not this summary object. The summary travels with the completion webhook; the WebSocket tells you when to go read it. {% callout type="info" title="A failed item does not fail the job." %} If a single record cannot be created, it does not sink the rest. The failure is recorded in the `errors` array, the records that succeeded are still applied to the engine, and the job still completes. You get a partially configured engine plus a precise list of what to revisit – not an empty engine and a stack trace. The job fails as a whole when the run produces nothing to work from – for example, every source fails to crawl; that failure case, and its `provisioning.failed` payload, lives on [Webhook delivery](/docs/api/provisioning/webhooks). {% /callout %} ## Reading a thin summary The summary tells you not only what was created but, by its counts, whether the run was worth it. A `count` of `0` for a component is not an error – the summary is well-formed and the engine exists – but it is information. Three brand voices and twelve glossary items is a configured engine. Zero of everything and an empty `errors` array is an engine that came back nearly blank, and the agent is telling you it found few rules to extract. When that happens, the cause is almost always upstream: the sources stated few concrete rules for the agent to lift. The summary is where you notice it; [Source types](/docs/api/provisioning/sources) is where you fix it. The honest expectation to carry into your first run is that the receipt only reflects what your sources actually said – a rich summary means rich sources, and a thin one means there was little to find. That is why the summary matters as much as the engine: it lets you verify the configuration instead of assuming it. Read the counts, open a few records by their IDs, confirm the agent caught what you expected – **ordinary records you can read and edit**, with a receipt that tells you precisely what to check. ## Next steps {% card-grid %} {% link-card title="Source types" href="/docs/api/provisioning/sources" icon="book" description="What makes a source worth submitting – and why a thin summary usually traces back to here." /%} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="Receive the summary at your callback URL on completion, and the error payload on failure." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Watch crawling and configuring steps live as the engine fills in – then read the summary from the completion webhook." /%} {% link-card title="Translate with your new engine" href="/docs/api/localization" icon="lightning" description="Once the records are in place, fan content out to every locale through the async Localization API." /%} {% /card-grid %} - [Source types](https://lingo.dev/en/docs/api/provisioning/sources): Choose what to feed the provisioner. A link source points the crawler at a URL; a content source hands raw text straight to the AI agent. The configuration you get back is only as good as the sources you put in. Provisioning reads your existing material and turns it into engine configuration. The one decision that shapes the result is what you put in the request's `sources` array – because the engine you get back is only as good as what you point it at. Each entry is one of two kinds. A `link` source is a URL the platform fetches and crawls for you; a `content` source is raw text or markdown you pass in directly. You can mix both in the same request, up to ten sources total. This page covers the two kinds, what the platform does with each, and how to pick sources that produce a useful configuration rather than an empty one. The `sources` field itself lives on the create request – see [Create a provisioning job](/docs/api/provisioning/create) for the full payload and the 202 response. ## The two source kinds Every source is an object with a `type` and a `payload`. The `type` decides how the platform reads the `payload`. | Type | Payload | Reach for it when | | --- | --- | --- | | `link` | A URL to crawl | The context already lives on the web – your brand page, public docs, a published style guide, a glossary page. | | `content` | Raw text or markdown | The context lives in your head or a private doc – terminology lists, tone rules, product-name conventions, translation do's and don'ts. | ```json { "sources": [ { "type": "link", "payload": "https://acme.com/brand-guidelines" }, { "type": "link", "payload": "https://acme.com/docs/style-guide" }, { "type": "content", "payload": "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English." } ] } ``` Two links and one content block in the same array. The links point at pages that already hold the context; the content block carries rules that live nowhere public. Both feed the same extraction step. ## What the platform does with each The two kinds differ in one step – getting the text in front of the AI agent – and converge after that. A `link` source is fetched and converted to markdown before analysis. The platform crawls link sources **in parallel**, so ten URLs are not ten sequential round-trips – they are read concurrently, then handed to the agent as text. You give a URL; the platform does the fetching and the HTML-to-markdown reduction so the agent reads prose, not page markup. A `content` source skips that step. The text you send is passed to the AI agent directly, exactly as written. There is no crawl, no conversion, nothing between your words and the agent – which is why a content source is the most precise way to state a rule you already know. From there both kinds are the same input: the agent reads all of it and extracts brand voices, glossary items, and instructions. What it produces from that text, and the summary it returns, is its own subject – see [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="How deep does a link crawl go?" %} A `link` source is fetched and converted to markdown before the agent analyzes it. Whether the crawler follows links beyond the URL you supply – and to what depth – is not specified here. If you need a specific set of pages analyzed, the reliable approach is to list each one as its own `link` source rather than relying on a single URL to fan out. {% /callout %} ## Pick sources that carry signal This is the move that decides whether provisioning is worth running. The extraction is only as good as its input, and the failure here is quiet: a job against weak sources still completes, still creates an engine – but a nearly empty one, and you find out later when translations ignore conventions you assumed were captured. The completion arrives like any other – see [Webhook delivery](/docs/api/provisioning/webhooks) – so nothing flags the gap for you. {% callout type="info" title="Provide meaningful sources" %} The quality of the extracted configuration depends on the quality of your input. Link sources should point to pages with useful context – brand guidelines, style guides, product documentation, glossaries. Raw content sources should contain concrete terminology, tone guidance, or translation rules. Generic marketing pages or login screens produce little useful configuration. {% /callout %} The pattern behind the callout: the agent extracts what is **stated**, not what is implied. A page that says "we write in a friendly, direct German that uses Sie, never Du" yields a brand voice. A glossary page that lists "workspace → Arbeitsbereich" yields a glossary item. A polished landing page that *demonstrates* good tone without naming a single rule yields almost nothing, because there is no rule on it to lift. When in doubt, prefer the source that says the rule out loud – which is often a `content` block you write in a sentence rather than a page you hope the agent infers from. ## One weak source won't sink the job A natural worry follows from feeding several sources at once: if one URL is dead or one block is thin, does the whole request fail? It does not. Sources are read independently, and a per-item failure is recorded rather than fatal – a dead link or an unreadable block is skipped, and the agent works from what it could read. The job fails as a whole only when no source could be read at all, leaving nothing to analyze. The exact shapes of those outcomes – the per-item failures recorded on success, and the failure payload when nothing could be read – belong to [What the AI extracts](/docs/api/provisioning/extraction) and [Webhook delivery](/docs/api/provisioning/webhooks). So you can list a candidate set without auditing every URL first: the strong sources contribute, the weak ones drop out, and you read the output summary to see what actually landed. Point it at what you already have – then check what came back. ## Next steps {% card-grid %} {% link-card title="Create a provisioning job" href="/docs/api/provisioning/create" icon="gear" description="The full create request the sources array is part of, with the 202 response and engine ID." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="gear" description="Brand voices, glossary items, and instructions the agent builds from your sources, plus the output summary." /%} {% link-card title="Live progress (WebSocket)" href="/docs/api/provisioning/realtime" icon="gear" description="Watch crawling and configuring steps as the job reads your sources and builds the engine." /%} {% /card-grid %} - [Create a provisioning job](https://lingo.dev/en/docs/api/provisioning/create): POST /jobs/provisioning with a name and up to 10 sources, and get back an engine ID you can use immediately. The 202 hands you the engine before the AI finishes configuring it from your content in the background. Submit the sources you already have, get an engine back. `POST /jobs/provisioning` takes a name for a new engine and up to 10 sources – links to crawl or raw text – and returns `202 Accepted` with the engine's ID. You do not wait for the AI to finish reading your content: the engine exists the moment the call returns, and its configuration is applied as the job runs. ``` POST /jobs/provisioning ``` This page covers the create call: its parameters, the request shape, and the `202` response. New to async provisioning? Start with the [Async Provisioning API overview](/docs/api/provisioning) for the mental model. What counts as a good source is its own page – [Source types](/docs/api/provisioning/sources) – and what the AI pulls out of them lives on [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="Authentication" %} Pass your API key in the `X-API-Key` header. Keys are organization-scoped and reach every engine in the organization. See [Authentication](/docs/api/authentication) for details. {% /callout %} ## Parameters Only `engine.name` is required. Everything else shapes what the engine learns – or, if you omit it all, leaves you with a clean engine on defaults. | Parameter | Type | Description | | --- | --- | --- | | `engine.name` | string | Name for the new localization engine. | | `engine.description` | string (optional) | Free-text description for the engine. | | `locales` | string[] (optional) | BCP-47 target locales to configure for, e.g. `["es", "ja", "de"]`. | | `sources` | array (optional) | Up to 10 sources to analyze. Each is a `link` (a URL the platform crawls) or `content` (raw text or markdown). See [Source types](/docs/api/provisioning/sources). | | `callbackUrl` | string (optional) | HTTPS webhook URL for the completion result. HTTPS only – HTTP callback URLs are rejected. See [Webhook delivery](/docs/api/provisioning/webhooks). | ## Request A source is a `{ type, payload }` object. Point `link` sources at pages with real context – brand guidelines, style guides, product docs – and use `content` for terminology and tone rules you can paste in directly. The request below mixes both: two pages to crawl and one block of explicit rules. {% tabs %} {% tab label="Node.js" %} ```javascript const response = await fetch("https://api.lingo.dev/jobs/provisioning", { method: "POST", headers: { "X-API-Key": process.env.LINGO_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ engine: { name: "Acme Corp Engine", description: "Production localization engine for acme.com", }, locales: ["de", "fr", "ja", "es"], sources: [ { type: "link", payload: "https://acme.com/brand-guidelines" }, { type: "link", payload: "https://acme.com/docs/style-guide" }, { type: "content", payload: "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English.", }, ], callbackUrl: "https://your-app.com/webhooks/provisioning", }), }); const { jobId, engineId, status } = await response.json(); // 202 back right away. // status: "in_progress" – the AI is reading your sources. console.log(engineId); // "eng_X1y2Z3a4B5c6D7e8" – usable right now ``` {% /tab %} {% tab label="Python" %} ```python import requests response = requests.post( "https://api.lingo.dev/jobs/provisioning", headers={ "X-API-Key": "your_api_key", "Content-Type": "application/json", }, json={ "engine": { "name": "Acme Corp Engine", "description": "Production localization engine for acme.com", }, "locales": ["de", "fr", "ja", "es"], "sources": [ {"type": "link", "payload": "https://acme.com/brand-guidelines"}, {"type": "link", "payload": "https://acme.com/docs/style-guide"}, { "type": "content", "payload": "Brand name 'Acme' is never translated. Use formal tone in German (Sie-form). Product names: AcmeFlow, AcmeSync, AcmeVault - always keep in English.", }, ], "callbackUrl": "https://your-app.com/webhooks/provisioning", }, ) result = response.json() # status: "in_progress" – the AI is reading your sources. print(result["engineId"]) # "eng_X1y2Z3a4B5c6D7e8" – usable right now ``` {% /tab %} {% /tabs %} ## Response (202 Accepted) The call returns without waiting for the crawl or the analysis – it hands you a job ID to track and an engine ID that is live from this point on. ```json { "jobId": "pjb_A1b2C3d4E5f6G7h8", "engineId": "eng_X1y2Z3a4B5c6D7e8", "status": "in_progress" } ``` | Field | Description | | --- | --- | | `jobId` | Provisioning job ID (`pjb_` prefix). Track the job by [connecting a WebSocket](/docs/api/provisioning/realtime) for live progress, or receive the result on your [webhook](/docs/api/provisioning/webhooks) when it finishes. | | `engineId` | The new engine's ID (`eng_` prefix). Usable immediately – the configuration the AI extracts is applied to it as the job runs. | | `status` | `in_progress` when you provide sources; `completed` when you do not (see below). | The detail that makes this an async call worth making rather than a wait: `engineId` comes back in the same `202`, and it points at a real engine right away. You can store it, send a [synchronous Localize](/docs/api/localize) request through it, or wire it into your app before the AI has finished reading a single source. As brand voices, glossary items, and instructions are extracted, the platform applies each one to that same engine – the engine exists before its configuration does. To know exactly what the job created, read [What the AI extracts](/docs/api/provisioning/extraction). {% callout type="info" title="No sources? You get an engine, not a wait." %} Omit `sources` and there is nothing to crawl, so the engine is created with default model configuration and returned with `status: "completed"` in the same response. That is the fast path when you want an empty engine to configure yourself – one call, a ready `engineId`, no background job to track. {% /callout %} ## Next steps {% card-grid %} {% link-card title="Source types" href="/docs/api/provisioning/sources" icon="gear" description="link vs content sources, and what makes a source worth analyzing." /%} {% link-card title="What the AI extracts" href="/docs/api/provisioning/extraction" icon="book" description="Brand voices, glossary items, and instructions – plus the summary the job returns." /%} {% link-card title="Webhook delivery" href="/docs/api/provisioning/webhooks" icon="shield" description="Receive the completion result at your callback URL, and verify the signature." /%} {% /card-grid %} ## Docs – Cli - [Cost Estimate](https://lingo.dev/en/docs/cli/cost-estimate): Preview the cost of a run before any translation happens - the Lingo.dev CLI prices the exact change-delta against your localization engine and exits without translating. `lingo.dev run --estimate` prices a run before it happens. The Lingo.dev CLI computes the same change-delta as a real run, prices it through your localization engine, prints a per-locale cost breakdown, and exits. Nothing is translated, written, or billed. ## Estimating a run ```bash npx lingo.dev@latest run --estimate ``` The CLI prints a per-locale breakdown and a total: ``` [Estimate] ✔ Delta computed for 3 task(s) › es: ~$0.04 (12,300 chars, ~3,075 tokens) › de: ~$0.04 (12,300 chars, ~3,075 tokens) › fr: ~$0.04 (12,300 chars, ~3,075 tokens) ✔ Estimated cost: ~$0.12 (estimate, not a quote — nothing was translated) ``` By default, only pending content - the change-delta against [`i18n.lock`](/docs/cli/lockfile) - is priced. An empty delta prints `$0.00 - nothing needs translation`. ## Full-project estimate Add `--force` to price every string, regardless of what is already translated: ```bash npx lingo.dev@latest run --estimate --force ``` This mirrors a `run --force` retranslation, so the estimate covers the whole project rather than just the delta. ## Estimate vs. a real run | | `run --estimate` | `run` | | --- | --- | --- | | **Computes the delta** | Yes | Yes | | **Translates content** | No | Yes | | **Writes target files** | No | Yes | | **Billed** | No | Yes | ## Output fields | Field | Description | | --- | --- | | `chars` | Translatable source characters in the delta for that locale. | | `tokens` | Estimated output tokens, derived from a chars-to-tokens heuristic. | | `cost` | Approximate cost for that locale. Summed into the total. | {% callout type="info" %} Estimates are approximate, not a quote - actual cost depends on the model and the real token count. `--estimate` requires the Lingo.dev provider and cannot be combined with `--watch` or `--frozen`. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the change-delta is computed" icon="lock" /%} {% link-card title="Large Projects" href="/docs/cli/large-projects" description="Scale runs across thousands of keys" icon="code" /%} {% link-card title="Localization API" href="/docs/api/localize" description="Estimate and translate programmatically" icon="lightning" /%} {% link-card title="Reports" href="/docs/platform/reports" description="Monitor translation volume and cost" icon="gear" /%} {% /card-grid %} - [Supported Formats](https://lingo.dev/en/docs/cli/supported-formats): The Lingo.dev CLI supports 25+ file formats - JSON, YAML, Markdown, MJML, Android XML, Xcode strings, Flutter ARB, PO, CSV, and more - each with a dedicated bucket type. The Lingo.dev CLI uses bucket types to parse and translate different file formats. Each bucket type is a dedicated parser designed for a specific format. Configure one or more buckets in your [`i18n.json`](/docs/cli/configuration) to define which files the CLI should translate. ## Bucket types | Bucket type | Format | Output mode | `[locale]` required | | --- | --- | --- | --- | | `json` | JSON files | Separate files per locale | Yes | | `json5` | JSON5 files | Separate files per locale | Yes | | `jsonc` | JSONC files (with comments) | Separate files per locale | Yes | | `json-dictionary` | JSON dictionary (flat key-value) | Separate files per locale | Yes | | `yaml` | YAML files | Separate files per locale | Yes | | `yaml-root-key` | YAML with locale root keys | Mutates source file | No | | `markdown` | Markdown files | Separate files per locale | Yes | | `mdx` | MDX files | Separate files per locale | Yes | | `markdoc` | Markdoc files | Separate files per locale | Yes | | `html` | HTML files | Separate files per locale | Yes | | `mjml` | MJML email templates | Separate files per locale | Yes | | `android` | Android XML resources | Separate files per locale | Yes | | `xcode-strings` | Xcode `.strings` files | Separate files per locale | Yes | | `xcode-stringsdict` | Xcode `.stringsdict` files | Separate files per locale | Yes | | `xcode-xcstrings` | Xcode `.xcstrings` catalogs | Mutates source file | No | | `flutter` | Flutter ARB files | Separate files per locale | Yes | | `po` | GNU gettext PO files | Separate files per locale | Yes | | `properties` | Java `.properties` files | Separate files per locale | Yes | | `csv` | CSV files | Mutates source file | No | | `csv-per-locale` | CSV files (one per locale) | Separate files per locale | Yes | | `xml` | Generic XML files | Separate files per locale | Yes | | `xliff` | XLIFF files | Separate files per locale | Yes | | `srt` | SRT subtitle files | Separate files per locale | Yes | | `vtt` | VTT subtitle files | Separate files per locale | Yes | | `php` | PHP localization arrays | Separate files per locale | Yes | | `typescript` | TypeScript files | Separate files per locale | Yes | | `vue-json` | Vue i18n JSON blocks | Separate files per locale | Yes | | `txt` | Plain text files | Separate files per locale | Yes | ## Output modes Buckets operate in one of two output modes: **Separate files per locale** - the CLI creates a distinct file for each target language. Include patterns must contain the `[locale]` placeholder: ```json { "buckets": { "json": { "include": ["locales/[locale].json"] } } } ``` This produces `locales/en.json`, `locales/es.json`, `locales/fr.json`, etc. **Mutates source file** - the CLI writes translations back into the same file that contains the source content. The `[locale]` placeholder is not used: ```json { "buckets": { "csv": { "include": ["translations.csv"] } } } ``` CSV files typically store all locales in columns within a single file. Xcode `.xcstrings` catalogs and YAML with root keys work similarly. ## Configuration examples ### Web app with JSON ```json { "buckets": { "json": { "include": ["src/locales/[locale].json"], "lockedKeys": ["brand/name"] } } } ``` ### Documentation site with Markdown ```json { "buckets": { "markdown": { "include": ["docs/[locale]/*.md"], "exclude": ["docs/[locale]/drafts/*.md"] } } } ``` ### Mobile app (iOS + Android) ```json { "buckets": { "xcode-xcstrings": { "include": ["ios/Localizable.xcstrings"] }, "android": { "include": ["android/app/src/main/res/values-[locale]/strings.xml"] } } } ``` ### Monorepo with multiple formats ```json { "buckets": { "json": { "include": ["apps/web/locales/[locale].json"] }, "mdx": { "include": ["packages/docs/content/[locale]/*.mdx"] }, "flutter": { "include": ["apps/mobile/lib/l10n/app_[locale].arb"] } } } ``` ## Bucket-specific features Some buckets support additional features beyond include/exclude patterns: | Feature | Supported buckets | Description | | --- | --- | --- | | [Key Locking](/docs/cli/key-locking) | Key-value formats (JSON, YAML, etc.) | Copy source values without translation | | [Key Ignoring](/docs/cli/key-ignoring) | Key-value formats | Exclude keys from target files entirely | | [Key Preserving](/docs/cli/key-preserving) | Key-value formats | Initialize once, then protect from updates | | [Translator Notes](/docs/cli/translator-notes) | JSONC, XCStrings | Provide context via comments to improve translation | ## Include pattern rules - Patterns are relative to the `i18n.json` file location - Use `[locale]` as the placeholder for locale codes (required for "separate files" buckets) - Asterisk (`*`) matches any filename: `locales/[locale]/*.json` - Recursive patterns (`**`) match files at any depth: `src/**/[locale].json`, `config/locales/**/[locale].yml`. When a pattern uses `**`, `node_modules`, `.git`, `dist`, `build`, `.next`, and `.turbo` are excluded by default — add your own `exclude` entries on top as needed (requires CLI 0.135.0+) - File extensions do not affect parsing - the bucket type determines the parser ## Next Steps {% card-grid %} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% link-card title="Key Locking" href="/docs/cli/key-locking" description="Protect specific keys from translation" icon="shield" /%} {% link-card title="Existing Translations" href="/docs/cli/existing-translations" description="Add the CLI to a project that already has translations" icon="globe" /%} {% link-card title="Setup" href="/docs/cli/setup" description="Install and configure the CLI" icon="rocket" /%} {% /card-grid %} - [Monorepos](https://lingo.dev/en/docs/cli/monorepo): Four patterns for localizing monorepos – a single recursive config that auto-discovers packages, a single config with shared locales, per-package configs with different locales, or per-package configs with separate engines. The CLI supports monorepos natively. Use a single `i18n.json` at the root with a recursive `**` glob to auto-discover packages, a single root config with explicit per-package paths, or separate `i18n.json` files in each package combined with a matrix-strategy GitHub Action. ## When to use each pattern | Scenario | Pattern | Config files | GitHub Action | | --- | --- | --- | --- | | All packages share the same layout and locales, and you want new packages picked up automatically | Recursive single config | One `i18n.json` at root with a `**` glob | One step, default `working-directory` | | All packages share source and target locales | Single config | One `i18n.json` at root | One step, default `working-directory` | | Packages need different target locales | Per-package configs | One `i18n.json` per package | Matrix strategy with `working-directory` | | Packages need different engines (glossary, brand voice) | Per-package configs + separate engines | One `i18n.json` per package, each with its own `engineId` | Matrix strategy with `working-directory` | ## Pattern 1: Recursive single config When every package follows the same layout (e.g. `apps//locales/[locale].json`), use a recursive `**` glob in a single root `i18n.json`. New packages are picked up automatically as long as they match the layout: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["apps/**/locales/[locale].json"] } } } ``` The CLI ignores `node_modules`, `.git`, `dist`, `build`, `.next`, and `.turbo` by default, so recursive patterns won't descend into vendored or build trees. Add your own `exclude` entries for anything else you want skipped. The GitHub Action is identical to Pattern 2 below – one step, default working directory. Use this when every package shares the same source locale, target locales, layout, and localization engine, and you don't want to edit `i18n.json` every time you add a package. ## Pattern 2: Single config, shared locales One `i18n.json` at the repository root. Bucket paths reach into each package explicitly: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": [ "apps/web/locales/[locale].json", "apps/dashboard/locales/[locale].json" ] }, "markdown": { "include": ["packages/docs/content/[locale]/*.md"] } } } ``` GitHub Action – standard single step: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Use this when every package shares the same source locale, target locales, and localization engine, but the layouts differ enough that a recursive glob would be too broad. ## Pattern 3: Per-package configs, different locales Each package has its own `i18n.json` with independent locale targets: **apps/web/i18n.json:** ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja", "ko", "zh-Hans"] }, "buckets": { "json": { "include": ["locales/[locale].json"] } } } ``` **apps/marketing/i18n.json:** ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "json": { "include": ["locales/[locale].json"] }, "markdown": { "include": ["content/[locale]/*.md"] } } } ``` GitHub Action – matrix strategy with `working-directory`: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest strategy: matrix: package: [apps/web, apps/marketing] steps: - uses: actions/checkout@v4 - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} working-directory: ${{ matrix.package }} pull-request: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Each matrix entry runs the CLI scoped to that package's `i18n.json`. The action translates only the files that package declares. ## Pattern 4: Per-package configs, separate engines Same structure as Pattern 3, but each package connects to a different localization engine. This gives each package its own glossary, brand voice, and model configuration. **apps/product/i18n.json:** ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["locales/[locale].json"] } }, "engineId": "eng_ProductApp123" } ``` **apps/docs/i18n.json:** ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr"] }, "buckets": { "markdown": { "include": ["content/[locale]/*.md"] } }, "engineId": "eng_DocsEngine456" } ``` The GitHub Action YAML is identical to Pattern 3 – the `engineId` in each `i18n.json` routes translations to the correct engine automatically. ## How the lockfile works Each `i18n.json` location produces its own `i18n.lock` file in the same directory. The lockfile tracks which source content has been translated, enabling incremental updates. In Patterns 1 and 2, there is one lockfile at the root. In Patterns 3 and 4, each package has its own lockfile. ## FAQ **Will new packages be picked up automatically?** Only with Pattern 1 (recursive `**`). With Patterns 3 and 4, you add a new entry to the workflow's `matrix.package` list when you add a package. **Can I have different locales per package?** Yes. Use Pattern 3 or 4. Each package's `i18n.json` declares its own `locale.targets` array independently. **Do I need one workflow file or multiple?** One workflow file with a matrix strategy handles all packages. Each matrix entry runs against a different `working-directory`, scoping the CLI to that package's config. **Can packages share a glossary but have different locales?** Yes. Point both packages to the same `engineId` but configure different `locale.targets` in each `i18n.json`. The engine's glossary applies to whichever locale pairs are requested. ## Next Steps {% card-grid %} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="BracketsCurly" /%} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="All workflow patterns and inputs" icon="GitBranch" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="25+ file formats with examples" icon="Stack" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route translations through your engine" icon="Plug" /%} {% /card-grid %} - [Extract Keys with AI](https://lingo.dev/en/docs/cli/extract-keys-with-ai): AI-assisted i18n setup for React projects via the Lingo.dev React MCP server - locale-aware routes, language switcher, and locale detection from a single prompt. The [Lingo.dev React MCP](/docs/react/mcp) gives AI coding assistants the tools to set up internationalization in your React codebase — locale-aware routes, language switcher, and locale detection — from a single prompt. Inspired by the Sequential Thinking MCP, it breaks i18n setup into a guided checklist the agent follows step by step, so keys and translation infrastructure are extracted and wired up automatically. {% callout type="info" title="Not to be confused with the Lingo.dev MCP" %} The **Lingo.dev React MCP** is focused on scaffolding i18n in React codebases. The separate **Lingo.dev MCP** is for working with localization engines (glossaries, brand voices, scorers, model configs). This page is about the React MCP. {% /callout %} ## How it works The Lingo.dev React MCP server exposes four tools to the agent: | Tool | Purpose | | --- | --- | | `i18n_checklist` | A step-by-step implementation guide that coordinates the entire setup. The agent calls it at each step to know what to do next. | | `get_project_context` | Captures the project's architecture — framework, router, directory structure — to inform the implementation strategy. | | `get_framework_docs` | Retrieves official framework documentation for the detected framework (Next.js, React Router, TanStack Start). | | `get_i18n_library_docs` | Retrieves documentation for i18n libraries (e.g., react-intl) used during provider and component setup. | The `i18n_checklist` tool is the coordinator. It walks the agent through 13 steps — from project analysis through locale routing, translation setup, language switcher, and build validation. ## What gets implemented A typical Lingo.dev React MCP-guided setup produces: - **Locale-aware routes** - URLs prefixed with the active locale (`/en/about`, `/es/about`) - **Language switcher** - A UI component for switching between supported locales - **Locale detection** - Automatic detection of the user's preferred language - **Translation infrastructure** - Provider setup, translation files, and helper functions with keys extracted from your components ## Supported frameworks | Framework | Versions | | --- | --- | | Next.js App Router | v13-16 | | Next.js Pages Router | v13-16 | | TanStack Start | v1 | | React Router | v7 | ## Usage Once the Lingo.dev React MCP is connected to your AI coding assistant, prompt it: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". The agent calls `i18n_checklist` to start, then follows the guided steps — calling the other tools as needed. The result is a working i18n setup tailored to your framework and project structure. {% callout type="info" %} AI-assisted coding is inherently non-deterministic. The Lingo.dev React MCP improves consistency through its checklist-driven approach, but exact results may vary between runs. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="React MCP overview" href="/docs/react/mcp" description="How the Lingo.dev React MCP works and what it implements" icon="book" /%} {% link-card title="Setup" href="/docs/react/mcp/setup" description="Connect the Lingo.dev React MCP to your AI coding assistant" icon="rocket" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor" icon="code" /%} {% /card-grid %} - [Adding Languages](https://lingo.dev/en/docs/cli/adding-languages): Add new target languages to your Lingo.dev CLI project by updating the targets array in i18n.json - the CLI generates complete translation files for new locales in a single run. Add new target languages by updating the `targets` array in `i18n.json` and running the CLI. Complete translation files are generated for new locales, while existing translations remain unchanged. ## Add a language Update your [`i18n.json`](/docs/cli/configuration) configuration: ```json { "locale": { "source": "en", "targets": ["es", "fr", "de"] } } ``` Run the CLI: ```bash npx lingo.dev@latest run ``` The CLI creates complete translation files for each new locale: ``` locales/ en.json (source - unchanged) es.json (existing - unchanged) fr.json (existing - unchanged) de.json (new - fully translated) ``` ## Existing vs. new languages The CLI handles existing and new languages differently: | | Existing languages | New languages | | --- | --- | --- | | **Behavior** | Only missing keys are translated | Complete files are generated from scratch | | **Existing content** | Preserved | N/A | ## Regional variants The CLI supports regional language variants using BCP 47 tags: ```json { "locale": { "source": "en-US", "targets": ["en-GB", "es-ES", "es-MX", "fr-FR", "fr-CA", "pt-BR", "pt-PT"] } } ``` Each variant gets a distinct translation file with region-appropriate terminology, spelling, and tone. ## Targeted generation Generate translations for a specific language without processing all targets: ```bash npx lingo.dev@latest run --target-locale de ``` This is useful when adding one language at a time to review quality before expanding further. ## Removing languages Remove a locale from the `targets` array and the CLI stops processing it. Existing files are not deleted - remove them manually if needed. ## Next Steps {% card-grid %} {% link-card title="Existing Translations" href="/docs/cli/existing-translations" description="Integrate with projects that already have translations" icon="globe" /%} {% link-card title="Parallel Processing" href="/docs/cli/parallel-processing" description="Process multiple languages concurrently" icon="lightning" /%} {% link-card title="Large Projects" href="/docs/cli/large-projects" description="Strategies for scaling localization" icon="terminal" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% /card-grid %} - [Automatic Retranslation](https://lingo.dev/en/docs/cli/automatic-retranslation): The Lingo.dev CLI automatically retranslates content when source text changes - it detects modified fingerprints in the lockfile and sends only the affected keys through the translation pipeline. The Lingo.dev CLI automatically retranslates content when you modify the source text. The [lockfile](/docs/cli/lockfile) stores fingerprints for every source string - when a fingerprint changes, the CLI sends the updated content through the translation pipeline and replaces the old translation in all target files. ## How it works ```json // locales/en.json (original) { "button.save": "Save changes" } // locales/es.json (generated) { "button.save": "Guardar cambios" } ``` After editing the source: ```json // locales/en.json (updated) { "button.save": "Save all changes" } ``` Running `npx lingo.dev@latest run` detects the new fingerprint and retranslates: ```json // locales/es.json (updated automatically) { "button.save": "Guardar todos los cambios" } ``` Unchanged keys are skipped entirely - only the modified key is sent to the translation backend. ## What triggers retranslation | Change | Retranslated? | | --- | --- | | Source text modified | Yes | | Source text unchanged | No | | Key renamed, content unchanged | No - translation is [carried forward](/docs/cli/key-renaming) | | Key deleted from source | Translation removed from targets | | New key added to source | Translated as new content | ## Overrides and automatic retranslation If you've [manually overridden](/docs/cli/overrides) a translation, automatic retranslation replaces your override when the source changes. This is by design - a source change signals that the meaning has shifted and a fresh translation is needed. To retranslate content for other reasons (model change, prompt update), see [Retranslation](/docs/cli/retranslation). ## Next Steps {% card-grid %} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="Manual retranslation options" icon="lightning" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How fingerprinting tracks changes" icon="shield" /%} {% link-card title="Overrides" href="/docs/cli/overrides" description="How manual edits are preserved" icon="gear" /%} {% link-card title="Remove Translations" href="/docs/cli/remove-translations" description="Delete translations from target files" icon="terminal" /%} {% /card-grid %} - [i18n.json Configuration](https://lingo.dev/en/docs/cli/configuration): Complete reference for the i18n.json configuration file - locale settings, bucket patterns, provider configuration, engine connection, and advanced options. `i18n.json` is the configuration file that controls the Lingo.dev CLI and [CI/CD integrations](/docs/cli/large-projects). It defines which languages to translate, where translatable content lives, and which translation backend to use. ## Minimal example ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "ja"] }, "buckets": { "json": { "include": ["locales/[locale].json"] } } } ``` The `$schema` field enables IDE autocompletion and validation. The `version` field tracks the schema version for automatic migration compatibility. ## Locale The `locale` section defines the source and target languages: ```json { "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] } } ``` | Field | Description | | --- | --- | | `locale.source` | The language your source content is written in. All translations flow from this locale. | | `locale.targets` | Array of target languages. Each target produces a separate file (or section) depending on the bucket format. | Language codes follow the [BCP 47](https://tools.ietf.org/rfc/rfc5646.txt) standard - `en`, `en-US`, `es-ES`, `zh-Hans`, and platform-specific formats like Android's `en-rUS` are all supported. To list available locale codes: ```bash npx lingo.dev@latest show locale sources # Available source languages npx lingo.dev@latest show locale targets # Available target languages ``` ## Buckets Buckets define file discovery patterns and processing rules. Each bucket key specifies a file format, and its value configures which files to include or exclude: ```json { "buckets": { "json": { "include": ["locales/[locale].json"], "exclude": ["locales/[locale]/internal.json"] }, "markdown": { "include": ["docs/[locale]/*.md"] } } } ``` | Field | Description | | --- | --- | | `include` | Array of file patterns with `[locale]` placeholder. Supports glob wildcards (`*`). | | `exclude` | Optional. Array of patterns to skip during processing. | | `lockedKeys` | Optional. Keys whose values are copied from source without translation. See [Key Locking](/docs/cli/key-locking). | | `ignoredKeys` | Optional. Keys excluded from translation entirely - they don't appear in target files. See [Key Ignoring](/docs/cli/key-ignoring). | | `preservedKeys` | Optional. Keys initialized once from source, then protected from automatic updates. See [Key Preserving](/docs/cli/key-preserving). | | `injectLocale` | Optional. Keys where the target locale code is injected automatically. | ### Include patterns Include patterns use the `[locale]` placeholder, which resolves to your configured locale codes at runtime: - `locales/[locale].json` → `locales/en.json`, `locales/es.json` - `docs/[locale]/*.md` → `docs/en/*.md`, `docs/es/*.md` {% callout type="warning" %} Recursive glob patterns (`**/*.json`) are not supported. Use explicit directory paths instead. {% /callout %} ### Custom locale delimiters By default, locale codes in filenames use a dash (`-`) delimiter: `en-US.json`. To use underscores instead, pass an object with a `delimiter` field: ```json { "include": [ "standard/[locale].json", { "path": "legacy/[locale].json", "delimiter": "_" } ] } ``` This produces `legacy/en_US.json` instead of `legacy/en-US.json`. ### Key path notation The `lockedKeys`, `ignoredKeys`, and `preservedKeys` arrays use forward slash (`/`) notation for nested keys and asterisk (`*`) for wildcards: ```json { "lockedKeys": ["brand/name", "config/*"] } ``` For the full list of supported bucket types, see [Supported Formats](/docs/cli/supported-formats). ## Provider The `provider` section configures a raw LLM provider for translation. This section is optional - when omitted, the CLI uses a [localization engine](/docs/platform/engines) on Lingo.dev. ```json { "provider": { "id": "openai", "model": "gpt-4o-mini", "prompt": "Translate the provided text from {source} to {target}." } } ``` | Field | Description | | --- | --- | | `provider.id` | Provider identifier: `openai`, `anthropic`, `google`, `mistral`, `openrouter`, or `ollama`. | | `provider.model` | Model name from the provider (e.g., `gpt-4o-mini`, `claude-3-haiku`). | | `provider.prompt` | System prompt. `{source}` and `{target}` are replaced with locale codes at runtime. | | `provider.baseUrl` | Optional. Custom API endpoint (required for Ollama: `http://localhost:11434`). | ## Engine connection To route translations through a specific [localization engine](/docs/platform/engines), add the `engineId` field: ```json { "engineId": "eng_SxjMwMsfOIsvV1wh" } ``` When `engineId` is set, every translation request applies your engine's [brand voice](/docs/platform/brand-voices), [glossary](/docs/platform/glossaries), [instructions](/docs/platform/instructions), and [model configuration](/docs/platform/llm-models) automatically. If omitted and `LINGO_API_KEY` is set, the CLI uses the default engine in your organization. For the full setup guide, see [Connect Your Engine](/docs/platform/connect-your-engine). ## Environment variables | Variable | Required | Description | | --- | --- | --- | | `LINGO_API_KEY` | For Lingo.dev Engine | Your Lingo.dev API key. | | `LINGO_API_URL` | No | Custom API base URL (for self-hosted or staging). | | `OPENAI_API_KEY` | For OpenAI provider | OpenAI API key. | | `ANTHROPIC_API_KEY` | For Anthropic provider | Anthropic API key. | | `GOOGLE_API_KEY` | For Google provider | Google API key. | | `MISTRAL_API_KEY` | For Mistral provider | Mistral API key. | | `OPENROUTER_API_KEY` | For OpenRouter provider | OpenRouter API key. | ## Full example ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["locales/[locale].json"], "lockedKeys": ["brand/name", "brand/tagline"], "ignoredKeys": ["internal/*"] }, "markdown": { "include": ["docs/[locale]/*.md"] } }, "engineId": "eng_SxjMwMsfOIsvV1wh" } ``` ## Version migration The CLI automatically migrates older `i18n.json` configurations to the latest schema version. It creates a backup of your current file, updates the schema, and preserves all settings. No manual intervention is required. ## Next Steps {% card-grid %} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="All bucket types and their configuration" icon="file-code" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the lockfile tracks translation state" icon="shield" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route translations through your localization engine" icon="plug" /%} {% link-card title="Setup" href="/docs/cli/setup" description="Install the CLI and get started" icon="rocket" /%} {% /card-grid %} - [Existing Translations](https://lingo.dev/en/docs/cli/existing-translations): The Lingo.dev CLI integrates with projects that already have translation files - it detects existing content, fills in missing keys, and preserves your previous work. The Lingo.dev CLI integrates with projects that already have translation files. It compares source keys against existing target files, generates only the missing translations, and leaves your existing work untouched. ## How it works When you run the CLI on a project with partial translations, it performs a gap analysis: ```json // locales/en.json (source - 4 keys) { "welcome": "Welcome to our app", "button.save": "Save", "button.cancel": "Cancel", "error.network": "Network error" } // locales/es.json (existing - 2 keys translated) { "welcome": "Bienvenido a nuestra aplicación", "button.save": "Guardar" } ``` Running `npx lingo.dev@latest run` fills in only the missing keys: ```json // locales/es.json (after run - all 4 keys present) { "welcome": "Bienvenido a nuestra aplicación", "button.save": "Guardar", "button.cancel": "Cancelar", "error.network": "Error de red" } ``` The existing `welcome` and `button.save` translations remain unchanged. ## First run On the first run, the CLI creates an [`i18n.lock`](/docs/cli/lockfile) file based on your current state. This lockfile records fingerprints for all source content, ensuring existing translations are not regenerated on subsequent runs - even if they were originally created by a different tool. {% callout type="warning" %} Ensure your target language files do not contain content in the source language. Having untranslated source text in target files can interfere with gap detection. {% /callout %} ## Migrating from other tools The CLI works with translation files created by any tool, as long as they follow a supported format (JSON, YAML, PO, etc.): {% steps %} {% step title="Configure i18n.json" %} Set up bucket patterns that match your existing file locations. {% /step %} {% step title="Run translations" %} The CLI fills in missing keys while preserving existing translations. {% /step %} {% step title="Review and commit" %} Only the gaps are filled. Your existing translations stay intact. {% /step %} {% /steps %} ## Refreshing translations If existing translations have quality issues, you can selectively retranslate: ```bash # Retranslate all Spanish content npx lingo.dev@latest run --force --target-locale es # Retranslate a specific key across all languages npx lingo.dev@latest run --force --key error.network ``` For more options, see [Retranslation](/docs/cli/retranslation). ## Next Steps {% card-grid %} {% link-card title="Adding Languages" href="/docs/cli/adding-languages" description="Expand to new target locales" icon="globe" /%} {% link-card title="Overrides" href="/docs/cli/overrides" description="Manually override specific translations" icon="gear" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the lockfile tracks translation state" icon="shield" /%} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="Refresh translations when needed" icon="lightning" /%} {% /card-grid %} - [Key Ignoring](https://lingo.dev/en/docs/cli/key-ignoring): Exclude specific translation keys from processing so they never appear in target files - designed for debug strings, internal flags, and test data. Ignored keys are excluded from translation processing entirely. They do not appear in target files - the CLI skips them during content discovery and never sends them to the translation backend. ## Configuration Add `ignoredKeys` to a bucket in [`i18n.json`](/docs/cli/configuration): ```json { "buckets": { "json": { "include": ["locales/[locale].json"], "ignoredKeys": ["internal/debug", "dev/testData"] } } } ``` ## How it works Given this source file: ```json { "welcome": "Welcome to our platform", "internal": { "debug": "Debug mode enabled", "testData": "Sample test content" } } ``` With `"ignoredKeys": ["internal/debug", "internal/testData"]`, the Spanish target file becomes: ```json { "welcome": "Bienvenido a nuestra plataforma" } ``` The entire `internal` section is absent from the target file. ## Key path notation Use forward slash (`/`) for nested keys and asterisk (`*`) for wildcards: ```json { "ignoredKeys": ["internal/*", "dev/settings"] } ``` Keys containing dots work naturally - `dev/api.mock` targets `"api.mock"` inside `"dev"`. ## Key Ignoring vs. Key Locking | | Key Ignoring | [Key Locking](/docs/cli/key-locking) | | --- | --- | --- | | **Appears in target files** | No | Yes - with source value | | **Use case** | Debug, test, internal content | Brand names, technical IDs | ## Next Steps {% card-grid %} {% link-card title="Key Locking" href="/docs/cli/key-locking" description="Copy values without translation" icon="shield" /%} {% link-card title="Key Preserving" href="/docs/cli/key-preserving" description="Initialize once, then protect from updates" icon="book" /%} {% link-card title="Translation Keys" href="/docs/cli/translation-keys" description="Overview of all key-level controls" icon="code" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% /card-grid %} - [Key Locking](https://lingo.dev/en/docs/cli/key-locking): Lock specific translation keys so their values are copied from source to all target files without translation - designed for brand names, technical identifiers, and values that must stay consistent. Locked keys are copied from the source file to all target files without translation. The Lingo.dev CLI excludes them from translation processing entirely and preserves their source values across all languages. ## Configuration Add `lockedKeys` to a bucket in [`i18n.json`](/docs/cli/configuration): ```json { "buckets": { "json": { "include": ["locales/[locale].json"], "lockedKeys": ["brand/name", "config/apiUrl", "system/version"] } } } ``` ## How it works Given this source file: ```json { "welcome": "Welcome to our platform", "brand": { "name": "Lingo.dev" }, "config": { "apiUrl": "https://api.example.com" } } ``` With `"lockedKeys": ["brand/name", "config/apiUrl"]`, the Spanish target file becomes: ```json { "welcome": "Bienvenido a nuestra plataforma", "brand": { "name": "Lingo.dev" }, "config": { "apiUrl": "https://api.example.com" } } ``` Only `welcome` is translated. Locked keys retain their source values exactly. ## Key path notation Use forward slash (`/`) to target nested keys: ```json { "lockedKeys": ["system/engine/component"] } ``` Use asterisk (`*`) to match multiple keys: ```json { "lockedKeys": ["navigation/menuItems/*"] } ``` Keys containing dots in their names work naturally - `modules/ai.translation` targets the key `"ai.translation"` inside `"modules"`. ## Key Locking vs. Key Ignoring | | Key Locking | [Key Ignoring](/docs/cli/key-ignoring) | | --- | --- | --- | | **Appears in target files** | Yes - with source value | No | | **Use case** | Brand names, technical IDs, URLs | Debug strings, internal flags, test data | ## Next Steps {% card-grid %} {% link-card title="Key Ignoring" href="/docs/cli/key-ignoring" description="Exclude keys from target files entirely" icon="terminal" /%} {% link-card title="Key Preserving" href="/docs/cli/key-preserving" description="Initialize once, then protect from updates" icon="book" /%} {% link-card title="Translation Keys" href="/docs/cli/translation-keys" description="Overview of all key-level controls" icon="code" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% /card-grid %} - [Key Preserving](https://lingo.dev/en/docs/cli/key-preserving): Preserve specific translation keys so they are initialized once from source and then protected from automatic updates - designed for legal text, compliance content, and copy that requires manual translation. Preserved keys are initialized once with source values and then protected from automatic updates. The CLI never overwrites them - they serve as placeholders for content that requires manual translation, such as legal text, compliance copy, or marketing taglines. ## Configuration Add `preservedKeys` to a bucket in [`i18n.json`](/docs/cli/configuration): ```json { "buckets": { "json": { "include": ["locales/[locale].json"], "preservedKeys": ["legal/privacy", "legal/terms"] } } } ``` ## How it works Given this source file: ```json { "welcome": "Welcome to our platform", "legal": { "privacy": "We respect your privacy and protect your data.", "terms": "By using this service, you agree to our terms." } } ``` On the first run, the CLI copies preserved keys as-is while translating everything else: ```json // locales/es.json (first run) { "welcome": "Bienvenido a nuestra plataforma", "legal": { "privacy": "We respect your privacy and protect your data.", "terms": "By using this service, you agree to our terms." } } ``` After you manually translate the legal section, subsequent CLI runs leave your translations intact. ## Key Preserving vs. Key Locking | | Key Preserving | [Key Locking](/docs/cli/key-locking) | | --- | --- | --- | | **Initial value** | Source value as placeholder | Source value (always) | | **Manual edits** | Preserved permanently | Overwritten with source on each run | | **Use case** | Legal, compliance, manual translation | Brand names, technical IDs | ## Key path notation Use forward slash (`/`) for nested keys and asterisk (`*`) for wildcards: ```json { "preservedKeys": ["legal/*", "marketing/tagline"] } ``` ## Next Steps {% card-grid %} {% link-card title="Key Locking" href="/docs/cli/key-locking" description="Copy values without translation" icon="shield" /%} {% link-card title="Key Ignoring" href="/docs/cli/key-ignoring" description="Exclude keys from target files" icon="terminal" /%} {% link-card title="Translation Keys" href="/docs/cli/translation-keys" description="Overview of all key-level controls" icon="code" /%} {% link-card title="Overrides" href="/docs/cli/overrides" description="How manual edits are preserved" icon="gear" /%} {% /card-grid %} - [Key Renaming](https://lingo.dev/en/docs/cli/key-renaming): The Lingo.dev CLI detects when translation keys are renamed and carries existing translations forward - no retranslation needed when only the key identifier changes. The Lingo.dev CLI detects when you rename translation keys and preserves existing translations automatically. If the key name changes but the source content stays the same, the CLI applies the existing translation to the new key - no retranslation occurs. ## How it works The CLI compares content fingerprints, not key names. When a fingerprint match is found under a different key, the CLI recognizes it as a rename. ```json // locales/en.json (before refactoring) { "welcome_msg": "Welcome to our platform", "btn_save": "Save" } // locales/es.json (existing translations) { "welcome_msg": "Bienvenido a nuestra plataforma", "btn_save": "Guardar" } ``` After renaming keys in the source file: ```json // locales/en.json (after refactoring) { "homepage.welcome": "Welcome to our platform", "button.save": "Save" } ``` Running `npx lingo.dev@latest run` preserves the translations: ```json // locales/es.json (translations carried forward) { "homepage.welcome": "Bienvenido a nuestra plataforma", "button.save": "Guardar" } ``` ## Detection rules Key rename is detected when: - The key name changes - The source content remains identical - The key exists in the same bucket Key rename is **not** detected when: - Both key and content change simultaneously (treated as a new key) - Only the content changes (treated as a content update, triggers retranslation) ## Mass refactoring Rename detection works at any scale. You can reorganize your entire key structure - from flat keys to nested namespaces - and the CLI carries all matching translations forward in a single run. ## Next Steps {% card-grid %} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How fingerprinting enables rename detection" icon="shield" /%} {% link-card title="Overrides" href="/docs/cli/overrides" description="How manual edits are preserved" icon="gear" /%} {% link-card title="Translation Keys" href="/docs/cli/translation-keys" description="Overview of all key-level controls" icon="code" /%} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="Force retranslation when needed" icon="lightning" /%} {% /card-grid %} - [Large Projects](https://lingo.dev/en/docs/cli/large-projects): Strategies for scaling the Lingo.dev CLI to projects with thousands of keys and dozens of languages - parallel processing, targeted runs, CI/CD integration, and bucket organization. The Lingo.dev CLI scales to projects with thousands of translation keys and dozens of target languages through parallel processing, incremental translation via the [lockfile](/docs/cli/lockfile), and targeted processing options. ## Parallel processing The CLI distributes translation tasks across concurrent workers. The default concurrency is 10 workers: ```bash npx lingo.dev@latest run ``` Increase concurrency for large projects: ```bash npx lingo.dev@latest run --concurrency 20 ``` For a project with 50 files across 10 languages (500 translation tasks), parallel processing handles them concurrently instead of sequentially. See [Parallel Processing](/docs/cli/parallel-processing) for details on the worker architecture. ## Targeted processing Process specific subsets instead of the entire project: ```bash # Specific languages npx lingo.dev@latest run --target-locale es --target-locale fr # Specific file format npx lingo.dev@latest run --bucket json # Specific files npx lingo.dev@latest run --file components/header # Specific keys npx lingo.dev@latest run --key welcome.title ``` These options combine - `--force --bucket json --target-locale es` retranslates all JSON content for Spanish only. ## CI/CD integration Automate translation on every push using GitHub Actions: ```yaml name: Lingo.dev Localization on: workflow_dispatch: permissions: contents: write pull-requests: write jobs: localize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` The lockfile ensures only changed content is translated, keeping CI runs fast even for large projects. ## Bucket organization Separate content types into distinct buckets for targeted processing: ```json { "buckets": { "json": { "include": ["src/locales/[locale].json"] }, "markdown": { "include": ["docs/[locale]/*.md"] } } } ``` This lets you process documentation and app content independently: `--bucket markdown` translates only documentation. ## Next Steps {% card-grid %} {% link-card title="Parallel Processing" href="/docs/cli/parallel-processing" description="Worker architecture and concurrency control" icon="lightning" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How incremental translation works" icon="shield" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="All bucket types and their configuration" icon="file-code" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route translations through your localization engine" icon="plug" /%} {% /card-grid %} - [i18n.lock](https://lingo.dev/en/docs/cli/lockfile): The i18n.lock file tracks content fingerprints so the Lingo.dev CLI only translates new or modified strings - reducing cost, time, and unnecessary retranslation. `i18n.lock` is a lockfile that stores SHA-256 fingerprints of your source content. The Lingo.dev CLI compares these fingerprints on every run to determine which strings are new or modified - only those enter the translation pipeline. Everything else is skipped. ## Structure The lockfile uses YAML format: ```yaml version: 1 checksums: a07974ea09011daa56f9df706530e442: title: f8692d39317193acf0e2e47172703c46 description: g9703e40428204bdf1f3f58283814d57 ``` | Field | Description | | --- | --- | | `version` | Lockfile schema version. | | `checksums` | Map of content fingerprints. Each entry maps a source content hash to a key hash. | The dual-hash structure (content hash + key hash) enables [key rename detection](/docs/cli/key-renaming) - the CLI preserves existing translations when a key is renamed but its content stays the same. ## Workflow **First run** - creates the lockfile with fingerprints for all source content: ```bash npx lingo.dev@latest run # Creates i18n.lock ``` **Subsequent runs** - translates only the delta: ```bash npx lingo.dev@latest run # Compares against i18n.lock, translates only changes ``` **Force retranslation** - bypasses the lockfile and retranslates everything: ```bash npx lingo.dev@latest run --force ``` **Recreate lockfile** - rebuilds the lockfile from the current state of your source files: ```bash npx lingo.dev@latest lockfile --force ``` {% callout type="info" %} Use `lockfile --force` to reset the lockfile during merge conflict resolution. It's safe to run at any time. {% /callout %} **Frozen verification** - fails if any content requires translation (designed for CI/CD): ```bash npx lingo.dev@latest run --frozen ``` ## Deduplication When merging branches, the lockfile YAML can accumulate duplicate entries. The CLI deduplicates automatically on every load - duplicate keys under the same checksum block are resolved by keeping the last occurrence. If duplicates are removed, the CLI logs the count. Deduplication runs during all commands that read the lockfile: `run`, `status`, `lockfile`, and others. ## Version control `i18n.lock` must be committed to your repository alongside your locale files. It is the mechanism that makes incremental translation possible - without it, every run would retranslate the entire project. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/cli" description="The five-step translation pipeline" icon="book" /%} {% link-card title="Key Renaming" href="/docs/cli/key-renaming" description="Rename keys without losing translations" icon="code" /%} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="When and how to retranslate content" icon="lightning" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% /card-grid %} - [Overrides](https://lingo.dev/en/docs/cli/overrides): Manually edit translations in target files and the Lingo.dev CLI preserves your changes - overrides persist across runs until the source content changes. The Lingo.dev CLI preserves manual edits to target files. When you override a generated translation by editing the target file directly, the CLI keeps your change on subsequent runs - as long as the source content hasn't changed. ## How overrides work The CLI tracks source content fingerprints, not target content. When you manually edit a translation in a target file, the source fingerprint remains unchanged, so the CLI treats the key as already translated and skips it. {% steps %} {% step title="CLI generates a translation" %} ```json // locales/es.json (generated) { "greeting": "Bienvenido a nuestra plataforma" } ``` {% /step %} {% step title="You override it manually" %} ```json // locales/es.json (manually edited) { "greeting": "¡Bienvenido a nuestro espacio digital!" } ``` {% /step %} {% step title="Subsequent runs preserve your override" %} ```bash npx lingo.dev@latest run # Your custom translation remains unchanged ``` {% /step %} {% /steps %} ## When overrides are replaced If the source content changes, the CLI detects a new fingerprint and retranslates the key - replacing your override: ```json // locales/en.json (source updated) { "greeting": "Welcome to our new platform" } ``` The new fingerprint doesn't match the lockfile entry, so the CLI generates a fresh translation for this key. ## Key renaming The CLI preserves translations even when keys are renamed, as long as the content stays the same. The lockfile tracks both content and key fingerprints, enabling [key renaming](/docs/cli/key-renaming) without losing translation work. ## Next Steps {% card-grid %} {% link-card title="Key Renaming" href="/docs/cli/key-renaming" description="Rename keys without losing translations" icon="code" /%} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="Force retranslation when needed" icon="lightning" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the lockfile tracks state" icon="shield" /%} {% link-card title="Existing Translations" href="/docs/cli/existing-translations" description="Integrate with existing translation files" icon="globe" /%} {% /card-grid %} - [Parallel Processing](https://lingo.dev/en/docs/cli/parallel-processing): The Lingo.dev CLI processes translation tasks concurrently using a worker pool - distributing locale/file combinations across multiple workers with file-system locking and automatic caching. The `run` command processes translation tasks concurrently by distributing them across a worker pool. Each locale/file combination becomes an independent task, and workers process them simultaneously. ## Usage ```bash # Default concurrency (10 workers) npx lingo.dev@latest run # Custom concurrency npx lingo.dev@latest run --concurrency 20 ``` ## How it works 1. **Task creation** - the CLI analyzes your [`i18n.json`](/docs/cli/configuration) and creates individual tasks for each locale/file combination 2. **Worker distribution** - tasks are assigned to available workers using load balancing 3. **Concurrent processing** - workers translate simultaneously while file-system locks prevent write conflicts 4. **Result aggregation** - completed translations are safely written to target files ## Targeting options All targeting options from the `run` command work with parallel processing: | Option | Description | | --- | --- | | `--target-locale es` | Process specific target languages | | `--source-locale en` | Override source locale | | `--bucket json` | Process specific bucket types | | `--file components/header` | Process specific files (supports glob patterns) | | `--key welcome.title` | Process specific keys (supports glob patterns) | | `--force` | Bypass lockfile and retranslate everything | | `--frozen` | Fail if any content requires translation | | `--concurrency 20` | Set number of concurrent workers | ## Automatic caching When using the Lingo.dev API, large locale files are divided into chunks. Target files are incrementally populated as each chunk returns from the API. If the process is interrupted, the next run resumes from where it left off. {% callout type="info" %} For retranslation, use [`purge`](/docs/cli/remove-translations) first, then `run` without `--force`. This leverages the built-in caching mechanism for more efficient processing compared to `run --force`. {% /callout %} ## Safety The worker pool prevents file corruption through: - **I/O synchronization** - file-system operations are serialized per file - **Lockfile protection** - atomic operations prevent concurrent `i18n.lock` corruption - **Transactional processing** - each task completes fully or fails cleanly ## Next Steps {% card-grid %} {% link-card title="Large Projects" href="/docs/cli/large-projects" description="Strategies for scaling localization" icon="terminal" /%} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="When and how to retranslate" icon="lightning" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How incremental translation works" icon="shield" /%} {% link-card title="How It Works" href="/docs/cli" description="The five-step translation pipeline" icon="book" /%} {% /card-grid %} - [Quick Start](https://lingo.dev/en/docs/cli/quick-start): Get up and running with the Lingo.dev CLI in under 5 minutes with step-by-step installation and setup. Get up and running with the CLI in under 5 minutes. {% callout type="info" title="Prerequisites" %} Make sure you have **Node.js 18.0 or higher** installed. Run `node --version` to check. {% /callout %} ## Installation Install the CLI globally using your preferred package manager: {% tabs %} {% tab label="npm" %} ```bash npm install -g lingo.dev ``` {% /tab %} {% tab label="pnpm" %} ```bash pnpm add -g lingo.dev ``` {% /tab %} {% tab label="yarn" %} ```bash yarn global add lingo.dev ``` {% /tab %} {% tab label="bun" %} ```bash bun add -g lingo.dev ``` {% /tab %} {% /tabs %} ## Verify Installation Confirm the CLI is installed correctly: ```bash lingo --version ``` You should see output like: ```plaintext lingo.dev v0.133.9 Node.js v20.10.0 ``` ## Initialize Your Project Navigate to your project directory and run: ```bash cd my-project lingo init ``` This interactive command will: 1. **Connect to your account** - Authenticate with your API key 2. **Select a project** - Choose an existing project or create a new one 3. **Configure locales** - Select which languages to support 4. **Set up file structure** - Choose where to store translation files {% callout type="success" title="Configuration Created" %} The CLI creates an `i18n.json` file with your settings. You can modify this file later as needed. {% /callout %} ## Your First Translation Create a simple translation file: ```json { "welcome": { "title": "Welcome to Lingo.dev", "subtitle": "The Localization Engineering Platform" }, "auth": { "login": "Log in", "logout": "Log out" } } ``` ## Run Translations Translate your content to all configured target languages: ```bash lingo run ``` The CLI will: - ✅ Discover your source files - ✅ Compute a delta against the lockfile - ✅ Send changed content to your translation backend - ✅ Write translations to disk ## Watch Mode (Development) For active development, enable watch mode to retranslate automatically when source files change: ```bash lingo run --watch ``` {% callout type="info" %} Press `Ctrl+C` to exit watch mode. {% /callout %} ## Getting Help Need assistance? ```bash # Show help for all commands lingo --help # Show help for a specific command lingo run --help ``` Join our [Discord community](https://discord.gg/rJ8zGYJQj5) for real-time support! ## Next Steps {% card-grid %} {% link-card title="Extract Keys with AI" href="/docs/cli/extract-keys-with-ai" icon="lightning" description="AI-assisted i18n setup" /%} {% link-card title="Translation Keys" href="/docs/cli/translation-keys" icon="gear" description="Lock, ignore, and rename keys" /%} {% link-card title="Supported Locales" href="/docs/cli/supported-locales" icon="globe" description="100+ languages and variants" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" icon="file-code" description="JSON, YAML, Markdown, and more" /%} {% /card-grid %} - [Remove Translations](https://lingo.dev/en/docs/cli/remove-translations): The purge command removes translations from target files by bucket, file, key, or locale - designed for cleaning up obsolete content or preparing for retranslation. The `purge` command removes translations from target files based on specific criteria - bucket type, file pattern, key, or locale. It updates the [`i18n.lock`](/docs/cli/lockfile) file to reflect the removal. ## Usage ```bash npx lingo.dev@latest purge [options] ``` ## Options | Option | Description | Example | | --- | --- | --- | | `--bucket ` | Remove translations in a specific bucket. Repeatable. | `--bucket json` | | `--file ` | Remove translations in files matching a glob pattern. | `--file src/**/*.json` | | `--key ` | Remove a specific translation key. Supports glob patterns. | `--key app.title` | | `--locale ` | Remove translations for a specific locale. Repeatable. | `--locale fr --locale de` | | `--yes-really` | Skip the interactive confirmation prompt. | `--yes-really` | ## Examples ### Remove a specific key ```bash npx lingo.dev@latest purge --key app.title ``` Removes `app.title` from all target files and the lockfile. ### Remove all translations in a bucket ```bash npx lingo.dev@latest purge --bucket json ``` ### Remove translations for specific locales ```bash npx lingo.dev@latest purge --locale fr --locale de ``` ### Remove by file pattern ```bash npx lingo.dev@latest purge --file src/**/*.json ``` ### Skip confirmation ```bash npx lingo.dev@latest purge --key obsolete.key --yes-really ``` ## Purge + Run workflow For efficient [retranslation](/docs/cli/retranslation), purge first, then run without `--force`. This leverages the CLI's caching mechanism: ```bash npx lingo.dev@latest purge --key welcome.title npx lingo.dev@latest run ``` This approach is more efficient than `run --force` because it only retranslates the purged content. ## Next Steps {% card-grid %} {% link-card title="Retranslation" href="/docs/cli/retranslation" description="When and how to retranslate content" icon="lightning" /%} {% link-card title="Automatic Retranslation" href="/docs/cli/automatic-retranslation" description="How source changes trigger retranslation" icon="gear" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the lockfile tracks state" icon="shield" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% /card-grid %} - [Retranslation](https://lingo.dev/en/docs/cli/retranslation): Force retranslation of specific keys, languages, buckets, or files - designed for refreshing translations after model changes, prompt updates, or quality improvements. The Lingo.dev CLI provides manual retranslation options for refreshing translations when the source text hasn't changed - after switching AI models, updating translation prompts, or improving [localization engine](/docs/platform/engines) configuration. For automatic retranslation triggered by source text changes, see [Automatic Retranslation](/docs/cli/automatic-retranslation). ## Complete retranslation Bypass the lockfile and retranslate all content: ```bash npx lingo.dev@latest run --force ``` This retranslates every key in every target language and recreates the `i18n.lock` file. ## Targeted retranslation ### By language ```bash npx lingo.dev@latest run --force --target-locale es ``` Retranslates only Spanish while preserving all other languages. ### By bucket type ```bash npx lingo.dev@latest run --force --bucket json ``` Retranslates only JSON files, leaving Markdown and other formats unchanged. ### By key ```bash npx lingo.dev@latest run --force --key welcome.title ``` Retranslates a single key across all target languages. Supports glob patterns. ### By file ```bash npx lingo.dev@latest run --force --file blog.[locale].json ``` Retranslates specific files. Multiple `--file` flags can be combined. ### Combined ```bash npx lingo.dev@latest run --force --bucket json --target-locale es ``` Options combine for precise control - this retranslates all JSON content for Spanish only. ## Efficient retranslation with purge For best performance, use [`purge`](/docs/cli/remove-translations) before `run` instead of `--force`. This leverages the CLI's built-in caching mechanism: ```bash # Remove existing translations for a specific key npx lingo.dev@latest purge --key welcome.title # Then regenerate (without --force) npx lingo.dev@latest run ``` ## When to retranslate | Scenario | Recommended approach | | --- | --- | | Source text changed | Automatic - no action needed | | Switched AI models | `run --force` or targeted retranslation | | Updated translation prompts | `run --force` or targeted retranslation | | Improved engine configuration | Targeted retranslation for affected locales | | Poor quality in specific locale | `run --force --target-locale ` | ## Next Steps {% card-grid %} {% link-card title="Automatic Retranslation" href="/docs/cli/automatic-retranslation" description="How source changes trigger retranslation" icon="lightning" /%} {% link-card title="Remove Translations" href="/docs/cli/remove-translations" description="Delete translations before regenerating" icon="terminal" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How fingerprinting tracks translation state" icon="shield" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Improve translations by configuring your engine" icon="plug" /%} {% /card-grid %} - [Setup](https://lingo.dev/en/docs/cli/setup): Install the Lingo.dev CLI, create an i18n.json configuration, connect a translation backend, and generate your first translations in under 5 minutes. Install the Lingo.dev CLI, configure your project, and generate your first translations. {% callout type="info" title="Prerequisites" %} Node.js 18 or higher is required. Run `node -v` to check your version. {% /callout %} ## Step 1. Initialize a project {% steps %} {% step title="Navigate to your project" %} ```bash cd your-project-directory ``` {% /step %} {% step title="Run the init command" %} ```bash npx lingo.dev@latest init ``` {% callout type="info" %} **Windows users:** If `npx lingo.dev` doesn't work, install the package first with `npm install lingo.dev@latest`, then use `npx lingo` instead. {% /callout %} Follow the prompts. The CLI creates an `i18n.json` configuration file in your project root. {% /step %} {% /steps %} ## Step 2. Configure a bucket In `i18n.json`, configure at least one bucket - a file format paired with include patterns that tell the CLI where translatable content lives: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "json": { "include": ["locales/[locale].json"] } } } ``` The `[locale]` placeholder resolves to your configured locale codes at runtime - `locales/en.json` for source, `locales/es.json` for Spanish, and so on. For the full list of supported file formats and their bucket configurations, see [Supported Formats](/docs/cli/supported-formats). ## Step 3. Connect a translation backend The CLI needs a translation backend to generate translations. Two options: {% tabs %} {% tab label="Lingo.dev Engine (recommended)" %} A [localization engine](/docs/platform/engines) on Lingo.dev applies [brand voice](/docs/platform/brand-voices), [glossary](/docs/platform/glossaries), [instructions](/docs/platform/instructions), and [model configuration](/docs/platform/llm-models) to every translation request automatically. 1. [Create an account](https://lingo.dev/en/orgs/~) and generate an API key from the [API Keys](/docs/platform/api-keys) page. 2. Set the API key as an environment variable: ```bash export LINGO_API_KEY="your-api-key" ``` No additional configuration is needed - the CLI uses your organization's default localization engine. To target a specific engine, add `engineId` to your `i18n.json`: ```json { "engineId": "eng_SxjMwMsfOIsvV1wh" } ``` {% /tab %} {% tab label="Raw LLM provider" %} The CLI can send translation requests directly to OpenAI, Anthropic, Google, Mistral, OpenRouter, or Ollama. 1. Set the provider's API key as an environment variable: | Provider | Environment variable | | --- | --- | | OpenAI | `OPENAI_API_KEY` | | Anthropic | `ANTHROPIC_API_KEY` | | Google | `GOOGLE_API_KEY` | | Mistral | `MISTRAL_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | | Ollama | No key needed (runs locally) | 2. Add a `provider` section to `i18n.json`: ```json { "provider": { "id": "openai", "model": "gpt-4o-mini", "prompt": "Translate the provided text from {source} to {target}." } } ``` The `{source}` and `{target}` placeholders are replaced with locale codes at runtime. {% /tab %} {% /tabs %} ## Step 4. Generate translations ```bash npx lingo.dev@latest run ``` The CLI discovers your source files, extracts translatable content, sends it to your configured translation backend, and writes the results back to disk. An `i18n.lock` file is created to track what has been translated - commit it alongside your locale files. ## Next Steps {% card-grid %} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="JSON, YAML, Markdown, and 20+ file formats" icon="file-code" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route translations through your localization engine" icon="plug" /%} {% link-card title="How It Works" href="/docs/cli" description="The five-step translation pipeline" icon="book" /%} {% /card-grid %} - [Supported Locales](https://lingo.dev/en/docs/cli/supported-locales): Complete list of 100+ supported languages and regional variants with ISO 639-1 locale codes. The CLI supports 100+ languages and regional variants. {% callout type="success" title="Universal Support" %} All languages use standard ISO 639-1 codes for consistency across platforms. {% /callout %} ## Common Languages The most frequently used locales: | Language | Code | Example | | --------------------- | --------------- | ---------- | | English (US) | `en` or `en-US` | Hello | | Spanish | `es` | Hola | | French | `fr` | Bonjour | | German | `de` | Hallo | | Italian | `it` | Ciao | | Portuguese | `pt` | Olá | | Japanese | `ja` | こんにちは | | Korean | `ko` | 안녕하세요 | | Chinese (Simplified) | `zh` or `zh-CN` | 你好 | | Chinese (Traditional) | `zh-TW` | 你好 | | Russian | `ru` | Привет | | Arabic | `ar` | مرحبا | | Hindi | `hi` | नमस्ते | | Dutch | `nl` | Hallo | | Polish | `pl` | Cześć | ## Regional Variants Specify regional dialects for precise localization: ```json { "locale": { "source": "en-US", "targets": ["en-GB", "es-ES", "es-MX", "pt-BR", "pt-PT", "fr-FR", "fr-CA"] } } ``` {% tabs %} {% tab label="English" %} - `en-US` - United States - `en-GB` - United Kingdom - `en-CA` - Canada - `en-AU` - Australia - `en-NZ` - New Zealand **Example differences:** | Key | en-US | en-GB | | --------- | --------- | ------ | | color | Color | Colour | | elevator | Elevator | Lift | | apartment | Apartment | Flat | {% /tab %} {% tab label="Spanish" %} - `es-ES` - Spain - `es-MX` - Mexico - `es-AR` - Argentina - `es-CL` - Chile - `es-CO` - Colombia **Example differences:** | Key | es-ES | es-MX | | -------- | --------- | ----------- | | computer | Ordenador | Computadora | | car | Coche | Carro | | mobile | Móvil | Celular | {% /tab %} {% tab label="Portuguese" %} - `pt-BR` - Brazil - `pt-PT` - Portugal **Example differences:** | Key | pt-BR | pt-PT | | ----- | ------ | --------- | | train | Trem | Comboio | | bus | Ônibus | Autocarro | {% /tab %} {% /tabs %} ## All Supported Locales Complete list of ISO 639-1 language codes: {% tabs %} {% tab label="A-G" %} - `af` - Afrikaans - `ar` - Arabic - `bg` - Bulgarian - `bn` - Bengali - `ca` - Catalan - `cs` - Czech - `da` - Danish - `de` - German - `el` - Greek - `en` - English - `es` - Spanish - `et` - Estonian - `fa` - Persian - `fi` - Finnish - `fr` - French - `gu` - Gujarati {% /tab %} {% tab label="H-N" %} - `he` - Hebrew - `hi` - Hindi - `hr` - Croatian - `hu` - Hungarian - `id` - Indonesian - `it` - Italian - `ja` - Japanese - `kn` - Kannada - `ko` - Korean - `lt` - Lithuanian - `lv` - Latvian - `mk` - Macedonian - `ml` - Malayalam - `mr` - Marathi - `ms` - Malay - `nl` - Dutch - `no` - Norwegian {% /tab %} {% tab label="P-Z" %} - `pa` - Punjabi - `pl` - Polish - `pt` - Portuguese - `ro` - Romanian - `ru` - Russian - `sk` - Slovak - `sl` - Slovenian - `sq` - Albanian - `sr` - Serbian - `sv` - Swedish - `ta` - Tamil - `te` - Telugu - `th` - Thai - `tl` - Tagalog - `tr` - Turkish - `uk` - Ukrainian - `ur` - Urdu - `vi` - Vietnamese - `zh` - Chinese {% /tab %} {% /tabs %} ## Configuration Specify locales in your config file: ```json { "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] } } ``` Or use regional variants: ```json { "locale": { "source": "en-US", "targets": ["en-GB", "es-ES", "es-MX", "pt-BR", "pt-PT"] } } ``` ## RTL Language Support Right-to-left languages are fully supported: - `ar` - Arabic - `he` - Hebrew - `fa` - Persian - `ur` - Urdu {% callout type="info" title="RTL Handling" %} The platform automatically detects RTL languages and applies proper text direction in the web editor. {% /callout %} ## Next Steps - Return to [Quick Start](/docs/cli/quick-start) to begin implementation - [Translation Keys](https://lingo.dev/en/docs/cli/translation-keys): The Lingo.dev CLI provides four key-level controls - locking, ignoring, preserving, and renaming - that determine how individual keys behave during translation. The Lingo.dev CLI provides four key-level controls that determine how individual translation keys behave during processing. Each serves a distinct purpose: | Control | Config field | Behavior | | --- | --- | --- | | [Key Locking](/docs/cli/key-locking) | `lockedKeys` | Copies source values to all targets without translation. Keys appear in target files with identical values. | | [Key Ignoring](/docs/cli/key-ignoring) | `ignoredKeys` | Excludes keys from processing entirely. They do not appear in target files. | | [Key Preserving](/docs/cli/key-preserving) | `preservedKeys` | Initializes keys once from source, then protects them from automatic updates. Designed for content that requires manual translation. | | [Key Renaming](/docs/cli/key-renaming) | Automatic | Detects when keys are renamed and preserves existing translations. No configuration required. | ## When to use what **Lock** a key when the value must remain identical across all languages - brand names, technical identifiers, configuration values: ```json { "lockedKeys": ["brand/name", "config/apiUrl"] } ``` **Ignore** a key when it should not exist in target files at all - debug strings, internal flags, test data: ```json { "ignoredKeys": ["internal/debug", "dev/testData"] } ``` **Preserve** a key when it needs manual translation - legal text, compliance content, marketing copy that requires human review: ```json { "preservedKeys": ["legal/privacy", "legal/terms"] } ``` **Renaming** is automatic - the CLI detects when a key changes but its content stays the same, and carries the existing translation forward without retranslation. ## Key path notation All key arrays use forward slash (`/`) notation for nested paths and asterisk (`*`) for wildcards: ```json { "lockedKeys": ["brand/name"], "ignoredKeys": ["internal/*"], "preservedKeys": ["legal/privacy/full"] } ``` Keys containing dots in their names are handled naturally - the forward slash separates hierarchy levels, so `modules/ai.translation` correctly targets the key `"ai.translation"` inside the `"modules"` object. ## Next Steps {% card-grid %} {% link-card title="Key Locking" href="/docs/cli/key-locking" description="Copy values without translation" icon="shield" /%} {% link-card title="Key Ignoring" href="/docs/cli/key-ignoring" description="Exclude keys from target files" icon="terminal" /%} {% link-card title="Key Preserving" href="/docs/cli/key-preserving" description="Protect keys for manual translation" icon="book" /%} {% link-card title="Key Renaming" href="/docs/cli/key-renaming" description="Rename keys without losing translations" icon="code" /%} {% /card-grid %} - [Translator Notes](https://lingo.dev/en/docs/cli/translator-notes): Add comments to JSONC and XCStrings files that provide context to the AI model - disambiguating terms, specifying tone, or describing where content appears in the UI. Some file formats support inline comments that the Lingo.dev CLI includes in translation requests. These comments provide context to the AI model - disambiguating terms, specifying tone, or describing where content appears in the UI. ## Why translator notes matter The word "Records" can refer to medical records, music records, or database records. Without context, the AI model has to guess. A translator note eliminates the ambiguity: ```jsonc { // Medical context: refers to patient medical records "records": "Records" } ``` The comment is sent alongside the string in the translation request, steering the model toward the correct interpretation. ## Supported formats Translator notes are currently supported in: | Format | Bucket type | Comment syntax | | --- | --- | --- | | JSONC | `jsonc` | `// comment` above the key | | Xcode String Catalogs | `xcode-xcstrings` | Comment field in the `.xcstrings` file | ## JSONC example ```jsonc { // Navigation menu item - appears in the top header bar "nav.home": "Home", // Button label - triggers form submission, keep it short "form.submit": "Submit", // "Light" refers to the visual theme, not weight or illumination "settings.theme.light": "Light" } ``` To use JSONC, configure the `jsonc` bucket type in your [`i18n.json`](/docs/cli/configuration): ```json { "buckets": { "jsonc": { "include": ["locales/[locale].jsonc"] } } } ``` ## Writing effective notes Effective translator notes describe context that isn't obvious from the string itself: | Effective | Why | | --- | --- | | `// Button label in checkout flow` | Tells the model about UI placement and expected brevity | | `// "Set" means a collection, not the verb` | Disambiguates a polysemous word | | `// Formal tone - displayed in legal footer` | Sets register expectations | Notes that restate the string itself (`// This says Welcome`) don't add value. ## Next Steps {% card-grid %} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="All bucket types and their features" icon="file-code" /%} {% link-card title="Key Locking" href="/docs/cli/key-locking" description="Protect specific values from translation" icon="shield" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Use brand voice and glossary for richer context" icon="plug" /%} {% /card-grid %} ## Docs – Cli-async - [Other commands](https://lingo.dev/en/docs/cli-async/commands): Reference for login, logout, link, unlink, whoami — the setup and identity commands that surround push and pull. Setup and identity commands. None of them touch source content — they only manage credentials and project bindings. ## `lingo login` Authenticate against Lingo.dev. Two flows: ### OTP (default, interactive) ```bash lingo login lingo login --email you@company.com # skip the email prompt lingo login --email you@company.com --code 123456 # skip the code prompt too ``` Sends a one-time code to your email, verifies it, stores a Supabase session in `~/.lingo/auth.json`. Refresh tokens are stored alongside so the session survives across sessions until you explicitly `logout`. ### API key (CI / non-interactive) ```bash lingo login --api-key lk_... ``` Stores the API key. Generate keys on the Lingo.dev platform under your organization's [API keys](/docs/platform/api-keys) settings. You can also pass `--api-key` as a **global flag** on any command, which bypasses stored credentials entirely: ```bash lingo push --api-key lk_... ``` Convenient for one-off CI jobs that shouldn't write credentials to disk. ## `lingo logout` ```bash lingo logout ``` Clears `~/.lingo/auth.json`. No-op if you weren't logged in. ## `lingo link` ```bash lingo link lingo link --org org_a8c... --engine eng_b9d... # skip prompts ``` Bind the current project to an organization and a localization engine. Writes `orgId` + `engineId` into `.lingo/config.json` (commit it). Interactive mode lets you pick from your existing orgs/engines or create new ones inline — `link` will prompt for a name, do the onboarding survey for new orgs, and create the resource via the API before linking. ## `lingo unlink` ```bash lingo unlink ``` Removes `orgId` and `engineId` from `.lingo/config.json`. Doesn't delete the org or the engine — only severs the local binding. Useful before re-linking to a different engine. ## `lingo whoami` ```bash lingo whoami lingo whoami --json ``` Shows three things: 1. **Identity** — the email you're logged in as, or whether you're using an API key. 2. **Org** — the linked organization (resolves the name from the API). 3. **Engine** — the linked engine (resolves the name from the API). ```text Email: you@company.com Org: Acme Inc (org_a8c...) Engine: Production (eng_b9d...) Auth: session ``` If you're not in a linked project directory, the Org/Engine lines are omitted. `--json` outputs the same data structured for scripting. ## Global `--api-key` flag Every command accepts a `--api-key` flag that overrides stored credentials for that invocation only. Standard pattern in CI: ```yaml env: LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }} steps: - run: lingo push --backfill-missing --yes --api-key "$LINGO_API_KEY" ``` (The CLI also reads `LINGO_API_KEY` from the environment as a fallback.) ## Where to next - [lingo push](/docs/cli-async/push) — scoped + delta translation. - [lingo pull](/docs/cli-async/pull) — cross-machine fetch. - [Configuration](/docs/cli-async/configuration) — `.lingo/config.json`, lockfile, run state. - [Configuration](https://lingo.dev/en/docs/cli-async/configuration): Reference for .lingo/config.json (committed), .lingo/lock.json (committed), and the per-machine run state file at ~/.lingo/runs/.json. The CLI's persistent state lives in three places: two inside the project (committed), one in your home directory (per-machine). ## `.lingo/config.json` — committed Created by `lingo init` (localization section) and `lingo link` (org/engine binding). Commit it. ```json { "orgId": "org_a8c...", "engineId": "eng_b9d...", "sourceLocale": "en", "targetLocales": ["de", "fr", "es"], "files": [ { "pattern": "locales/en.json" }, { "pattern": "docs/en/**/*.md" } ] } ``` ### Field reference | Field | Required | Description | | --- | --- | --- | | `orgId` | yes (after `link`) | Organization that owns the engine. | | `engineId` | yes (after `link`) | Engine that performs the translation. Holds model config, glossary, brand voice. | | `sourceLocale` | yes | Locale code of your source files (e.g. `"en"`). Source files are read; never written. | | `targetLocales` | yes | Locales to translate into. Outputs are written to the same directory as the source, with the locale code substituted into the pattern (`locales/en.json` → `locales/de.json`). | | `files` | yes | Array of source patterns. `pattern` is a glob (forward slashes, `**` recursion, `*` wildcards). The CLI substitutes locale codes when resolving target paths. | | `github` | no | Settings for the [GitHub App](/docs/workflows/github-app) — ignored by the CLI itself. | ### Pattern → target mapping The CLI replaces the source locale in the pattern with each target locale: - `locales/en.json` → `locales/de.json`, `locales/fr.json`, ... - `docs/en/**/*.md` → `docs/de/**/*.md` (mirrored subtree) - `copy/en/marketing.md` → `copy/de/marketing.md` If your source layout doesn't include the locale code in the path, restructure it. The CLI can't infer where target files should go without it. ## `.lingo/lock.json` — committed Tracks the **last-known-server hash** of every source and target file. Used for two things: 1. **`lingo push`** consults it to decide whether a source has changed since the last successful run. Unchanged files become a no-op without a server round-trip. 2. **`lingo pull`** consults it to detect local target edits — if a local target's hash differs from what the lockfile says, pulling would overwrite local work, so `pull` errors unless you pass `--force`. Source hashes are committed to the lockfile **only after a fully successful push**, so a half-completed run can be retried without manual cleanup. Commit the lockfile alongside translated outputs. Treat conflicts the same way you'd treat `package-lock.json` conflicts: regenerate by running `lingo push` again. ## `~/.lingo/runs/.json` — per-machine Records the most recently submitted `push` so that `lingo pull` knows which run's outputs to fetch — works across closed terminals and across machines that share the same checkout. ```json { "runId": "run_a8c...", "engineId": "eng_b9d...", "organizationId": "org_a8c...", "sourceLocale": "en", "createdAt": "2026-05-22T14:32:01.000Z" } ``` The filename hash is derived from the **absolute project root path**, not from file contents, so: - Editing `en.json` doesn't invalidate the file — the same `pull` will still find the run. - Two checkouts of the same repo on the same machine get distinct files (different absolute paths). - Moving the project (`mv ~/Projects/foo ~/Projects/bar`) between push and pull invalidates the lookup, since the hash changes. The JSON is still in `~/.lingo/runs/` if you need to recover the run ID manually. This file is **per-machine state, not project state** — it isn't gitignored because it lives outside the repo entirely. ## Authentication credentials — `~/.lingo/auth.json` Stored by `lingo login` (OTP flow stores a Supabase session; `--api-key` flow stores the key). Not committed, never read by anything except the CLI itself. ```bash lingo logout # clear credentials lingo whoami # check what's stored and which org/engine the cwd resolves to ``` ## Resolution from subdirectories Every command walks up from `cwd` looking for the nearest `.lingo/config.json`. Running `lingo push` from `src/components/` writes its lockfile back to the project root, not a fresh `.lingo/` in `components/`. So you don't have to `cd` to root before each command. - [lingo pull](https://lingo.dev/en/docs/cli-async/pull): Fetch outputs from the most recent push — works across machines and across terminal sessions. Reference for conflict detection, --force, and --dry-run. Fetch the most recent push's outputs and write them to disk, with conflict detection against the lockfile. ```text lingo pull [--force] [--dry-run] ``` ## When to use it `lingo push` already writes outputs when the run completes — so `pull` is only useful when you didn't (or couldn't) wait synchronously: - **Closed the terminal mid-translation.** Reopen, run `lingo pull` — picks up wherever the run left off. - **Pulling from a different machine.** Translator runs `push` on their laptop; CI / a teammate runs `pull` on the same checkout, same engine, same credentials, and gets the outputs. - **Resumed work after the run finished but before `push` could write.** Network blip, process killed — `pull` finishes the job. ## How it finds the run `pull` reads `~/.lingo/runs/.json` where `` is derived from the absolute project root path. The file records the last `runId` from `push`. Without it, `pull` errors: ```text Error: No run state at ~/.lingo/runs/.json — run `lingo push` first so we know which run's outputs to pull. ``` This file is per-machine and lives outside the repo (see [Configuration](/docs/cli-async/configuration#run-state) for details on why). ## Conflict detection Before writing each target, `pull` compares: - Hash of the **local file on disk** - Hash recorded in **`.lingo/lock.json`** as the last-known-server version If they match → no local edits, safe to overwrite. If they differ → local edits exist; pulling would discard them. `pull` aborts: ```text Error: 3 conflict(s) — rerun with --force ``` The lockfile is the single source of truth here — it tracks what the server last wrote, not the source content. Manual edits to translated files that you want to keep should be committed (so they survive `pull`) or pulled with `--force` (so they get overwritten). ## Flags ### `--force` / `-f` Overwrite local target files that have diverged from the lockfile. Use after you've reviewed the conflicts and decided the server version is canonical (e.g. someone else pushed a glossary update that should take precedence). Recommended workflow: ```bash git status # stash or commit local edits first git stash # if you want to keep them aside lingo pull --force git stash pop # re-apply your edits, resolve conflicts manually ``` ### `--dry-run` Show what `pull` would do without touching the filesystem: ```bash lingo pull --dry-run ``` Outputs the count of files that would be written and how many are already in sync. Useful in CI for asserting nothing has drifted. ## Output Success: ```text ✓ Pulled run run_a8c...: wrote 12 file(s), 4 already in sync. ``` Dry run: ```text Dry run complete. 16 file(s) already in sync. ``` Run not yet complete: ```text Run run_a8c... is running, not pulling yet. ``` (`pull` doesn't block on running runs — re-run later, or use `push` next time, which waits.) ## Edge cases - **No prior push.** Error as above. There's no concept of "pull translations that exist somewhere on the server" — `pull` always targets a specific run. - **Run state pointing at a deleted/expired run.** The engine returns 404; `pull` reports it cleanly. Delete `~/.lingo/runs/.json` and re-run `push`. - **Different engine in `.lingo/config.json` than the one the run was created on.** Engine ID mismatch — the CLI errors with the IDs. Re-run `push` against the current engine. - [lingo push](https://lingo.dev/en/docs/cli-async/push): Send source files to the localization engine, wait for the run to complete, write translated outputs. Reference for scoped patterns, --force, --backfill-missing, and retry behavior. Push source files to the engine, wait for the run, and write outputs to disk. ```text lingo push [patterns...] [--force] [--backfill-missing] [--yes] ``` ## Default behavior — delta push With no arguments, `lingo push` runs the **delta-only mode**: 1. Hash every source file matched by the config's `files` patterns 2. Diff each hash against the lockfile to find sources that changed 3. Upload changed sources as a run on the engine 4. Wait for the run to complete 5. Write outputs to disk 6. Commit the new source hashes to the lockfile If no source has changed since the last successful push, the command short-circuits with `✓ Nothing to push.` — no server round-trip, no token spend. ## Arguments and flags ### Positional: `patterns...` — scoped push ```bash lingo push docs/en/about.md lingo push 'docs/en/**/*.md' 'locales/en.json' ``` Restricts the push to specific files (must match patterns already in `.lingo/config.json`). Switches the command into **scoped mode**: - No previous-source diff — every matching source is treated as in-scope, even if unchanged. - Server-side noop for targets that already exist with matching source hashes — the engine skips them and the CLI reports them as cached. Use when you want to translate exactly one updated file without rehashing the entire project, or when you want to retranslate a single page with `--force`. ### `--force` / `-f` ```bash lingo push docs/en/about.md --force ``` Retranslate every matching target, ignoring any existing translations and bypassing server-side caching. **Requires a scope** — either positional patterns or `--backfill-missing`. Bare `lingo push --force` is rejected because it'd retranslate the entire project. By default `--force` prompts before running: ```text ! --force will retranslate every target for pattern(s): docs/en/about.md and overwrite existing translations. Continue? (Yes, retranslate / Cancel) ``` Pass `--yes` / `-y` to skip the prompt (CI-friendly). ### `--backfill-missing` ```bash lingo push --backfill-missing ``` Translate every target that doesn't exist yet across every configured pattern. Equivalent to a scoped push over all config patterns, but only producing files where they're absent. Use after adding a new locale to `targetLocales`, or on the first push of a new project. Combine with `--force` to retranslate everything from scratch: ```bash lingo push --backfill-missing --force --yes ``` ### `--yes` / `-y` Skips the `--force` confirmation prompt. No effect without `--force`. ## Output On success: ```text Pushing source files to localization engine… ✓ Run run_a8c...: localized 12 target file(s), 4 already up-to-date, uploaded 1 new artifact(s). ``` The summary breaks down into: - **Localized N target file(s)** — the engine produced new translations and the CLI wrote them. - **N already up-to-date** — server-side cache hits (source matched, target reused). - **Uploaded N new artifact(s)** — sources that the engine hadn't seen before (binary/large content stored once, referenced thereafter). - **N target(s) skipped (local edits)** — local target hashes diverge from the lockfile. Rerun with `--force` to overwrite. On per-target failure the CLI prints each failed target's error and exits non-zero — useful for CI: ```text ✓ Run run_a8c...: localized 10 target file(s). 2 target(s) failed: locales/de.json: rate limit on engine; retry later locales/fr.json: timeout ``` ## Retry semantics The lockfile is updated **only after a fully successful run**. A partial failure (e.g. one locale times out) leaves source hashes unchanged in the lockfile, so the next `lingo push` retries the same diff — no manual reset. If the engine errors out before any translation happens (auth, validation), nothing is written and the lockfile is unchanged. ## Common patterns ### CI: translate-on-merge ```yaml - run: lingo push --backfill-missing --yes - run: git add . && git commit -m "chore: refresh translations" && git push ``` `--backfill-missing` is the safe default: doesn't overwrite anything, only fills gaps. ### Single-file iteration ```bash lingo push docs/en/onboarding.md -f -y ``` Retranslate just one source after a major copy change. Skip the prompt for fast iteration. ### Adding a new locale After bumping `targetLocales` in `.lingo/config.json`: ```bash lingo push --backfill-missing ``` Translates the entire corpus into the new locale without retranslating existing ones. - [Quickstart](https://lingo.dev/en/docs/cli-async/quickstart): Install @lingo.dev/cli, authenticate, link a project to an engine, and run the first push/pull cycle in a few minutes. End-to-end: install, authenticate, link to an engine, push sources, pull translations. {% callout type="info" title="Prerequisites" %} Node.js 22+ (`node -v` to check). The CLI runs as `lingo` once installed. {% /callout %} ## Setup {% steps %} {% step title="Install" %} ```bash npm install -g @lingo.dev/cli ``` Or `pnpm add -g @lingo.dev/cli` / `yarn global add @lingo.dev/cli` / `bun add -g @lingo.dev/cli`. {% /step %} {% step title="Authenticate" %} ```bash lingo login ``` Enter your email; the CLI sends a one-time code and stores a session token in `~/.lingo/auth.json`. For CI / non-interactive contexts use an API key: `lingo login --api-key lk_...` (or set `--api-key` as a global flag on any command). {% /step %} {% step title="Initialize the project" %} In your project root: ```bash lingo init ``` Prompts for **source locale**, **target locales**, and **file patterns** (globs that point at your source files). Writes the localization section into `.lingo/config.json`. Commit this file — it's the source of truth for what gets translated. {% /step %} {% step title="Link to an engine" %} ```bash lingo link ``` Pick (or create) an **organization** and a **localization engine**. The engine holds your AI model config, glossaries, brand voice, and instructions — set it up once on the [Lingo.dev platform](https://lingo.dev) and reuse it across projects. `link` appends `orgId` and `engineId` to `.lingo/config.json` (also committed). {% /step %} {% /steps %} ## First push With a non-empty source file in place (e.g. `locales/en.json`): ```bash lingo push --backfill-missing ``` Translates every missing target across every configured pattern. The CLI waits for the run to complete and writes outputs (`locales/de.json`, `locales/fr.json`, ...) to disk. On a clean checkout this takes anywhere from seconds (small JSON) to minutes (large markdown bundles). When it finishes: ```text ✓ Run run_a8c... : localized 12 target file(s), uploaded 1 new artifact(s). ``` ## Subsequent runs After you edit source files, plain `lingo push` only translates the delta — files whose source hash didn't change are skipped server-side. Local target edits are preserved by default; pass `--force` (with a scope) to overwrite. ```bash lingo push # delta only lingo push docs/en/**/*.md # scoped: only this subtree lingo push docs/en/about.md -f # scoped + force: retranslate even if up to date ``` ## Pulling from a different machine `push` records the run ID in `~/.lingo/runs/.json` (keyed by absolute project path). On any machine with the same checkout and the same credentials: ```bash lingo pull ``` …fetches the outputs from the last push. Useful for CI ("translator runs push from laptop, CI runs pull on every build") or just resuming after a terminal close. ## Where to next - [Configuration](/docs/cli-async/configuration) — `.lingo/config.json` schema, lockfile, where run state lives. - [lingo push](/docs/cli-async/push) — scoped patterns, `--force`, retry semantics. - [lingo pull](/docs/cli-async/pull) — conflict detection, `--dry-run`. ## Docs – Mcp - [Capabilities](https://lingo.dev/en/docs/mcp/capabilities): Full reference of what your AI assistant can manage through the Lingo.dev MCP server - engines, glossaries, brand voice, instructions, model configuration, AI reviewers, and API keys. Once connected, your AI assistant can manage your entire localization engine configuration through natural language. This page lists every capability exposed through the MCP server. ## Engine management Create, update, and inspect localization engines. Each engine is an independent configuration scope with its own models, glossary, brand voice, and instructions. ## Brand voice Define how your product speaks in each locale. Set formal German ("Sie"), informal Italian ("tu"), or any per-locale tone. Your assistant can create, update, and remove brand voice profiles directly. ## Glossary Add terms that must be localized exactly - or not localized at all. When you tell your assistant "make sure '911' localizes to '112' in German," it creates the glossary entry immediately. Glossary entries are matched during localization via semantic similarity, so exact phrasing in source content doesn't need to match the glossary entry verbatim. ## Instructions Encode linguistic rules that generic models miss. Space before percentage signs in French, adjective positioning in Spanish, pronoun formality per market. Your assistant adds these as locale-scoped instructions that apply to every localization. ## LLM model configuration Configure which model handles each locale pair, with ranked fallbacks. Assign Claude for European languages, GPT for CJK, or any combination - your assistant manages the full model routing table. ## AI Reviewers Create and configure AI Reviewers that automatically evaluate localization quality. Define review criteria, select the evaluation model, and set sampling rates. Attach or detach scorers from specific engines. ## Localization Run localizations directly through your engine - single requests with full context enrichment, or async batch jobs targeting up to 100 locales in one call. Also detect the language of arbitrary text. See [Localize from the Editor](/docs/mcp/localize) for detailed workflows. ## Observability Inspect request logs with full execution context - which model handled a request, token usage, duration, fallback status, and complete input/output. Retrieve AI Reviewer verdicts, glossary compliance reports, and instruction adherence results per request. See [Debug Localization Quality](/docs/mcp/observe) for the post-mortem workflow. ## Engine provisioning Submit links and content to create a fully configured engine automatically - AI extracts brand voice, glossary terms, and instructions from your sources. See [Provision Engines with AI](/docs/mcp/provision) for details. ## Available models List all LLM models available for assignment to your engine's model chains. Your assistant can query this to know what options exist before configuring a locale pair. ## API keys Generate and revoke API keys for programmatic access to your localization engine. ## Integration credentials Manage credentials for external integrations connected to your organization. ## Team management Invite teammates to your organization and review pending invites. New invites grant admin access by default and trigger the standard invitation email; when the MCP session is authenticated with OAuth, the email shows the inviter's name. ## Permissions and roles Manage role-based access control from the conversation: list roles and permissions, create custom roles, assign users to roles, manage per-engine access grants, and transfer organization ownership. ## Audit logs Query the append-only history of state-changing actions in your organization - who changed what, when, from which IP. Filter by actor, target, action type, or time range. ## AI agent threads Create and manage AI agent threads for automated localization debugging and issue resolution. ## Next Steps {% card-grid %} {% link-card title="Localize" href="/docs/mcp/localize" icon="lightning" description="Run localization directly from the editor" /%} {% link-card title="Observe" href="/docs/mcp/observe" icon="chart" description="Debug localization quality with request logs and scorer verdicts" /%} {% link-card title="Provision" href="/docs/mcp/provision" icon="gear" description="Create engines from links and content" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define how your product speaks in each language" /%} {% /card-grid %} - [Expand to New Locales](https://lingo.dev/en/docs/mcp/extend): Add a target language to an existing localization engine with the right model chain, brand voice, and instructions - one prompt to full locale coverage. Add a target language to an existing engine with model chain, brand voice, and instructions configured in one conversation. ## The workflow > "Add Brazilian Portuguese to our engine. Use Claude Sonnet as primary, GPT-4.1 as fallback. Informal tone, 'você' not 'tu'. And make sure our product name 'Acme Hub' stays untranslated." What happens: 1. The assistant reads the engine's current configuration to understand the pattern 2. Creates the model chain for `en → pt-BR` with ranked fallback 3. Adds a brand voice profile specifying informal register with 'você' 4. Adds a glossary entry marking "Acme Hub" as non-translatable for `pt-BR` 5. Runs a test localization to verify the configuration works ## What gets configured per locale | Component | What to specify | | --- | --- | | Model chain | Primary model + fallback(s) for the locale pair | | Brand voice | Register (formal/informal), pronoun choice, tone | | Instructions | Locale-specific rules (punctuation, spacing, formatting) | | Glossary extensions | Terms that need locale-specific localizations or protection | ## Common patterns ### Locale variants > "Add pt-BR. Copy the model chain from pt-PT but change the brand voice to informal." The assistant reuses the existing Portuguese configuration as a starting point, adjusting only what differs for Brazil. ### CJK languages > "Add Japanese. Use Claude Sonnet — it handles Japanese well. Brand voice: polite-formal (keigo for user-facing, plain form for developer docs). Add instructions for honorific consistency." Languages with complex register systems benefit from explicit brand voice and instruction configuration. ### Right-to-left languages > "Add Arabic. Same model chain as other languages. Add an instruction about mixed LTR/RTL content handling in UI strings." The assistant configures the locale and adds RTL-specific instructions. ## After extending Once the locale is live: - Run a [localization test](/docs/mcp/localize) with representative content - [Review](/docs/mcp/review) the output against the new rules - Monitor with [Observe](/docs/mcp/observe) for scorer results on early requests - [Compare Engines](https://lingo.dev/en/docs/mcp/compare): A/B test two engine configurations on the same content - verify a tune improved output before promoting changes to production. Test the same content through two engine configurations to evaluate a change before committing. ## The workflow > "Compare our production engine against the staging engine on these 5 strings for Japanese" What happens: 1. The assistant localizes the content through both engines 2. Presents results in a side-by-side table 3. Highlights differences: "The staging engine applies the new glossary term for 'onboarding' (オンボーディング) while production still uses the descriptive localization (導入手続き)" ## When to use this - After tuning — verify the change improved output before promoting - Evaluating model changes — same config, different primary model - Testing glossary impact — with and without new terms - Comparing engines for different use cases — marketing vs. technical content ## Example comparisons ### Before/after a tune > "Localize 'Welcome to your new workspace' to German through engine A and engine B" Shows whether the glossary entry for "workspace" is being preserved in the updated engine. ### Model evaluation > "I switched the Japanese model from GPT-4.1 to Claude Sonnet. Compare outputs for these 10 UI strings." Side-by-side reveals which model handles short UI strings vs. longer descriptions better for your specific domain. ### Glossary depth testing > "Compare the engine with our full 200-term glossary against a fresh engine with no glossary on these legal strings" Quantifies how much the glossary contributes to output quality for a specific content type. - [Review Localizations](https://lingo.dev/en/docs/mcp/review): Spot-check localizations against your engine's glossary, instructions, and brand voice - get a pass/fail report with specific violations per string. Evaluate localizations against your engine's rules without modifying anything. Your assistant reads the engine's configuration and reports violations per string. ## The workflow > "Check these 10 German strings against our engine. Are all glossary terms applied? Do they follow the formal register?" What happens: 1. The assistant reads your engine's glossary, instructions, and brand voice for `de` 2. Evaluates each localization for glossary compliance, instruction adherence, and tone consistency 3. Reports violations per string: "Line 3: 'Checkout' was localized to 'Kasse' but your glossary marks it as non-translatable. Line 7: informal 'du' used, but brand voice specifies 'Sie'." ## When to use this - Before shipping — spot-check critical strings - After receiving localizations from an external source - Validating that a tune actually fixed the issue across a batch - Auditing localizations produced before the engine was configured ## What gets checked | Dimension | What the assistant verifies | | --- | --- | | Glossary compliance | Are required term localizations honored? Are non-translatable terms preserved? | | Instruction adherence | Are locale-specific rules followed (spacing, punctuation, formatting)? | | Brand voice consistency | Does the tone match the configured register (formal/informal, polite/casual)? | | Terminology consistency | Are the same source terms localized the same way across the batch? | ## Review vs. Observe Both are read-only, but they serve different purposes: | | Review | Observe | | --- | --- | --- | | Input | Localizations you paste or provide | Requests already processed by the engine | | Checks against | Engine configuration (glossary, instructions, brand voice) | Request logs and AI Reviewer verdicts | | Use case | "Are these localizations correct?" | "What happened during this localization?" | - [Tune Engine Configuration](https://lingo.dev/en/docs/mcp/tune): Apply feedback to your localization engine - update glossary, brand voice, and instructions based on translation quality signals. After identifying an issue or receiving feedback, your assistant drafts the configuration change, shows it for review, and applies on approval. ## The workflow > "Our French localizations keep using 'vous' but we want 'tu' for our app. Also, 'workspace' should stay untranslated in all locales." What happens: 1. The assistant reads the current French brand voice and glossary 2. Drafts two changes: update brand voice to informal register, add "workspace" as a non-translatable glossary term 3. Shows the diff for review 4. Applies on approval 5. Runs a test localization to verify the changes took effect ## Types of feedback the assistant handles | Feedback | Configuration change | | --- | --- | | "Use informal tone in Spanish" | Update brand voice for `es` | | "Don't translate our product name" | Add non-translatable glossary entry | | "'Sign up' should be 'S'inscrire' not 'Créer un compte'" | Add glossary entry with enforced localization | | "Always use Oxford comma in English" | Add locale-scoped instruction for `en` | | "Japanese needs keigo (polite form) for all user-facing text" | Update brand voice for `ja` | | "Percentage signs need a space before them in French" | Add instruction for `fr` | ## Batch tuning from a review report Paste an entire review or feedback document and the assistant extracts actionable patterns: > "Here's feedback from our German reviewer: [paste]. Extract the glossary terms and instructions." The assistant groups recurring themes, classifies each as a glossary entry, instruction, or brand voice update, drafts the full set of changes, and applies after your review. ## Verify after tuning After applying changes, the assistant localizes a sample to confirm the configuration works: > "Localize 'Sign up for your workspace' to French through my engine" If the output reflects the new rules (informal 'tu', 'workspace' preserved), the tune is complete. - [Triage Localization Bugs](https://lingo.dev/en/docs/mcp/triage): Reproduce a localization issue, inspect engine state, and identify root cause - all from your AI assistant without leaving the editor. When a localization comes out wrong, your assistant can reproduce the issue, inspect the engine's state, and identify the root cause — all in one conversation. ## The workflow > "The German localization of 'data processing agreement' came out as 'Datenverarbeitungsvereinbarung' but our legal team requires 'Auftragsverarbeitungsvertrag'. Why?" What happens: 1. The assistant localizes the source string through your engine to reproduce the issue 2. Pulls the glossary to check if the term is covered 3. Checks scorer verdicts and glossary compliance for recent requests 4. Reports the root cause: "The term isn't in your glossary — the model chose a valid but non-standard localization" 5. Recommends the fix: "Add a glossary entry to enforce the legal team's preferred term" ## When to use this - A user or reviewer reports a bad localization - A scorer pass rate drops unexpectedly - You notice inconsistent terminology across localizations - A localization request failed and you need to understand why ## What the assistant inspects | Signal | What it checks | | --- | --- | | Wrong terminology | Glossary coverage — is the term defined? Was it matched? | | Wrong tone/register | Brand voice configuration — does the locale have one? | | Rule violation | Instruction review logs — did the rule apply? Did it pass? | | Model failure | Request logs — did the primary model fail? Did fallback trigger? | | Scorer failure | Scorer run logs — which scorer flagged it? What was the reasoning? | ## Triage → Fix Triage identifies the cause. The fix depends on what's broken: - **Missing glossary term** → "Add 'data processing agreement' → 'Auftragsverarbeitungsvertrag' to the glossary for `de`" - **Wrong brand voice** → "Update the German brand voice to use formal register" - **Missing instruction** → "Add an instruction for `de` about legal terminology conventions" - **Model limitation** → "Try a different primary model for `en → de` legal content" Your assistant can apply the fix immediately after you approve it — one conversation from bug report to resolution. - [Debug Localization Quality](https://lingo.dev/en/docs/mcp/observe): Inspect request logs, AI Reviewer verdicts, glossary matching, and instruction compliance directly from your AI assistant - the post-mortem workflow for localization engineers. When a localization comes out wrong, the MCP server gives your AI assistant access to the full observability stack - request logs, scorer verdicts, glossary matching reports, and instruction review results. Debug quality without leaving the conversation. ## Request logs Every localization request produces a log entry with the full execution context: which model handled it, input and output tokens, duration, whether a fallback was triggered, and the complete input/output data. > "Show me the last request log for the German engine" The assistant retrieves the log and can answer follow-up questions: "Did it use the fallback model?" "How many tokens did it consume?" "What was the raw output?" ### What each log contains | Field | What it tells you | | --- | --- | | Provider / model | Which LLM handled the request | | Input / output data | Exact input sent and localization received | | Input / output tokens | Token consumption | | Duration | Processing time in milliseconds | | Used fallback | Whether the primary model failed and fallback kicked in | | Status | `success`, `error`, or `in_progress` | | Error text | Error detail when status is `error` | | Trigger type | Whether the request came from API, CLI, CI, playground, or integration | ## AI Reviewer verdicts Each request log links to scorer run logs - the independent AI Reviewer evaluations that ran after the localization was produced. > "Did the last German localization pass all scorers?" The assistant retrieves scorer run logs for a given request and reports each scorer's verdict: pass/fail (boolean scorers) or percentage score, along with the reasoning the reviewer produced. ### Scorer run log fields | Field | What it tells you | | --- | --- | | Scorer name | Which AI Reviewer ran | | Scorer type | `boolean` (pass/fail) or `percentage` (0-100) | | Score result | The verdict and reasoning | | Provider / model | Which model performed the review | | Duration | How long the review took | ## Glossary compliance > "Were all glossary terms applied correctly in that localization?" The assistant retrieves the glossary review log for a request, showing each matched glossary term, whether it was applied, and the reasoning if it wasn't. The report includes: - Each source term matched - The expected target localization - Whether the term is a custom localization or non-translatable - Applied or not applied per term - Reasoning when a term wasn't applied - Overall compliance rate ## Instruction adherence > "Did the French localization follow the non-breaking space instruction?" The assistant retrieves instruction review logs - one entry per instruction that was evaluated against the localization output. Each shows the instruction name, the rule text, and a pass/fail verdict with reasoning. ## The debugging workflow A typical post-mortem conversation: 1. "The German localization of 'checkout flow' looks wrong" 2. "Show me the request log for that" - see what went in and came out 3. "Did the glossary apply?" - check if 'checkout' was matched and preserved 4. "What did the scorers say?" - see if any AI Reviewer flagged it 5. "The glossary term wasn't matched - update it to also cover 'checkout flow'" - fix the root cause The entire loop happens in one conversation, without opening the dashboard. - [Localize from the Editor](https://lingo.dev/en/docs/mcp/localize): Run localization and language detection directly from your AI coding assistant - test your localization engine on real content without switching context. The MCP server exposes your localization engine directly inside your AI assistant. Test a glossary term, localize a batch of strings, detect a language - without leaving the conversation. ## Localize content Tell your assistant to localize content and it calls your engine with the full configuration - glossary, brand voice, instructions, and model chain all applied automatically. > "Localize these strings to German through my engine: 'Add to cart', 'Proceed to checkout', 'Your order has been placed'" The assistant sends the key-value data to your engine, specifying source and target locale. Your engine's glossary enforces exact terms (e.g. "checkout" stays as "Checkout" if you configured it), brand voice applies the correct register, and the configured model chain handles the generation. You can also provide hints - contextual breadcrumbs that help the engine disambiguate: > "Localize 'Share' to Japanese - it's a button label in a social media context, not a financial share" ### What the assistant manages for you | Parameter | What it does | | --- | --- | | Engine selection | Which engine to use (defaults to the first in your organization) | | Source locale | Source language (e.g. `en`) | | Target locale | Target language (e.g. `de`, `ja`, `pt-BR`) | | Data | Key-value map of strings to localize | | Hints | Optional breadcrumbs per key for disambiguation | | Reference | Pre-existing localizations for few-shot context | ## Batch localization (async) For larger payloads or multi-locale runs, the assistant uses async jobs - submit once, get results for every target locale: > "Localize this JSON file to French, German, and Japanese. Use my production engine." The assistant submits all target locales in a single request. Each locale becomes an independent job that processes in parallel. You can ask for status at any time: > "What's the status of that localization batch?" The assistant polls the job group and reports progress - how many locales are complete, any failures or warnings, and retrieves results when ready. ### Async job capabilities - Submit up to 100 target locales per request - Optional webhook callback when jobs complete - Idempotency keys to prevent duplicate submissions - Pipeline configuration overrides per request - Locked keys excluded from localization ## Detect language > "What language is this text: 'Nous sommes ravis de vous accueillir'" The assistant detects the language and returns the BCP-47 locale, language name, region, script, and text direction (LTR/RTL). ## When to use this vs. the CLI | Scenario | Use | | --- | --- | | Testing a glossary term you just added | MCP - instant feedback in the conversation | | Localizing a single string during code review | MCP - no context switch | | Running a full project localization across all files | CLI - designed for file-based workflows | | CI/CD pipeline integration | CLI or API - automated, repeatable | | Localizing a batch of strings to many locales at once | MCP async jobs - one prompt, parallel processing | - [Import from Legacy Vendors](https://lingo.dev/en/docs/mcp/import): Migrate glossaries and translation memory from legacy vendors into your localization engine - parse TMX, CSV, or TBX files and seed your engine's glossary and instructions. When migrating from a legacy localization vendor or TMS, you likely have glossaries, term bases, or translation memory exports sitting in TMX, CSV, or TBX files. Your AI assistant can parse these and seed your localization engine's configuration directly. ## The workflow > "Here's our glossary export (CSV). Import it into our engine's glossary for all locales." What happens: 1. The assistant reads the CSV structure — identifies source term, target localization, locale, and term type columns 2. Maps each row to a glossary entry: source text, target text, locale pair, and whether it's a custom localization or non-translatable term 3. Shows the import plan: "Found 147 terms across 6 locales. 12 are marked do-not-translate, 135 are enforced localizations." 4. On approval, creates all glossary entries via the MCP 5. Reports: "147 glossary entries created. 3 duplicates skipped." ## Supported formats | Format | What it contains | How to provide it | | --- | --- | --- | | CSV / TSV | Term bases, glossaries, simple bilingual lists | Paste the content or describe the file structure | | TMX | Translation memory — source/target segment pairs with metadata | Paste a representative sample or describe the structure | | TBX | Terminology databases — structured term entries with definitions | Paste the content or describe the schema | | Excel exports | Vendor-specific glossary or style guide exports | Describe the columns and paste representative rows | ## Step-by-step: TMX import TMX files from legacy vendors contain segment pairs that can seed both glossary entries and instructions. > "Here's a TMX export from our previous vendor. It has 500 translation units for en → de. Extract any recurring terminology as glossary entries." What happens: 1. The assistant parses the TMX structure — identifies source segments, target segments, locale pairs 2. Groups recurring terms — words or phrases that appear 3+ times with consistent localizations 3. Proposes glossary entries for terms with stable localizations: "'privacy policy' → 'Datenschutzerklärung' (appears 12 times, always localized this way)" 4. Identifies patterns that should become instructions: "Compound nouns are always hyphenated in this corpus — add as instruction for `de`?" 5. Shows the full plan for review 6. Applies on approval ## Step-by-step: CSV glossary import Most legacy localization platforms export glossaries as CSV with columns for source, target, locale, and notes. > "Import this CSV into our engine. Columns: source_term, target_term, locale, type (localize/do-not-translate), notes." What happens: 1. The assistant reads the column mapping 2. Creates glossary entries: `localize` rows become custom localizations, `do-not-translate` rows become non-translatable entries 3. Entries with notes that describe rules (not just definitions) are flagged as potential instructions: "The note for 'date format' says 'Always use DD.MM.YYYY in German' — add as instruction for `de`?" 4. Shows the plan, applies on approval ## What to import vs. what to leave behind | Import as glossary | Import as instruction | Skip | | --- | --- | --- | | Brand names (non-translatable) | Formatting rules (date, number, currency) | Fuzzy TM matches below 95% | | Product terminology (enforced localizations) | Punctuation conventions | Context-dependent segment pairs | | Legal terms (enforced localizations) | Register/formality rules | One-off localizations that aren't terminology | | UI labels with mandated localizations | Capitalization rules | Segments longer than 2-3 sentences | ## After import 1. **Verify** — run a [localization test](/docs/mcp/localize) with content that uses the imported terms 2. **Review** — [spot-check](/docs/mcp/review) a batch against the new glossary to confirm enforcement 3. **Tune** — [adjust](/docs/mcp/tune) entries that don't produce the right output in context ## Tips for large imports - **Start with high-frequency terms.** A 5,000-entry TM export isn't a glossary — it's a corpus. Ask the assistant to extract only terms that appear 3+ times. - **Import in batches by locale.** Easier to review 50 German terms than 500 terms across 10 locales. - **Use the notes column.** If your export has translator notes, the assistant can convert patterns into instructions. - **Don't import sentence-level TM as glossary.** Glossary entries are terms and short phrases. Full sentences belong in reference material, not the glossary. - [Provision Engines with AI](https://lingo.dev/en/docs/mcp/provision): Create a fully configured localization engine from links and content - submit your website, docs, or style guide and receive brand voice, glossary, and instructions extracted automatically. The MCP server can create a fully configured localization engine from your existing content. Submit links to your website, documentation, or style guides, and AI extracts brand voice, glossary terms, and instructions automatically. ## How it works > "Create a localization engine called 'Acme' for German and Japanese. Use our website at acme.com and this style guide as sources." The assistant submits a provisioning request with: - The engine name and optional description - Target locales - Source material (links to scrape or raw content to analyze) The provisioning job runs asynchronously - AI reads the sources, identifies domain terminology, extracts tone and style patterns, and populates the engine with: - **Brand voice** profiles per locale - **Glossary entries** for product terms, brand names, and domain-specific vocabulary - **Instructions** for locale-specific linguistic patterns ## Source types | Type | Use case | Example | | --- | --- | --- | | Link | Websites, docs, publicly accessible style guides | `https://acme.com`, `https://docs.acme.com/style-guide` | | Content | Raw text, markdown, terminology lists, internal style guides | Paste brand guidelines, glossary CSVs, or tone-of-voice documents | You can combine up to 10 sources per provisioning request. Mix links and content freely. ## Example workflows ### New client onboarding > "Create an engine called 'ClientName' for es, de, fr, ja. Scrape their website at clientname.com and their docs at docs.clientname.com/brand" One prompt produces a production-ready engine with extracted terminology and brand voice. Review and refine the configuration afterward. ### Bootstrapping from internal docs > "Create an engine called 'Product v2' for pt-BR. Here's our style guide: [paste content]. And our terminology list: [paste content]." The assistant submits the raw content as sources. The provisioning job extracts structured glossary entries and instructions from unstructured text. ### Adding locales to an existing engine Provisioning creates a new engine. To add locales to an existing engine, use the standard configuration tools (brand voice, instructions, model configs) described in [Capabilities](/docs/mcp/capabilities). ## After provisioning completes The job returns a new engine ID. Your assistant can then: - Inspect the extracted configuration: "Show me the glossary for the Acme engine" - Refine specific entries: "Change the German brand voice to use Sie instead of du" - Test it immediately: "Translate 'Welcome to Acme' to Japanese through the new engine" - Connect it to the CLI: update `i18n.json` with the new engine ID Provisioning is a starting point. The extracted configuration improves as you tune it with real translation feedback. - [Setup](https://lingo.dev/en/docs/mcp/setup): Connect the Lingo.dev MCP server to your AI coding assistant - Claude Code, Codex, Cursor, Claude Desktop, or ChatGPT - with OAuth sign-in or static API key. Connect your assistant to `https://mcp.lingo.dev/account` and sign in through your browser. No API key copy-paste - the server runs a standard OAuth flow, you pick the organization on the consent screen, and the access token is cached locally by your client. {% tabs %} {% tab label="Claude Code" %} Run in your terminal - registers the server globally for all projects (also picked up by the Claude Code VS Code extension, which shares the same config): ```bash claude mcp add lingo --transport http https://mcp.lingo.dev/account --scope user ``` Verify with `claude mcp list`. Or, to manage it yourself, add to `~/.claude/settings.json` or project-level `.mcp.json`: ```json { "lingo": { "type": "http", "url": "https://mcp.lingo.dev/account" } } ``` {% /tab %} {% tab label="Codex" %} Codex doesn't have an `mcp add` command yet. Open the config in your terminal: ```bash mkdir -p ~/.codex && open -e ~/.codex/config.toml # macOS - replace with your editor on Linux/Windows ``` Add this block (or merge with your existing `[mcp_servers.*]` entries): ```toml [mcp_servers.lingo] url = "https://mcp.lingo.dev/account" ``` {% /tab %} {% tab label="Cursor" %} Cursor reads MCP servers from a JSON file. Open it in your terminal: ```bash mkdir -p ~/.cursor && open -e ~/.cursor/mcp.json # macOS - replace with your editor on Linux/Windows ``` Add Lingo to the `mcpServers` block (create the file if it doesn't exist): ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/account" } } } ``` For project-scoped install, use `.cursor/mcp.json` in the repo root instead. {% /tab %} {% tab label="Claude Desktop / claude.ai" %} Recent versions of Claude Desktop support remote MCP servers through the **Connectors** UI - no config file editing required: {% steps %} {% step title="Open Connectors settings" %} Click the gear icon (top-right) > **Settings** > **Connectors** in the sidebar. {% /step %} {% step title="Add a custom connector" %} Click **Add custom connector**. In the dialog, set: - **Name:** `Lingo` - **Remote MCP server URL:** `https://mcp.lingo.dev/account` Click **Add**. {% /step %} {% step title="Authorize" %} Claude Desktop opens your browser for the Lingo OAuth flow. Pick the organization on the consent screen and click **Approve**. The token is saved automatically. {% /step %} {% /steps %} {% accordion title="Older Claude Desktop versions (no Connectors UI)" %} Older builds need the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge. Open the config: ```bash open -e "$HOME/Library/Application Support/Claude/claude_desktop_config.json" # macOS # Windows: notepad "%APPDATA%\Claude\claude_desktop_config.json" ``` Add Lingo to the `mcpServers` block (create the file with `{}` if it's missing): ```json { "mcpServers": { "lingo": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.lingo.dev/account"] } } } ``` Fully quit Claude Desktop (`Cmd+Q`) and reopen - the new server appears on next launch. {% /accordion %} {% /tab %} {% tab label="ChatGPT" %} ChatGPT supports remote MCP servers as **custom apps** behind the developer-mode toggle. {% steps %} {% step title="Enable developer mode" %} Open **Settings** > **Apps** > **Advanced Settings** and toggle **Developer mode** on. {% /step %} {% step title="Create the app" %} Click **Create app** and fill in: - **Name:** `Lingo` - **Authentication:** `OAuth` - **MCP Server URL:** `https://mcp.lingo.dev/account` Save and click **Connect**. {% /step %} {% step title="Authorize" %} ChatGPT opens your browser for the Lingo OAuth flow. Pick the organization on the consent screen and click **Approve**. The token is saved automatically. {% /step %} {% /steps %} {% /tab %} {% /tabs %} The first time your assistant calls a Lingo tool, a browser window opens for sign-in and consent. After you approve, the token is stored by the client and reused on every subsequent call. {% callout type="info" title="Organization scope" %} The consent screen lets you pick which organization the MCP server should manage. Your assistant never needs to specify an organization ID - every tool operates within the organization you approved. To switch organizations, remove the connection and add it again. {% /callout %} {% accordion title="Use an API key instead (legacy)" %} If you can't run the OAuth flow - for CI, headless environments, or shared service accounts - the MCP server still accepts a static API key in the `x-api-key` header. Generate one in the [API Keys](/docs/platform/api-keys) section of the dashboard. {% tabs %} {% tab label="Claude Code" %} ```json { "lingo": { "type": "http", "url": "https://mcp.lingo.dev/account", "headers": { "x-api-key": "your_api_key" } } } ``` {% /tab %} {% tab label="Codex" %} ```toml [mcp_servers.lingo] url = "https://mcp.lingo.dev/account" http_headers = { "x-api-key" = "your_api_key" } ``` {% /tab %} {% tab label="Cursor" %} ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/account", "headers": { "x-api-key": "your_api_key" } } } } ``` {% /tab %} {% /tabs %} The API key determines which organization the MCP server manages - there is no consent screen in this mode. {% /accordion %} ## Docs – Platform - [Engine Suggestions](https://lingo.dev/en/docs/platform/engine-suggestions): When your AI reviewers score a translation low, Lingo.dev reads the failing reviews, finds the pattern, and proposes the exact glossary, instruction, or brand-voice fix – ready to apply in one click. Turn quality signals into engine improvements automatically. You already run [AI Reviewers](/docs/platform/ai-reviewers) on your translations, so you can see when quality slips – a glossary term ignored, a formality rule missed, a score that drops below the bar. Seeing the problem is one thing. Turning it into the right engine change is another: read the failing reviews, spot what they have in common, decide whether the fix is a glossary entry, an instruction, or a brand-voice change, then write it. That second step is the slow part, and it is the one that quietly never gets done. Engine Suggestions does it for you. When reviews come back low, Lingo.dev reads them, finds the pattern, and proposes the exact edit to your engine's [glossary](/docs/platform/glossaries), [instructions](/docs/platform/instructions), or [brand voice](/docs/platform/brand-voices) – with the reasoning attached. You review it and click **Apply**, or **Dismiss** it. Low scores in, concrete engine fixes out. {% callout type="info" title="A suggestion is a proposed edit, not a translation change" %} A suggestion is a pending change to your engine's configuration. Applying one writes a real glossary item, instruction, or brand-voice entry – the same record you would have created by hand. It does not re-translate anything: the change takes effect on the **next** translation the engine runs. {% /callout %} ## Automatic suggestions from low scores This is the main way suggestions appear. If you run [AI Reviewers](/docs/platform/ai-reviewers), every translation is already being scored against your glossary, instructions, and custom criteria. A run of low scores – a failed boolean check, or a percentage under the bar – is a signal that something in the engine needs tuning. Turn on **auto-suggestions** and Lingo.dev acts on that signal for you: it reads the failing reviews, finds what they have in common, and proposes edits in the background, without you asking. Enable it from the engine's **Reviews** tab. From then on, a run of low scores quietly produces suggestions, and you find them waiting in the **Suggestions** tab – and in a notification, so they do not sit unnoticed. {% callout type="info" title="It batches, it doesn't spam" %} Auto-generation is debounced to roughly one run per engine every ten minutes, so a burst of low scores produces one considered batch of suggestions rather than a flood. Identical proposals are de-duplicated, so you never see the same edit twice. {% /callout %} ### Any translation can trigger it The signal is the review score, not where the translation came from. Whether the engine ran a [synchronous call](/docs/api/localize), an [async job](/docs/api/localization), or a job through the full [localization pipeline](/docs/api/pipeline), the result is scored by the same AI Reviewers – and a low score feeds the same suggestions. So the more of your translation traffic runs through reviewed engines, the more the suggestions reflect what is actually going wrong in production. ### Generate on demand You do not have to wait for the next low score. A **Generate suggestions** button runs the same analysis immediately – recent low-scoring reviews plus the engine's current config – whether or not auto-suggestions is enabled. Use it after you have made other changes and want a fresh read, or when you would rather pull suggestions than wait for them. ## What a suggestion proposes Every suggestion targets one of three parts of the engine, and is either an addition or an update to an existing entry: | Action | What applying it does | | --- | --- | | **Add / update glossary item** | Creates or changes a [glossary](/docs/platform/glossaries) rule – an enforced translation, or a term marked non-translatable. | | **Add / update instruction** | Creates or changes a per-locale [instruction](/docs/platform/instructions). | | **Add / update brand voice** | Creates or changes the [brand voice](/docs/platform/brand-voices) for a locale. | Each suggestion carries its **reasoning** – a short explanation of why it is proposing this edit – and the target locale it applies to. You are never asked to trust an opaque change: you read what it wants to do and why before anything is written. {% callout type="info" title="Suggestions are surgical" %} The model proposes atomic edits – one glossary item, one instruction – not a rewrite of your engine. Each is reviewed and applied on its own, so you can take the three that are right and drop the one that isn't. {% /callout %} ## Review, apply, dismiss Suggestions land in the engine's **Suggestions** tab as `pending`. Each one shows the proposed change, the target locale, and the reasoning. Two actions: {% steps %} {% step title="Apply" %} Writes the proposed change into the engine – a real glossary item, instruction, or brand-voice entry. Applying is a deterministic write of the proposed edit: no second AI call, no surprise. The suggestion is marked `applied` and the change takes effect on the engine's next translation. {% /step %} {% step title="Dismiss" %} Drops the suggestion. Use it when a proposal is wrong for your product – you know your terminology better than any model. Dismissing leaves the engine untouched. {% /step %} {% /steps %} Because applying writes the same kind of record you would create by hand, an applied suggestion is not a black box afterward: it is an ordinary glossary item, instruction, or brand-voice entry you can open, edit, or delete like any other. {% callout type="warning" title="Apply does not re-translate" %} Applying a suggestion changes the engine's configuration, not past translations. Content already translated keeps its existing output until it is translated again. The improvement shows up on the next run through the engine. {% /callout %} ## Notifications When a generation run produces new suggestions, the engine's members are notified – in the app and by email – so improvements do not sit unnoticed in a tab nobody opened. It is the same notification system as the rest of the platform: if you would rather not hear about it, mute **Engine suggestions generated** in your notification preferences. ## Generate from your own feedback Not every problem shows up as a low score. Sometimes a linguist or a support ticket tells you, in plain words, exactly what is wrong – "the German copy sounds too stiff", "stop translating the product name". You can feed that text straight in and get the same kind of suggestion, through the [Engine Suggestions API](/docs/api/engine-suggestions). It is the same review-apply-dismiss flow you see here; only the trigger differs – your written feedback instead of a review score. ## Reviewers measure, suggestions act [AI Reviewers](/docs/platform/ai-reviewers) and Engine Suggestions are two halves of one quality loop. Reviewers **measure** – they score each translation against your glossary, instructions, and custom criteria, and tell you where quality is slipping. Suggestions **act** – they read those low scores and propose the engine change that would raise them. Reviewers find the problem; suggestions draft the fix; you decide. Together they close the loop: translate, score, suggest, apply, and the next translation is better. ## Next steps {% card-grid %} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="target" description="Score every translation against your glossary, instructions, and custom criteria – the signal automatic suggestions act on." /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Enforced translations and non-translatable terms – one of the three things a suggestion can change." /%} {% link-card title="Instructions" href="/docs/platform/instructions" icon="file-code" description="Per-locale linguistic rules the engine follows on every translation." /%} {% link-card title="Engine Suggestions API" href="/docs/api/engine-suggestions" icon="code" description="Generate suggestions from your own written feedback, list them, and apply or dismiss them from code." /%} {% /card-grid %} - [Locale Resolution](https://lingo.dev/en/docs/platform/locale-resolution): How the localization engine matches stored glossaries, brand voices, instructions, and model configs to a request locale - including regional fallback and why sibling regions never share config. Every glossary item, brand voice, instruction, and model config is stored against a locale. When the engine processes a translation request, it resolves which stored entries apply to the request locale - matching exact codes, inheriting across regional variants, and falling back when no exact entry exists. The same resolution applies to all four configuration surfaces. ## How it works Locales are normalized to a canonical form on input, then stored and returned in that form. Casing and delimiters are fixed; subtags are preserved. | You enter | Stored as | | --- | --- | | `EN` | `en` | | `en_US` | `en-US` | | `sr_Latn-RS` | `sr-Latn-RS` | | `zh-cn` | `zh-CN` | Matching is bidirectional across the subtag boundary - a stored locale applies to a request when one is an exact match or an ancestor of the other. | Stored | Applies to | Does not apply to | | --- | --- | --- | | `de` | `de`, `de-DE`, `de-AT`, `de-CH` | - | | `de-DE` | `de-DE`, `de` | `de-AT`, `de-CH` (siblings) | {% callout type="info" title="Reverse-inheritance" %} A stored `de-DE` answering a bare `de` request is the dominant real-world pattern: most engines are configured against full regional codes but receive base-code requests. Both directions are supported. {% /callout %} ## Resolving between multiple matches When more than one stored entry applies, the engine ranks them and uses the best: - **Exact or language-default first.** For a `de` request, `de-DE` (the CLDR default region for German) is preferred, then bare `de`. - **Most specific next**, as a tiebreak. - **Any other matching region survives as a fallback** - a customer whose only entry is `de-CH` still gets it for a `de` request when nothing better matches, so configuration is never orphaned. | Request | Preferred | Also applies (fallback) | Excluded | | --- | --- | --- | --- | | `de` | `de-DE`, then `de` | `de-CH`, `de-AT` | - | | `de-DE` | `de-DE`, then `de` | - | `de-AT`, `de-CH` | | `de-AT` | `de-AT`, then `de` | - | `de-DE`, `de-CH` | ## Script safety One extra rule applies only to glossary `custom_translation` items, whose text is bound to a specific orthography. A base language with an ambiguous script - `sr` (Cyrillic or Latin), `zh` (Simplified or Traditional) - must commit to a script (`sr-Cyrl`, `sr-Latn`, `zh-Hans`, `zh-Hant`) on write. Languages with a single script, like `de`, need no script and resolve `de` to `de-DE` normally. `non_translatable` items pass through regardless of script. ### Example An engine configured with regional codes (`en-US` to `fr-FR`, `de-DE`, `nb-NO`) receiving base-code requests (`fr`, `de`, `no`): - A `fr` target picks up the `fr-FR` glossary, brand voice, and instructions - ranked as the default for `fr`, not a last resort, because `fr-FR` is the CLDR default region for French. - A source of `en` matches `en-US` entries - matching is bidirectional. - A `no` target does not pick up `nb-NO`. `no` and `nb` are different language subtags, not a region pair; use `nb` as the target. ## Using locale resolution with the API Resolution happens automatically when you call the [localize endpoint](/docs/api). The engine matches the request's `sourceLocale` and `targetLocale` against stored glossaries, brand voices, instructions, and model configs - no additional parameters are needed. ## Next Steps {% card-grid %} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define overall tone and formality per locale" /%} {% link-card title="Instructions" href="/docs/platform/instructions" icon="file-code" description="Add linguistic rules for specific locale pairs" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="lightning" description="Configure per-locale model selection and fallbacks" /%} {% /card-grid %} - [Glossaries](https://lingo.dev/en/docs/platform/glossaries): Enforce exact term translations or prevent translation entirely using glossary rules matched by semantic similarity. A glossary gives the localization engine exact control over specific terms - either enforcing a precise translation or preventing translation entirely. Glossary rules take precedence over the model's own judgment, so the engine applies them consistently across every request. ## How it works Each glossary item belongs to a localization engine and matches on a source locale and target locale pair (or `*` for any locale). When the engine processes a translation request, it retrieves relevant glossary items using semantic search - matching the meaning of the input text against stored terms, not just exact string matches. | Field | Description | | --- | --- | | **Source locale** | The locale of the source text, or `*` for any source | | **Target locale** | The locale of the target text, or `*` for any target | | **Source text** | The term in the source language | | **Target text** | The required translation (or the same term, for non-translatables) | | **Type** | `custom_translation` or `non_translatable` | | **Hint** | Optional context to disambiguate the term (e.g., "noun, the product feature") | ## Glossary types ### Custom translations Force a specific translation for a term. The engine will always use your translation instead of the model's. | Source text | Target text | Source locale | Target locale | | --- | --- | --- | --- | | Deploy | Bereitstellen | en | de | | 911 | 112 | en | de | | workspace | espace de travail | en | fr | Use custom translations for: - Product terminology with established translations - Cultural adaptations (emergency numbers, measurement units) - Terms where the model consistently picks the wrong synonym ### Non-translatables Prevent a term from being translated. The engine keeps the source text as-is. | Source text | Target text | Type | | --- | --- | --- | | Lingo.dev | Lingo.dev | non_translatable | | OAuth | OAuth | non_translatable | | GraphQL | GraphQL | non_translatable | Use non-translatables for: - Brand names and product names - Technical protocols and standards - Proper nouns that should remain in the source language ## Semantic matching Glossary items are matched by meaning, not exact string comparison. When the engine receives a translation request, it generates embeddings for the input text and finds glossary items with semantically similar source terms. This means a glossary entry for "Deploy" will also match "Deploying", "deployment", and "deploy your application" - without needing separate entries for each variation. {% callout type="info" title="Hint field" %} Use the hint field to disambiguate terms with multiple meanings. For example, a glossary entry for "bank" with hint "financial institution" won't match "river bank" in the input text. {% /callout %} ## Wildcard locales Set source or target locale to `*` to apply a glossary item across all locale pairs. **Common patterns:** | Source text | Source locale | Target locale | Use case | | --- | --- | --- | --- | | Lingo.dev | `*` | `*` | Never translate the brand name in any language | | API | `en` | `*` | Keep "API" untranslated across all target locales | | Deploy | `en` | `de` | Use specific German translation for this English term | Wildcard items and locale-specific items combine - they don't override each other. ## Locale matching Glossary items match across regional variants, not just exact locale codes. A `de` entry applies to `de-DE`; a `de-DE` entry applies to a bare `de` request. Siblings like `de-DE` and `de-AT` never share entries. When several match, the CLDR default region wins. The same rules drive brand voices, instructions, and model configs. See [Locale Resolution](/docs/platform/locale-resolution) for the full behavior, including the script-safety rule for custom translations. ## Glossary vs. instructions vs. brand voices Each serves a distinct purpose in the engine's configuration: | | Glossary | Instruction | Brand Voice | | --- | --- | --- | --- | | **Controls** | Individual terms | Linguistic rules | Overall tone and style | | **Granularity** | Per-term | Per-rule | Per-locale | | **Matching** | Semantic (by meaning) | All included for locale | All included for locale | | **Precedence** | Highest - overrides model judgment | Medium - guides the model | Lowest - sets context | | **Example** | "Deploy" → "Bereitstellen" | "Abbreviate Straße to Str." | "Use informal du, technical tone" | {% callout type="warning" title="Rule precedence" %} Glossary terms take the highest precedence in the engine's rule hierarchy. If a glossary item conflicts with an instruction, the glossary wins. Design instructions to complement the glossary, not duplicate it. {% /callout %} ## Using glossaries with the API Glossary items are applied automatically when you call the [localize endpoint](/docs/api). The engine retrieves semantically relevant items for the source and target locale pair and includes them in the prompt. No additional parameters are needed - the engine handles retrieval and injection. ## Managing glossaries via MCP If you use the [Lingo.dev MCP server](/docs/mcp), your AI coding assistant can manage glossary items directly: ``` "Add a glossary entry: translate 'workspace' to 'espace de travail' for English to French." ``` ``` "Mark 'GraphQL' as non-translatable for all locales." ``` ## Next Steps {% card-grid %} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define overall tone and formality per locale" /%} {% link-card title="Instructions" href="/docs/platform/instructions" icon="file-code" description="Add linguistic rules for specific locale pairs" /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Monitor glossary compliance automatically" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [Team](https://lingo.dev/en/docs/platform/team): Manage who has access to your Lingo.dev organization, invite members, and control permissions. Team management controls who has access to your organization on Lingo.dev. All members share access to the organization's engines, reports, AI reviewers, and API keys. ## Roles | Role | Permissions | | --- | --- | | **Admin** | Full access - manage engines, API keys, team members, billing, and all settings | All team members are admins by default. On the Enterprise plan, [Roles & Permissions](/docs/platform/rbac) replaces this default with custom roles you assign per member. ## Inviting members Invite team members by email from the Team page in the dashboard. The invitation flow: 1. Enter the email address of the person to invite 2. An invitation email is sent with a secure link 3. The recipient clicks the link and signs in (or creates an account) 4. They are added to the organization as an admin ### Via the MCP server You can also create invites and list pending ones from the [Lingo.dev MCP server](/docs/mcp), so AI coding assistants can manage your team without leaving the conversation. When the MCP session is authenticated with OAuth, the invite email shows the inviter's name. {% callout type="info" title="Invite limits" %} Only one pending invite per email address per organization. Invitations expire automatically - if an invite expires, you can send a new one. {% /callout %} ## Removing members Remove a team member from the Team page. Removing a member immediately revokes their access to the organization - they can no longer view engines, reports, or API keys. [Personal API keys](/docs/platform/api-keys) created by a removed member are orphaned — they keep working only while RBAC is off (legacy behaviour); once RBAC is on they fail authorization on every engine. Rotate them before removing the user. [Service API keys](/docs/platform/api-keys) are unaffected: they carry their own role and engine scope and have no tie to the user who created them. ## Next Steps {% card-grid %} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="lightning" description="Generate keys for API and MCP access" /%} {% link-card title="Reports" href="/docs/platform/reports" icon="gear" description="Monitor your organization's translation activity" /%} {% link-card title="How it works" href="/docs/platform" icon="book" description="Learn how the localization engine works" /%} {% /card-grid %} - [LLM Models](https://lingo.dev/en/docs/platform/llm-models): Choose which LLM handles each locale pair in your localization engine, with ranked fallback chains for production reliability. Every localization engine on Lingo.dev uses LLM models to produce translations. You choose which model handles each locale pair, configure fallbacks for reliability, and use wildcard locales to set defaults - all without managing API keys or provider accounts. ## Available models Lingo.dev provides access to 400+ models from every major provider through a single platform: | Provider | Notable models | | ------------- | ----------------------------------------------------- | | **OpenAI** | GPT-4o, GPT-4 Turbo, o3, o4-mini | | **Anthropic** | Claude Opus, Claude Sonnet, Claude Haiku | | **Google** | Gemini 2.0 Flash, Gemini Pro (up to 1M token context) | | **Meta** | Llama 3.3 70B, Llama 3.1 405B | | **Mistral** | Mistral Large, Mixtral | | **DeepSeek** | DeepSeek V3 | The full model catalog - with context window sizes - is available on the [LLM Models](/docs/platform/llm-models) page. {% callout type="info" title="No provider accounts needed" %} You don't need API keys from individual providers. Lingo.dev handles authentication, billing, and routing to all models through a unified infrastructure. {% /callout %} ## Model configs A model config assigns a specific model to a source-target locale pair within a localization engine. | Field | Description | | ----------------- | ----------------------------------------------------------------- | | **Provider** | The model provider (e.g., `openai`, `anthropic`, `google`) | | **Model** | The specific model (e.g., `gpt-4o`, `claude-sonnet-4-5-20250514`) | | **Source locale** | The source locale, or `*` for any source | | **Target locale** | The target locale, or `*` for any target | When the engine receives a translation request, it selects the most specific matching config based on the source and target locales. ## Defaults and customization The Lingo.dev team has been researching which models produce the best translations for each language pair since 2023. When a new localization engine is created, it comes pre-configured with sensible model defaults - primary models and fallbacks selected based on that research, optimized for quality across common and low-resource languages alike. Most teams won't need to change them. These defaults are designed to work well out of the box. You can edit any model config, swap providers, add fallbacks, or override specific locale pairs with models you prefer - but the defaults already reflect what we've found works best across hundreds of language pairs. The engine's model configuration is fully yours to control. ## Fallback models LLMs evolve fast - new models ship weekly, capabilities improve with each generation, and pricing drops as competition intensifies. But that velocity comes at a cost: provider outages, rate limits, content filter changes, and model deprecations are routine. A production localization pipeline that depends on a single model is a pipeline that will eventually break. Lingo.dev's localization engine is purpose-built for production-grade translation workflows. Each locale pair supports a fallback model - if the primary model fails, the engine automatically and transparently tries the next fallback model, without any intervention or failed requests reaching your users. ### How fallback ordering works The engine sorts available configs by specificity, then by priority: 1. **Target locale specificity** - exact target locale beats wildcard `*` 2. **Source locale specificity** - exact source locale beats wildcard `*` 3. **Priority** - default, then fallback ### Example Given these configs for an engine: | Source | Target | Model | Priority | | ------ | ------ | ------------- | -------- | | `en` | `de` | GPT-4o | Default | | `en` | `de` | Claude Sonnet | Fallback | | `*` | `de` | Gemini Flash | Default | | `*` | `*` | GPT-4o-mini | Default | A request translating `en → de` tries models in this order: 1. **GPT-4o** - exact match, default 2. **Claude Sonnet** - exact match, fallback 3. **Gemini Flash** - wildcard source, exact target, default 4. **GPT-4o-mini** - wildcard both, default A request translating `fr → de` skips the first two (source doesn't match) and starts at Gemini Flash. {% callout type="info" title="Fallback tracking" %} When a fallback model handles a request, the engine records it in the request log. Monitor fallback usage in [Reports](/docs/platform/reports) to identify unreliable primary models. {% /callout %} ## Wildcard locales Set source or target locale to `*` to create default configs that apply when no locale-specific config exists. **Common patterns:** | Source | Target | Model | Purpose | | ------ | ------ | ------------- | ------------------------------------------- | | `*` | `*` | GPT-4o | Catch-all default for any locale pair | | `en` | `*` | Claude Sonnet | Default for all English-source translations | | `*` | `ja` | GPT-4o | Use a specific model for Japanese targets | | `en` | `de` | Mistral Large | Override default for this specific pair | Specific configs always take priority over wildcard configs. Use wildcards to set sensible defaults, then override for locale pairs that need special handling. ## Managing model configs via MCP If you use the [Lingo.dev MCP server](/docs/mcp), your AI coding assistant can configure models directly: ``` "Set GPT-4o as the primary model for English to German, with Claude Sonnet as fallback." ``` ``` "Add a catch-all model config using GPT-4o-mini for all locale pairs." ``` ## Next Steps {% card-grid %} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define overall tone and formality per locale" /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Monitor translation quality per model" /%} {% link-card title="Reports" href="/docs/platform/reports" icon="gear" description="Track model usage, fallbacks, and token consumption" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [Instructions](https://lingo.dev/en/docs/platform/instructions): Add named linguistic rules to your localization engine that handle specific translation patterns for each target locale. Instructions are named linguistic rules that tell the localization engine how to handle specific translation patterns for a target locale. Unlike a brand voice, which sets the overall tone, instructions encode discrete, testable rules - each addressing one linguistic concern. ## How it works Each instruction belongs to a localization engine and targets a specific locale (or all locales via the `*` wildcard). When the engine processes a translation request, it collects all instructions matching the target locale and includes them in the LLM prompt alongside the brand voice and glossary. | Field | Description | | --- | --- | | **Name** | A short label identifying the rule (e.g., "German formal address") | | **Target locale** | The locale this instruction applies to, or `*` for all locales | | **Text** | The linguistic rule, written in natural language | {% callout type="info" title="Multiple instructions per locale" %} You can create as many instructions as needed for each locale. Each instruction should address one specific rule - this makes them easier to test, update, and debug independently. {% /callout %} ## Instructions vs. brand voices Both shape translation output, but at different levels: | | Brand Voice | Instruction | | --- | --- | --- | | **Scope** | Overall tone, style, formality | One specific linguistic rule | | **Per locale** | One per locale | Many per locale | | **Wildcard** | No | Yes (`*` applies to all locales) | | **Example** | "Use informal du, technical tone" | "Always abbreviate Straße to Str. in addresses" | **Use a brand voice** to define how your product speaks in a language - formality, register, sentence style. **Use instructions** to encode specific rules the model would otherwise miss - abbreviation conventions, punctuation preferences, unit formatting, or locale-specific grammar patterns. They work together: the brand voice sets the voice, instructions handle the edge cases. ## Writing effective instructions Each instruction should be a single, unambiguous rule. The engine includes the full text in the LLM prompt, so clarity matters. ### Good instructions ``` Always use the Oxford comma in English lists. ``` ``` In Japanese, use full-width parentheses ()instead of half-width (). ``` ``` For German addresses, abbreviate "Straße" to "Str." and "Nummer" to "Nr." ``` ``` When translating percentage values for French, add a non-breaking space before the percent sign: 42 %. ``` ### What to avoid - Vague guidance that overlaps with the brand voice ("be more casual") - put that in the brand voice instead - Multiple unrelated rules in one instruction - split them so each can be tested independently - Rules that contradict the glossary - glossary terms take precedence in the engine's rule hierarchy ## Wildcard locale Set the target locale to `*` to apply an instruction across all locales. This is useful for rules that are language-independent: ``` Never translate product feature names: "Smart Compose", "Quick Actions", "Flow Builder". ``` ``` Preserve Markdown formatting in all translated strings. Keep bold (**), italic (*), and link syntax [text](url) intact. ``` Locale-specific instructions and wildcard instructions are both included when the engine processes a request - they combine, not override. ## Using instructions with the API Instructions are applied automatically when you call the [localize endpoint](/docs/api). The engine collects all instructions matching the request's `targetLocale` (plus any `*` wildcard instructions) and includes them in the prompt. No additional parameters are needed in the API request. ## Managing instructions via MCP If you use the [Lingo.dev MCP server](/docs/mcp), your AI coding assistant can create, update, and delete instructions directly: ``` "Add an instruction for German: always abbreviate Straße to Str. in addresses." ``` ``` "Add a wildcard instruction: never translate the term Smart Compose." ``` ## Next Steps {% card-grid %} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define overall tone and formality per locale" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale" /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Validate that instructions are being followed" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [Brand Voices](https://lingo.dev/en/docs/platform/brand-voices): Define per-locale tone, formality, and style rules that your localization engine applies to every translation. A brand voice defines how your product speaks in a specific language - tone, formality, and style rules that the localization engine applies to every translation for that locale. ## How it works Each brand voice is tied to a single target locale within a localization engine. When the engine processes a translation request for that locale, it includes the brand voice text as context for the LLM - shaping word choice, sentence structure, and register. | Field | Description | | --- | --- | | **Target locale** | The locale this voice applies to (e.g., `de`, `fr-CA`, `ja`) | | **Voice text** | Free-form instructions describing tone, formality, and style for this locale | {% callout type="info" title="One voice per locale" %} Each localization engine supports one brand voice per target locale. This keeps instructions unambiguous - the model receives exactly one set of style directives per language. {% /callout %} ## Defining a brand voice Brand voice text is free-form natural language. Write it as if briefing a translator who has never worked with your product. **Effective brand voices include:** - **Formality level** - formal "Sie" vs. informal "du" in German, "vous" vs. "tu" in French - **Tone** - professional, conversational, playful, technical - **Audience** - developers, enterprise buyers, consumers, internal teams - **Conventions** - how to handle numbers, dates, currency, or product-specific terminology ### Example For a German locale targeting a developer audience: ``` Use informal "du" address. Keep a direct, technical tone - similar to how Stripe or Vercel write their German documentation. Prefer short sentences. Use active voice. When a German equivalent exists for a technical term, use it (e.g., "Bereitstellung" for deployment), but keep widely-adopted English terms as-is (e.g., API, CLI, Token). ``` ## Using brand voices with the API Brand voices are applied automatically when you call the [localize endpoint](/docs/api). The engine matches the `targetLocale` in the request to the configured brand voice and includes it in the LLM prompt. No additional parameters are needed - if a brand voice exists for the target locale, it is used. ```json { "sourceLocale": "en", "targetLocale": "de", "data": { "greeting": "Hey there! Ready to ship?", "cta": "Get started" } } ``` With the German brand voice above, the engine produces informal, technically-oriented translations rather than generic formal output. ## Managing brand voices via MCP If you use the [Lingo.dev MCP server](/docs/mcp), your AI coding assistant can create and update brand voices directly from the conversation: ``` "Set the German brand voice to use informal du, technical tone, short sentences, active voice." ``` The MCP server writes the brand voice to your localization engine without leaving the development environment. ## Next Steps {% card-grid %} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale" /%} {% link-card title="Instructions" href="/docs/platform/instructions" icon="file-code" description="Add linguistic rules for specific locale pairs" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="lightning" description="Configure per-locale model selection and fallbacks" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [AI Reviewers](https://lingo.dev/en/docs/platform/ai-reviewers): Configure built-in and custom AI reviews that evaluate translation quality - glossary compliance, instruction adherence, and custom criteria - with pass/fail verdicts or percentage scores after each localization engine request. AI reviews are automated quality checks that evaluate translations produced by your localization engine. After each translation request, Lingo.dev runs independent LLM evaluations to verify the output - checking glossary compliance, instruction adherence, and any custom criteria you define. Reviews run asynchronously and never block the translation response. ## How it works When the localization engine completes a translation request, it queues the applicable reviews for asynchronous evaluation. Each review runs an independent LLM that receives the source text, translated output, context, and evaluation criteria. It returns a structured result - pass/fail or a percentage score - with reasoning for imperfect results. The engine's **Reviews** tab controls which reviews run for that engine. There are three categories: | Category | What it checks | Result type | Configuration | | --- | --- | --- | --- | | **Glossary items AI review** | Whether translations follow the engine's [glossary](/docs/platform/glossaries) rules | Pass / Fail | Built-in toggle per engine | | **Instructions AI review** | Whether translations follow each of the engine's [instructions](/docs/platform/instructions) | Pass / Fail per instruction | Built-in toggle per engine | | **Custom AI reviewers** | Your own evaluation criteria, defined at the organization level | Pass / Fail or 0–100% | Select per engine from org-level reviewers | ## Built-in AI reviews Every localization engine includes two built-in review types that verify translations against the engine's own configuration. Enable or disable them in the engine's **Reviews** tab. ### Glossary items AI review Checks whether the translation adhered to all applicable glossary rules. If the engine has custom translations (e.g., "Deploy" → "Bereitstellen") or non-translatable terms (e.g., "OAuth"), the review verifies the translation respected them. The review accounts for grammatical variations - a glossary rule for a term in one grammatical case applies to all forms of that term. If conflicting glossary rules exist, the translation is considered compliant as long as one of them was followed. The result is a single pass/fail verdict for the entire translation request, with reasoning when the result is a fail. ### Instructions AI review Evaluates each instruction independently. If the engine has three instructions, the review produces three separate pass/fail verdicts - each with its own reasoning when the result is a fail. An instruction can return N/A when its criteria don't apply to the content being translated. For example, an instruction about formal address returns N/A when the translation contains only a product name or a technical term where formality is irrelevant. N/A results are excluded from aggregate scores. Both built-in reviews only trigger when the engine has relevant configuration - if no glossary items match the locale pair, no glossary items AI review runs. ## Configuring reviews per engine Open the engine's **Reviews** tab to control which reviews run for that engine. The tab has two sections: **Built-in toggles** at the top control the glossary items AI review and instructions AI review. These are independent - you can enable one without the other, depending on what the engine has configured. **Custom AI reviewers** below the toggles list all AI reviewers defined at the organization level. Toggle each one on or off for that specific engine. This lets you maintain a shared library of quality checks and apply them selectively. A single engine can have both built-in reviews and multiple custom AI reviewers running simultaneously. All reviews run asynchronously after each translation request, and results appear in the translation log and in [Reports](/docs/platform/reports). ## AI reviewer types ### Boolean AI reviewers Return a binary verdict: **pass** or **fail**. Use these for rules that are either met or not. **Examples:** - "Does the translation preserve all HTML tags and attributes?" - "Are pluralization rules applied correctly for the target language?" - "Does the translation use formal address (Sie) in German?" Results are aggregated as pass rates - 75% means 3 out of 4 evaluated translations passed. ### Percentage AI reviewers Return a score from **0 to 100**. Use these for quality dimensions that exist on a spectrum. **Examples:** - "Rate the naturalness of the translation for a native speaker (0–100)" - "Score how well the translation preserves the original tone and intent (0–100)" - "Evaluate grammatical correctness on a scale of 0–100" Results are aggregated as averages across the evaluation period. ## AI reviewer configuration | Field | Description | | --- | --- | | **Name** | A label identifying the AI reviewer (e.g., "Pluralization check") | | **Instruction** | The evaluation criteria, written in natural language | | **Type** | `boolean` (pass/fail) or `percentage` (0–100) | | **Source locale** | The source locale to match, or `*` for any | | **Target locale** | The target locale to match, or `*` for any | | **Provider / Model** | The LLM used for evaluation (independent of the translation model) | | **Sampling** | Percentage of requests to evaluate (0–100%) | | **Allow N/A** | Whether the AI reviewer can return "not applicable" for irrelevant pairs | | **Enabled** | Toggle review on or off without deleting the configuration | ## Writing AI reviewer instructions The instruction field is the core of an AI reviewer. It tells the evaluation LLM exactly what to check. Write it as a specific, testable criterion. ### Good instructions **Boolean:** ``` Check whether all HTML tags in the source text are preserved exactly in the translation. Tags must not be added, removed, modified, or reordered. Pass if all tags are preserved, fail if any tag is missing or altered. ``` **Percentage:** ``` Rate the fluency of the translation on a scale of 0-100. 100 means a native speaker would find it completely natural. 0 means it reads like machine output. Deduct points for awkward phrasing, unnatural word order, or overly literal constructions. ``` ### What makes a good instruction - **Specific criteria** - define exactly what pass/fail means, or what 0 and 100 represent - **Observable outcomes** - the LLM should be able to evaluate by reading the text, not guessing intent - **One concern per AI reviewer** - split multi-dimensional quality checks into separate AI reviewers ## Locale matching AI reviewers match translation requests by source and target locale. Wildcard `*` matches any locale. | Source locale | Target locale | Matches | | --- | --- | --- | | `en` | `de` | Only English → German translations | | `en` | `*` | Any translation from English | | `*` | `ja` | Any translation into Japanese | | `*` | `*` | All translations | A single translation request can trigger multiple AI reviewers if several match its locale pair. ## Sampling Not every translation needs to be reviewed. The sampling rate controls what percentage of matching requests get evaluated. | Sampling | Behavior | | --- | --- | | **100%** | Every matching request is reviewed (thorough but higher cost) | | **50%** | Roughly half of matching requests are reviewed | | **10%** | One in ten - useful for high-volume engines where trends matter more than individual scores | | **0%** | AI reviewer is effectively paused without disabling it | Sampling is applied at request time using a random check. Over a sufficient volume of requests, the actual evaluation rate converges to the configured percentage. ## N/A support When `allowsNA` is enabled, the review LLM can return "not applicable" instead of a score. This is useful for AI reviewers whose criteria don't apply to every locale pair. **Example:** An AI reviewer checking formal address conventions returns N/A for English → English translations (English has no formal/informal distinction), but returns a score for English → German. N/A results are excluded from averages and pass rates in reporting - they don't pull scores down or inflate them. ## Reasoning AI reviewers provide reasoning for imperfect results to help you understand what went wrong: - **Perfect score** (pass or 100%) - reasoning is null (nothing to explain) - **N/A** - reasoning is null - **Imperfect score** - a brief one-sentence explanation This keeps the review results actionable: when a translation fails a check, the reasoning tells you why without manual investigation. ## Review model Each AI reviewer has its own LLM provider and model configuration, independent of the translation model. This separation is intentional - the model that produces the translation should not be the same model that evaluates it. {% callout type="info" title="Model independence" %} Using a different model for review than for translation provides an independent assessment. If GPT-4o produces the translation, evaluating with Claude Sonnet gives you a second opinion rather than self-assessment. {% /callout %} ## AI reviewer reports Review results are visualized in the dashboard under the AI reviewer reports section, showing: - **Pass rates over time** - for boolean AI reviewers, plotted as daily percentages - **Average scores over time** - for percentage AI reviewers, plotted as daily averages - **Per-locale-pair breakdown** - see how each source → target pair performs independently - **Aggregate view** - combine all locale pairs into a single trend line AI reviewer reports complement the volume-focused [Reports](/docs/platform/reports) - together they give you a complete picture of both throughput and quality. ## Managing AI reviewers via MCP If you use the [Lingo.dev MCP server](/docs/mcp), your AI coding assistant can create and configure AI reviewers directly: ``` "Create a boolean AI reviewer for all locale pairs that checks whether HTML tags are preserved in translations." ``` ``` "Add a percentage AI reviewer for English to German that rates translation fluency on a 0-100 scale, sampling 50% of requests." ``` ## Next Steps {% card-grid %} {% link-card title="Reports" href="/docs/platform/reports" icon="gear" description="Monitor translation volume, token usage, and locale coverage" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="lightning" description="Configure the translation models that AI reviewers evaluate" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Set up terms that glossary compliance AI reviewers can check against" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [Localization Engines](https://lingo.dev/en/docs/platform/engines): Build and configure a stateful translation API on Lingo.dev that combines LLM models, brand voice, glossaries, instructions, and AI reviewers. A localization engine is a stateful translation API you build and configure on Lingo.dev. Instead of sending strings to a generic LLM and hoping the output matches your expectations, you build an API that produces the translations you actually expect - consistently, across every locale, on every request. ## What a localization engine does Each engine combines five configurable layers. When a translation request arrives, the engine applies all of them automatically - no prompt engineering per request, no manual intervention. | Layer | What it controls | Docs | | --- | --- | --- | | **[LLM Models](/docs/platform/llm-models)** | Which model handles each locale pair, with ranked fallback chains | [LLM Models →](/docs/platform/llm-models) | | **[Brand Voice](/docs/platform/brand-voices)** | How your product speaks in each language - tone, formality, style | [Brand Voices →](/docs/platform/brand-voices) | | **[Instructions](/docs/platform/instructions)** | Discrete linguistic rules for specific locales - testable and debuggable individually | [Instructions →](/docs/platform/instructions) | | **[Glossary](/docs/platform/glossaries)** | Exact term mappings per locale, with semantic matching - highest precedence in the engine | [Glossaries →](/docs/platform/glossaries) | | **[AI Reviewers](/docs/platform/ai-reviewers)** | Automated evaluation using an independent LLM, after each translation | [AI Reviewers →](/docs/platform/ai-reviewers) | ## How the layers interact The engine applies layers in a defined order, with a clear precedence hierarchy: 1. **Glossary** - highest precedence. If a glossary rule matches, it overrides model judgment. 2. **Instructions** - medium precedence. Locale-specific linguistic rules guide the model. 3. **Brand voice** - sets overall context. Tone, formality, and style for the locale. The **model config** determines which LLM processes the request, with automatic fallback if the primary model fails. **AI reviewers** run asynchronously after the translation completes - they never block the response. {% callout type="info" title="Complementary, not competing" %} Design glossary, instructions, and brand voice to complement each other. Glossary handles exact terms, instructions handle locale-specific rules, and brand voice sets the overall voice. If a glossary item conflicts with an instruction, the glossary wins. {% /callout %} Jobs submitted via the [Async Localization API](/docs/api/localization) can also run through an optional [pipeline](/docs/api/pipeline) - AI pre-edit of the source, human review, AI post-edit, and a back-translation drift check. ## Defaults When you create a new engine, it comes pre-configured with model defaults - primary models and fallbacks selected from three years of weekly localization research, optimized for quality across common and low-resource languages alike. Most teams won't need to change them. These defaults are designed to work well out of the box. You can edit any model config, swap providers, add fallbacks, or override specific locale pairs - but the defaults already reflect what we've found works best across hundreds of language pairs. Brand voice, instructions, and glossary start empty - you add them as you learn what your product needs in each locale. ## Using engines Engines are accessible through every Lingo.dev integration: | Integration | How it connects | | --- | --- | | **[CLI](/docs/platform/connect-your-engine)** | Set `engineId` in `i18n.json` - every `lingo.dev run` routes through your engine | | **[API](/docs/api)** | Call the localize endpoint with your API key - the engine applies all layers automatically | | **[CI/CD](/docs/integrations)** | Same CLI config - translations run through your engine on every pull request | | **[MCP](/docs/mcp)** | AI coding assistants can configure and use engines directly from the conversation | If you omit `engineId`, the default engine in your organization is used. ## Observability Every translation request is logged: model used, tokens consumed, whether a fallback handled it, which glossary items and instructions were applied. Monitor engine performance in [Reports](/docs/platform/reports) and translation quality in [AI Reviewers](/docs/platform/ai-reviewers). Test engine configurations before they go live in the [Playground](/docs/platform/playground) - compare your engine against a raw model, or compare two engines side by side. ## Next Steps {% card-grid %} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" icon="plug" description="Wire your CLI and codebase to a localization engine" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="lightning" description="Configure per-locale model selection and fallbacks" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Define how your product speaks in each language" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale" /%} {% link-card title="Async Pipeline" href="/docs/api/pipeline" icon="gear" description="Wrap async jobs with pre-edit, human review, post-edit, and back-translation" /%} {% /card-grid %} - [API Keys](https://lingo.dev/en/docs/platform/api-keys): Create and manage Personal and Service API keys that authenticate requests to the Lingo.dev localization API and MCP server. API keys authenticate requests to the localization API and the [MCP server](/docs/mcp). Lingo.dev supports two flavours — pick the one that matches who or what is calling the API. ## Two kinds of keys | | Personal | Service | | --- | --- | --- | | Owner | The user who created it | None — meant for automation | | Authorization | Inherits the creator's [RBAC role](/docs/platform/rbac) + engine grants | Carries its own role and/or per-engine scope | | If the owner loses access | Key loses access too | Unaffected; controlled by the key's own role / scope | | Plan | Any plan | Enterprise (requires the RBAC entitlement) | | Typical use | Local development, MCP, ad-hoc scripts | CI/CD pipelines, production integrations | Personal keys are the default. Service keys are an organization-level artifact decoupled from any single human, which is what you want for credentials that should outlive employees and survive role changes. ## Creating a key Open the API Keys page in the dashboard. Personal and Service live on separate tabs — the Service tab only appears when your organization has the Enterprise plan. {% tabs %} {% tab label="Personal" %} Click **Create API key**, give it a name (e.g., "Local MCP", "Max's staging key"), and copy the key from the success dialog. The key inherits your current RBAC role and per-engine grants. {% /tab %} {% tab label="Service" %} Switch to the **Service** tab and click **Create service API key**. Choose a role from the picker — only roles whose permission set is limited to `engine:access` are accepted (anti-escalation). You can also pick **No role** and instead select specific engines the key can reach. If the chosen role already grants `engine:access` org-wide, the engine multi-select is replaced with a banner — per-engine entries would be redundant in that case. {% /tab %} {% /tabs %} {% callout type="warning" title="Key visibility" %} The full API key is shown only once at creation. Copy and store it securely — it cannot be retrieved after you close the dialog. {% /callout %} ## Service keys and RBAC Service keys mirror the user model: a user can have an org-level role (umbrella permissions via [Roles & Permissions](/docs/platform/rbac)) AND/OR per-engine grants from being added to specific engines. A service key works the same way: - **Role only** — the role's permissions apply org-wide. If it includes `engine:access`, the key reaches every engine in the organization. - **No role + engine scope** — the key is restricted to the engines you check during creation. Update the list later from the **Engines x/y** button next to the key. - **Role + engine scope** — both authorities are additive. The role's umbrella wins if it grants `engine:access`; otherwise the per-engine list is consulted. - **Neither** — the key authenticates but cannot reach any engine. Useful as a placeholder while you wire up the scope, but pointless in production. Anti-escalation guards apply at create-time and on edit: - The chosen role must belong to the same organization. - The role's permission set must be a subset of `engine:access` — broader roles (for example one that includes `org:manage_team`) are rejected. - You can only add an engine to the key's scope if you yourself already have access to that engine. {% callout type="warning" title="If your Enterprise plan lapses" %} Service keys are part of the RBAC entitlement. If the entitlement is removed, every service key in the organization is **deactivated** — requests come back with a 403 and a message pointing at the plan, not at the engine scope. Personal keys are unaffected. Either restore the Enterprise plan or rotate to a Personal API key. {% /callout %} ## Using a key Pass the API key in the `X-API-Key` header on every request — the wire format is the same for both flavours: ```bash curl -X POST https://api.lingo.dev/process/localize \ -H "X-API-Key: your_api_key" \ -H "Content-Type: application/json" \ -d '{"engineId": "eng_abc123", "sourceLocale": "en", "targetLocale": "de", "data": {"greeting": "Hello"}}' ``` The same key works for both the [localization API](/docs/api) and the [MCP server](/docs/mcp). ## Security - Keys are stored as hashes — Lingo.dev cannot retrieve a key after creation. Rotate by deleting and recreating. - Personal keys follow their creator's permissions in real time. If the creator's role is demoted or an engine grant is revoked, the key loses the same access on the next call. - A personal key whose creator was removed from the organization keeps working only while RBAC is off (legacy behaviour). The moment RBAC is on, it is denied — rotate it before it becomes orphaned. - Service keys carry their own authority. Editing the role or scope from the Service tab takes effect immediately; deleting the key revokes it. - There is no limit on the number of keys per organization. ## Next Steps {% card-grid %} {% link-card title="Roles & Permissions" href="/docs/platform/rbac" icon="shield" description="How roles, per-engine grants, and Service keys fit together" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Use your API key to call the localization endpoints" /%} {% link-card title="MCP Server" href="/docs/mcp" icon="plug" description="Configure the MCP server with your API key" /%} {% link-card title="Team" href="/docs/platform/team" icon="globe" description="Manage who has access to your organization" /%} {% /card-grid %} - [Audit Logs](https://lingo.dev/en/docs/platform/audit-logs): Review an append-only, tamper-evident history of state-changing actions in your organization — who did what, when, from where. Audit logs record every state-changing action in your organization — role assignments, engine edits, API key issuance, billing changes — and surface them in a searchable, filterable timeline. Available on the Enterprise plan alongside [Roles & Permissions](/docs/platform/rbac). The log is append-only at the database level: the application's connection cannot update, delete, or truncate audit rows. What gets written, stays written. ## What's recorded Each event captures the actor, action, target, and the HTTP context the request arrived on. | Field | Notes | | --- | --- | | Actor | The user or [API key](/docs/platform/api-keys) that performed the action | | Action | A typed value like `engine.created` or `member.role_changed` | | Target | The entity that was touched (e.g. an engine, a role, a glossary item) | | Metadata | IDs of related entities — never names, emails, or content snippets | | Request context | `request_id`, IP address, user agent — captured when the call came in over HTTP | | Timestamp | UTC, plus a monotonic sequence number for gap detection | {% callout type="info" title="IDs only, by design" %} The metadata column stores IDs, not human-readable values. The UI joins to current names at read time, so renaming an engine or user doesn't rewrite history. It also keeps audit rows out of GDPR right-to-erasure conflicts — there's no content to redact. {% /callout %} ## Actions covered Events are grouped into categories that match the filter in the dashboard. | Category | Actions wired today | | --- | --- | | **Organization** | `org.created`, `org.settings_updated`, `org.ownership_transferred`, `org.deleted` | | **Members** | `member.invited`, `member.removed`, `member.role_changed` | | **Roles** | `role.created`, `role.updated`, `role.deleted` | | **Engines** | `engine.created`, `engine.updated`, `engine.deleted` | | **API keys** | `api_key.created`, `api_key.revoked` | | **Content** | `glossary_item.*`, `instruction.*`, `brand_voice.*` (singular operations only) | ## Accessing the log The **Audit logs** entry appears in the Organization sidebar only when the entitlement is on for your organization. Inside, you'll find: - **Filterable list** — narrow by actor, action category, target type, and date range. Future dates are blocked and the upper bound is always ≥ the lower bound. - **Infinite scroll** — newest events first; older events load as you scroll. - **Detail sheet** — click any row to see the full metadata, request ID, IP, and user agent. - **Refresh** — pulls the latest events without losing your filter selection. Filters and the open event are reflected in the URL, so any view is shareable. ## Who can view Two gates control access to the page: 1. **Entitlement** — the audit-logs feature must be enabled for the organization (Enterprise plan). 2. **Permission** — the viewer's [role](/docs/platform/rbac) must include `org:view_audit_logs`. The seeded **Full Access** role includes it by default; Owners always have it. Members without the permission see a "no access" page instead of the timeline. Members of an organization without the entitlement see a paywall. {% callout type="warning" title="If your Enterprise plan lapses" %} Audit logs are part of the same RBAC-tier entitlement. Past events stay in the database, but the page is hidden and the read endpoints return 403 until the plan is restored. {% /callout %} ## Tamper-evidence Audit logs aren't just a record — they're meaningfully hard to alter after the fact. - **Append-only at the database level.** The migration that installs the table runs `REVOKE UPDATE, DELETE, TRUNCATE` on the API role. Any future code path — bug, new endpoint, even SQL injection — is rejected by Postgres before it can change a row. - **Monotonic sequence.** Every row carries a strictly increasing `seq` column. An auditor scanning the sequence can detect deletions as gaps. This is sufficient for SOC 2-style controls. It does not defend against an attacker with direct production database credentials — stronger postures (separate-owner role, WORM mirror) are available on request. ## Retention Audit events live indefinitely by default. Custom retention windows — typically 1, 3, or 7 years — are available on Enterprise. Reach out if you need a specific retention policy for compliance or security questionnaires. ## What's not covered A few deliberate v1 boundaries to be aware of: - **Best-effort emit, not transactional.** Transactional emit is on the near-term roadmap. The audit event is written after the action it describes — not inside the same database transaction. In rare failure modes (an action that succeeds and then the audit insert fails) the action can happen without a corresponding event. - **Some action types aren't wired yet.** Engine membership changes, integration connect / update / disconnect, model-config edits, billing subscription changes, and bulk content operations (CSV upload, `createMany` / `updateMany` / `deleteMany`) don't yet produce events. The action vocabulary is reserved so they show up under the existing filter categories once wired. - **Reads are not audited.** Viewing the audit log itself, browsing engine request logs, or downloading a glossary does not produce events. Only state-changing actions do. - **No exports yet.** Audit data is surfaced through the dashboard. CSV export, SIEM webhook, and S3 mirror are on the roadmap — let us know if you need them. - **No engine-scoped view.** All audit events for the organization live on a single timeline. Filter by target type or target ID to scope down. - **No live tail.** The list refreshes on demand or when you click **Refresh** — there's no WebSocket stream. ## Next Steps {% card-grid %} {% link-card title="Roles & Permissions" href="/docs/platform/rbac" icon="shield" description="Grant org:view_audit_logs to the right people" /%} {% link-card title="Team" href="/docs/platform/team" icon="globe" description="Invite members and manage your organization roster" /%} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="lightning" description="Audit who issued and revoked keys" /%} {% link-card title="Reports" href="/docs/platform/reports" icon="gear" description="Monitor translation activity and engine quality" /%} {% /card-grid %} - [Roles & Permissions](https://lingo.dev/en/docs/platform/rbac): Define custom roles and assign granular permissions to control who can manage your Lingo.dev organization, engines, and billing. Role-based access control (RBAC) lets you define custom roles and assign granular permissions to your team members. Available on the Enterprise plan. When RBAC is on, every member's permissions come from their assigned role — a member without a role can sign in but cannot manage anything. Without RBAC, all members have full access by default (see [Team](/docs/platform/team)). ## Permissions Five permissions make up every role: | Permission | Scope | Grants | | --- | --- | --- | | `org:manage_team` | Organization | Invite or remove members, create or edit roles, assign roles | | `org:manage_settings` | Organization | Edit organization name, timezone, integrations | | `org:manage_billing` | Organization | View and change billing, plan, and invoices | | `org:delete` | Organization | Delete the entire organization | | `engine:access` | Engine | View, edit, delete, and manage members on [localization engines](/docs/platform/engines) | `org:manage_billing` and `org:delete` are Owner-exclusive — only the current Owner can grant either of them by assigning a role that includes them. ## Roles Three kinds of roles exist: - **Owner** — system role with every permission. The user who creates the organization is the first Owner; an existing Owner can appoint additional Owners. The role itself cannot be edited or deleted, and the organization must always have at least one Owner. - **Full Access** — seeded automatically when an organization is created with `org:manage_team`, `org:manage_settings`, and `engine:access`. Editable like any custom role; a safe default for trusted teammates. - **Custom roles** — any role you create. Pick a name and any subset of the permission catalog. {% callout type="info" title="Roles are bundles" %} A user holds exactly one role at the organization level. To give partial access, create a role with that exact permission subset and assign it. You cannot grant individual permissions outside of a role. {% /callout %} ## Assigning a role Open the Team page, pick a member, and select a role. Removing the role leaves them as an organization member with no permissions — they remain signed in but can't access any engines, settings, or billing. Only an existing Owner can promote another member to Owner or demote one — those changes need the Owner permission set themselves. ## Engine access By default, any member whose role includes `engine:access` sees every [localization engine](/docs/platform/engines) in the organization. To narrow access, add specific users to specific engines from that engine's Members tab. Per-engine grants are **additive** — an organization-level `engine:access` always wins. To restrict a user to a single engine, give them a role _without_ `engine:access`, then add them to that engine individually. [Service API keys](/docs/platform/api-keys) follow the same model: a key may carry a role (umbrella permissions), a per-engine scope, both, or neither. Anti-escalation guards apply on create and on edit — service keys are limited to roles whose permission set is `engine:access` only. ## Service API keys Service keys are an RBAC-only construct. Personal keys exist on every plan and inherit their creator's role; Service keys exist only when the RBAC entitlement is active and carry their own authority. - Creating a service key requires `org:manage_team`, the same scope that governs role assignment. - A service key with no role is valid — its access comes entirely from the engines listed on the key. - If the Enterprise plan ends, every service key in the organization is deactivated with a typed 403 that names the entitlement, so the operator knows to restore the plan or rotate to a Personal key rather than chase a phantom engine-scope bug. Manage roles and engine scope for service keys from the [API Keys](/docs/platform/api-keys) page. ## Transferring ownership If you're the only Owner and want to step down, use "Transfer ownership" from the Team page. Pick the new Owner and the role you want to hold afterwards (or no role at all) — promotion and self-demotion commit in a single transaction, so the organization is never left without an Owner. This flow is specifically for stepping down. If you just want to share Owner duties, promote a second user to Owner from the regular role picker instead. ## Next Steps {% card-grid %} {% link-card title="Team" href="/docs/platform/team" icon="globe" description="Invite members and manage your organization roster" /%} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="lightning" description="Generate keys scoped to your organization" /%} {% link-card title="Engines" href="/docs/platform/engines" icon="gear" description="Configure per-locale localization engines" /%} {% /card-grid %} - [Reports](https://lingo.dev/en/docs/platform/reports): Monitor translation volume, token usage, locale coverage, glossary depth, codebase change rates, and AI-reviewer quality metrics across your localization engines. Reports give you visibility into how your localization engines are performing - translation volume, token usage, locale coverage, glossary depth, codebase change rates, and AI-reviewer quality metrics. All reports are scoped to your organization and update automatically as requests flow through the engine. ## Available reports | Report | What it measures | | --- | --- | | [Word Generations](#word-generations) | Words translated per day | | [Token Consumption](#token-consumption) | Input and output tokens used per day | | [Top Locales](#top-locales) | Which locales consume the most resources | | [Glossary Depth](#glossary-depth) | How many glossary terms exist per locale | | [Change Rate](#change-rate) | Localization file changes in GitHub by locale | | [Average Scores](#average-scores) | Daily average translation scores from AI reviewers | | [Terminology Coverage](#terminology-coverage) | How consistently glossary terms are applied in translations | | [Instruction Adherence](#instruction-adherence) | How consistently custom instructions are followed in translations | ## Word Generations Tracks the total word count processed by the localization engine, aggregated by day. Use this to understand translation volume trends and plan capacity. **Filters:** engine, period (month), source locale, target locale The chart displays one bar per day for the selected month. Days with no translation activity show zero. ## Token Consumption Monitors LLM token usage broken down into input tokens and output tokens, aggregated by day. Token consumption directly reflects cost - use this report to identify cost spikes and compare efficiency across engines or locale pairs. **Filters:** engine, period (month), source locale, target locale {% callout type="info" title="Input vs. output tokens" %} Input tokens include the system prompt, glossary, brand voice, instructions, and the source text. Output tokens are the translated result. A high input-to-output ratio may indicate that the engine's context (glossary, instructions) is large relative to the translated content. {% /callout %} ## Top Locales Ranks locales by resource consumption - helping you identify which languages drive the most translation volume and cost. You can view rankings by source locale or target locale, and measure by input tokens, output tokens, or word count. **Filters:** engine, period (month), locale type (source or target), metric (input tokens, output tokens, or word count) This report answers questions like: "Which target locale uses the most tokens?" or "Which source language generates the most words?" ## Glossary Depth Shows how many glossary items exist per locale across your engines. Unlike other reports, this is a current snapshot - not time-series data - reflecting the present state of your glossary configuration. **Filters:** engine, locale type (source or target) Use this to identify gaps: if your engine translates into 12 locales but only 3 have glossary entries, the uncovered locales rely entirely on the model's judgment for terminology. ## Change Rate Tracks the rate of localization file changes in your connected GitHub repositories, broken down by locale and day. This report requires an active GitHub integration - you'll be prompted to connect GitHub if it isn't set up. **Filters:** period (month), repository, locale The change rate report helps answer: "How actively is each locale being updated?" and "Which repositories generate the most localization changes?" {% callout type="info" title="Timezone support" %} Date grouping respects your organization's configured timezone. A commit at 23:30 UTC appears on the correct local date, not shifted to the next day. {% /callout %} ## Average Scores Plots daily average translation scores from your [AI reviewers](/docs/platform/ai-reviewers), as a percentage. Use this to track quality trends over time and spot regressions after engine, model, or glossary changes. **Filters:** engine, period (month), view (aggregated or breakdown) When viewing a single engine, each line represents one scorer attached to that engine. When viewing across all engines, choose **Aggregated** for a single line averaging every scorer across every engine, or **Breakdown** to compare scorers side by side. {% callout type="info" title="Requires AI reviewers" %} This report only has data once at least one [AI reviewer](/docs/platform/ai-reviewers) is configured and scoring translations. {% /callout %} ## Terminology Coverage Tracks how consistently glossary terms are applied correctly in translations each day. The line shows daily coverage percentage (correctly applied terms ÷ total relevant terms); the bars show the absolute number of terms applied. Hovering reveals the applied/total breakdown and the number of reviews behind each data point. **Filters:** engine, period (month) A high terms-applied count with a falling coverage rate signals that glossary terms are being missed or mistranslated more often as volume scales - a useful early warning that the glossary or engine instructions need attention. ## Instruction Adherence Tracks how consistently the engine's custom [instructions](/docs/platform/instructions) are followed in translations each day. The percentage is calculated only across reviews where instructions were actually relevant - tooltips show `followed / relevant` so you can see both the rate and the sample size. **Filters:** engine, period (month) Use this to verify that newly added instructions actually change behavior, and to catch regressions where the engine starts ignoring rules after a model swap or prompt change. ## Filtering and periods All time-based reports operate on monthly periods. The default is the current month. Filters are preserved in the URL, so filtered views are shareable and bookmarkable. Common filters across reports: | Filter | Available in | Description | | --- | --- | --- | | **Engine** | Word Gen, Token, Top Locales, Glossary Depth, Average Scores, Terminology Coverage, Instruction Adherence | Narrow to a specific engine or view all | | **Period** | Word Gen, Token, Top Locales, Change Rate, Average Scores, Terminology Coverage, Instruction Adherence | Select month (`YYYY-MM`) | | **Source locale** | Word Gen, Token | Filter by source language | | **Target locale** | Word Gen, Token | Filter by target language | | **Repository** | Change Rate | Filter by GitHub repo | | **View** | Average Scores | Aggregated single line, or per-scorer breakdown | ## Quality vs. volume Reports split into two complementary views: **volume and cost** (Word Generations, Token Consumption, Top Locales, Glossary Depth, Change Rate) and **quality** (Average Scores, Terminology Coverage, Instruction Adherence). Quality reports require at least one configured [AI reviewer](/docs/platform/ai-reviewers). ## Next Steps {% card-grid %} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Set up automated translation quality monitoring" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="gear" description="Configure per-locale model selection and fallbacks" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Improve glossary coverage across locales" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} - [Connect Your Engine](https://lingo.dev/en/docs/platform/connect-your-engine): Connect the Lingo.dev CLI to your localization engine so every translation run applies your brand voice, glossary, and model configuration automatically. The Lingo.dev CLI connects your codebase to a localization engine through a single configuration field. Every `lingo.dev run` call routes through your engine - applying your brand voice, glossary, instructions, and model configuration automatically. No code changes, no new dependencies. ## What changes | | Before | After | | --- | --- | --- | | **Translation pipeline** | Default Lingo.dev pipeline | Your localization engine | | **Brand voice** | None | Applied per target locale | | **Glossary** | None | Semantically matched per request | | **Instructions** | None | Included per target locale | | **Model selection** | Lingo.dev default | Your model config with fallbacks | | **Quality review** | None | Your configured AI reviewers run automatically | ## Setup {% steps %} {% step title="Add engineId to i18n.json (optional)" %} To target a specific engine, add the `engineId` field to your `i18n.json` configuration. Find your engine ID in the dashboard - it starts with `eng_` (e.g., `eng_SxjMwMsfOIsvV1wh`). ```json { "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "json": { "include": ["[locale]/messages.json"] } }, "engineId": "eng_SxjMwMsfOIsvV1wh" } ``` Everything else in your config stays the same - source locale, targets, buckets. If you omit `engineId`, the CLI uses the default engine in your organization. {% /step %} {% step title="Set your API key" %} The CLI authenticates with your Lingo.dev API key. Set it as an environment variable: ```bash export LINGO_API_KEY="your-api-key" ``` Or persist it in `~/.lingodotdevrc`: ```ini [auth.vnext] apiKey = your-api-key ``` Generate an API key from the [API Keys](/docs/platform/api-keys) page in the dashboard. {% /step %} {% step title="Run translations" %} ```bash lingo.dev run ``` The CLI sends translation requests to your engine and writes the results back to your locale files - applying your brand voice, glossary, instructions, and model configuration automatically. {% /step %} {% /steps %} ## Configuration reference ### i18n.json | Field | Description | | --- | --- | | `engineId` | Optional. Your engine ID (`eng_...`). If omitted, the default engine in your organization is used. | All other `i18n.json` fields (`version`, `locale`, `buckets`) work identically to the standard CLI configuration. ### Environment variables | Variable | Required | Default | Description | | --- | --- | --- | --- | | `LINGO_API_KEY` | Yes | - | Your Lingo.dev API key | | `LINGO_API_URL` | No | `https://api.lingo.dev` | Custom API base URL (for self-hosted or staging) | ## What to expect Every translation the CLI produces flows through your engine's full pipeline: - [Brand voice](/docs/platform/brand-voices) shapes tone and formality per locale - [Glossary items](/docs/platform/glossaries) enforce exact terminology via semantic matching - [Instructions](/docs/platform/instructions) apply locale-specific linguistic rules - [Model configs](/docs/platform/llm-models) select the right LLM with automatic fallbacks - [AI Reviewers](/docs/platform/ai-reviewers) evaluate quality automatically after each request Translations appear in [Reports](/docs/platform/reports) with trigger type `api`, alongside requests from the API and integrations. ## Next Steps {% card-grid %} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="lightning" description="Generate the API key for CLI authentication" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Configure how your engine translates per locale" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Set up terminology rules before your first run" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Use the same engine programmatically" /%} {% /card-grid %} - [Playground](https://lingo.dev/en/docs/platform/playground): Test and compare translations interactively by running your localization engines and raw LLM models side by side. The playground lets you test translations interactively - type text, pick locales, and see results from your localization engines in real time. It's a side-by-side comparison tool for evaluating how different engines and models translate the same input. ## Comparison modes The playground operates in two modes, switchable via the control bar: ### Engine vs Model Compares the output of a fully configured localization engine against a raw LLM model. The left panel runs your engine - with its brand voice, glossary, instructions, and model config applied. The right panel sends the same text to a bare model with no engine context. This is the default mode. Use it to see the value your engine configuration adds on top of the base model's translation ability. ### Engine vs Engine Compares the output of two different localization engines side by side. Both panels run full engine translations - each applying its own brand voice, glossary, instructions, and model configuration. Use this to compare engines configured for different strategies: different models, different glossary coverage, or different instruction sets. ## How to use 1. **Select locales** - pick the source and target language from the locale selectors 2. **Choose what to compare** - select an engine (or model) in each panel 3. **Enter text** - type or paste the source text you want to translate 4. **Translate** - click the Translate button on each panel independently Each panel translates independently, so you can run one side, adjust the text, and re-run without affecting the other. ## Engine translations When the playground translates through an engine, the request flows through the full localization pipeline - exactly as it would via the [API](/docs/api): - [Brand voice](/docs/platform/brand-voices) for the target locale is applied - [Glossary items](/docs/platform/glossaries) are retrieved via semantic search - [Instructions](/docs/platform/instructions) matching the target locale are included - The [model config](/docs/platform/llm-models) for the locale pair determines which LLM processes the request Playground translations are logged in request logs with trigger type `playground`, so they appear in [Reports](/docs/platform/reports) alongside API and integration requests. ## Raw model translations In Engine vs Model mode, the right panel sends text directly to a bare LLM - no brand voice, no glossary, no instructions. The model receives only the source text and locale pair with a basic translation prompt. This baseline comparison shows what the model produces on its own. The difference between the raw model output and the engine output is the measurable impact of your engine configuration. ## Next Steps {% card-grid %} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Configure the voice that shapes engine translations" /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="gear" description="Choose and compare models for each locale pair" /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Automate quality evaluation beyond manual comparison" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Run the same translations programmatically" /%} {% /card-grid %} ## Docs – React - [Formatting](https://lingo.dev/en/docs/react/i18n/formatting): Locale-aware number, currency, percent, date, time, relative, list, and file-size formatters — thin wrappers over native Intl APIs. Every `Lingo` instance carries a set of formatting methods keyed to the active locale. They're thin wrappers around the native `Intl.*` APIs — no extra bundle weight, no opinionated defaults beyond the locale string you already provided to `LingoProvider`. All methods are pure: pass the same input and locale, get the same output. They're safe to call inside render without memoization. ## Numbers ### `l.num(value, options?)` Format a number with locale-aware grouping and decimals. ```tsx l.num(1234567); // "1,234,567" (en) / "1.234.567" (de) / "1 234 567" (fr) l.num(3.14159, { maximumFractionDigits: 2 }); // "3.14" (en) / "3,14" (de) ``` `options` is forwarded to [`Intl.NumberFormat`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). ### `l.currency(value, code, options?)` ```tsx l.currency(29.99, "USD"); // "$29.99" (en) / "29,99 $US" (fr) l.currency(29.99, "EUR"); // "€29.99" (en) / "29,99 €" (de) ``` ### `l.percent(value, options?)` Pass a decimal (0.156, not 15.6). ```tsx l.percent(0.156); // "16%" (en) / "16 %" (fr) l.percent(0.156, { maximumFractionDigits: 1 }); // "15.6%" ``` ### `l.unit(value, unit, options?)` `unit` is an Intl-recognized unit name (`celsius`, `kilometer-per-hour`, `megabyte`, etc). ```tsx l.unit(32, "celsius"); // "32°C" (en) / "32 °C" (de) l.unit(120, "kilometer-per-hour"); // "120 km/h" ``` ### `l.compact(value, options?)` ```tsx l.compact(1234567); // "1.2M" (en) / "123万" (ja) / "1,2 Mio." (de) l.compact(950); // "950" ``` ## Dates and time ### `l.date(value, options?)` / `l.time` / `l.datetime` Accept `Date` or epoch milliseconds. ```tsx l.date(new Date()); // "3/16/2026" (en) / "16.03.2026" (de) l.time(new Date()); // "3:45 PM" (en) / "15:45" (de) l.datetime(new Date()); // "3/16/2026, 3:45 PM" (en) l.date(now, { dateStyle: "long" }); // "March 16, 2026" l.date(now, { weekday: "short", month: "short", day: "numeric" }); // "Mon, Mar 16" ``` ### `l.relative(value, unit, options?)` Signed offset — negative for past, positive for future. ```tsx l.relative(-3, "day"); // "3 days ago" (en) / "vor 3 Tagen" (de) l.relative(2, "hour"); // "in 2 hours" l.relative(0, "second", { numeric: "auto" }); // "now" ``` ## Lists ### `l.list(items, options?)` Locale-aware conjunction. Default style is `long` with type `conjunction` ("and"). ```tsx l.list(["apples", "oranges", "pears"]); // "apples, oranges, and pears" (en) // "apples, oranges y pears" (es) l.list(["red", "blue"], { type: "disjunction" }); // "red or blue" ``` ## File sizes ### `l.fileSize(bytes)` Convenience wrapper that picks an appropriate unit (B, KB, MB, GB, TB, PB) and formats the result with locale-aware decimals. ```tsx l.fileSize(1024); // "1 KB" l.fileSize(1073741824); // "1 GB" (en) / "1 Go" (fr) l.fileSize(1536); // "1.5 KB" ``` ## Display names ### `l.displayName(code, type)` Translate a language, region, script, or currency code into a localized name. ```tsx l.displayName("en", "language"); // "English" (en) / "Englisch" (de) / "Английский" (ru) l.displayName("US", "region"); // "United States" / "Vereinigte Staaten" l.displayName("USD", "currency"); // "US Dollar" / "US-Dollar" l.displayName("Cyrl", "script"); // "Cyrillic" / "Kyrillisch" ``` Returns `undefined` if the code isn't recognized for the requested type. ## Collation ### `l.sort(items, options?)` Returns a **new** sorted array — doesn't mutate the input. ```tsx l.sort(["ä", "z", "a"]); // de: ["a", "ä", "z"] sv: ["a", "z", "ä"] l.sort(["File 10", "File 2"], { numeric: true }); // ["File 2", "File 10"] ``` ## Segmentation ### `l.segment(text, granularity?)` Locale-aware splitting into graphemes, words, or sentences. Essential for CJK where spaces don't separate words. ```tsx l.segment("Hello world", "word"); // ["Hello", " ", "world"] l.segment("こんにちは世界", "word"); // ["こんにちは", "世界"] (ja) l.segment("café", "grapheme"); // ["c", "a", "f", "é"] ``` Granularity defaults to `"grapheme"`. ## Why thin wrappers? Every method delegates to a native `Intl` formatter — no parsing, no number libraries, no extra dependencies. The runtime keeps the bundle small and lets the platform handle the locale data, which means new locales added to the V8 / SpiderMonkey ICU tables work for free. The only added value over raw `Intl` is the locale string — you set it once via `` and every formatter reads it from there. If you'd prefer raw `Intl` (for code outside React), `createLingo(locale)` gives you the same object without the provider. ## Where to next - [useLingo](/labs/docs/react/i18n/use-lingo) — translating strings via `l.text` and `l.rich`. - [Plurals and select](/labs/docs/react/i18n/plurals-and-select) — picking forms by count or category. - [Plurals and select](https://lingo.dev/en/docs/react/i18n/plurals-and-select): Handle count-dependent ('1 item' vs 'N items') and category-dependent ('he/she/they') translations via the runtime's plural and select helpers. Plurals and select forms are the cases where one source string isn't enough — the translation depends on a number or a category. `@lingo.dev/react` exposes two friendly helpers (`l.plural` and `l.select`) that compile to ICU MessageFormat under the hood, so translators see the standard syntax and runtime stays the same. ## Plurals `l.plural(count, forms, { context })` picks the right form based on `count` and the locale's CLDR plural rules. ```tsx const l = useLingo(); l.plural(items.length, { one: "1 item", other: "{count} items", }, { context: "Cart summary" }); // → "1 item" (en, count=1) / "5 items" (en, count=5) // → "1 Eintrag" / "5 Einträge" (de, after translation) ``` ### Forms by locale The forms map accepts every CLDR plural category — `zero`, `one`, `two`, `few`, `many`, `other`. Locales pick what they need: - English uses `one` + `other` (1 vs everything else) - Russian uses `one` + `few` + `many` + `other` (1; 2-4; 5-20; 21, 31, ...) - Arabic uses all six - Japanese uses only `other` (no plural distinction) You only need to provide the forms the **source** locale uses — translators add the rest per target locale. {% callout type="info" %} `{count}` is interpolated automatically inside any plural form. You don't pass it via `values` — it comes from the first argument. {% /callout %} ### Combining with other placeholders For sentences with both a count and other variables, write the variables into the form strings; they'll be passed through to ICU. ```tsx l.plural(notifications.length, { one: "1 message from {sender}", other: "{count} messages from {sender}", }, { context: "Inbox header" }); ``` Then pass the values when you call — but wait, `l.plural`'s signature only has `{ context }`. For mixed cases, use `l.text` directly with ICU plural syntax: ```tsx l.text(`{count, plural, one {1 message from {sender}} other {# messages from {sender}}}`, { values: { count: notifications.length, sender: user.name }, context: "Inbox header", }); ``` The `#` token is replaced with the count value verbatim — useful when you want it without the curly-brace interpolation form. ## Select `l.select(value, forms, { context })` picks a form based on a string key (gender, role, content type — anything categorical). ```tsx l.select(user.gender, { male: "He uploaded a photo", female: "She uploaded a photo", other: "They uploaded a photo", }, { context: "Activity feed" }); ``` `other` is required as a fallback. The match is exact — there's no fuzzy or case-insensitive matching. ### Selectordinal For ordinal numbers (1st, 2nd, 3rd) use ICU `selectordinal` directly via `l.text`: ```tsx l.text(`You finished in {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place`, { values: { place: rank }, context: "Leaderboard", }); // → "You finished in 1st place" / "2nd" / "3rd" / "4th, 5th, ..." ``` ## What this compiles to Both `l.plural` and `l.select` build an ICU MessageFormat string and pass it to `l.text`. The compiled form is what gets extracted by `lingo extract` and stored in your locale files — translators edit ICU syntax directly, not the JS object literal. Example: `l.plural(n, { one: "1 item", other: "{count} items" }, { context: "Cart" })` extracts as: ```text {count, plural, one {1 item} other {{count} items}} ``` This means translators can adapt the categories per locale, including ones the source doesn't have. Russian becomes `{count, plural, one {...} few {...} many {...} other {...}}` without any code change. ## When not to use these - **A simple "1 or many" boolean.** Two `l.text` calls under an `if` are fine and easier for translators to spot. - **Programmatic enum that's not user-facing.** Plural / select are for *translation* of categorical messages, not for routing app logic. ## Where to next - [useLingo](/labs/docs/react/i18n/use-lingo) — base `l.text` and `l.rich` semantics. - [Formatting](/labs/docs/react/i18n/formatting) — number, currency, date, list formatting via native Intl. - [useLingo](https://lingo.dev/en/docs/react/i18n/use-lingo): The hook that returns the active Lingo object. Translate strings, render rich React subtrees, and read locale metadata. `useLingo()` is how components get to the runtime. It reads the nearest `` and returns the active `Lingo` object — call it once per component, hold the reference, and use whichever methods you need. ```tsx import { useLingo } from "@lingo.dev/react"; function Greeting() { const l = useLingo(); return

{l.text("Hello", { context: "Hero heading" })}

; } ``` It throws if called outside a provider, so test setups need to wrap the render — even with `messages: {}` if you don't care about translations in the test. ## `l.text(source, options)` — plain strings The everyday call. Returns a translated string, or the source if no translation exists for the current locale. ```tsx l.text("Save", { context: "Form button" }); // → "Speichern" (de) / "Save" (en, fallback) ``` ### Interpolation `{placeholder}` segments are substituted from `options.values`. ```tsx l.text("Welcome back, {name}!", { values: { name: user.firstName }, context: "Dashboard greeting", }); ``` Missing values render as `{name}` literally — handy for spotting bugs during dev, but make sure your tests assert on the rendered result so empty values don't slip through. ### When the source contains ICU syntax If the source string contains plural / select / number / date markers, the runtime upgrades automatically — no extra API. See [Plurals and select](/labs/docs/react/i18n/plurals-and-select) for the friendly wrapper. ## `l.rich(source, options)` — React subtrees When the translated text contains React components (links, bold, an ``), use `l.rich`. The translation string carries placeholder tags like `...`; you map each tag to a renderer. ```tsx l.rich("Click here for {topic}", { tags: { link: (children) => {children}, }, values: { topic: "details" }, context: "Footer help link", }); // → <>Click here for details ``` Self-closing tags work too: ```tsx l.rich("Loading ...", { tags: { spinner: () => }, context: "Inline loading state", }); ``` Tags **without** a renderer fall back to the raw text — so the missing-tag case is visible in dev, not silently swallowed. {% callout type="warning" %} Don't put markup directly inside translated strings. `l.rich` exists so translators see neutral placeholders (``) instead of ``, which they'd have to preserve verbatim and often break. Define the renderer in your code, not in the locale file. {% /callout %} ## Locale metadata on `l` Beyond translation, the object exposes: | Property | Type | Notes | | ------------- | --------------------- | ---------------------------------------------------------------- | | `l.locale` | `string` | Whatever you passed to `LingoProvider`. BCP-47. | | `l.direction` | `"ltr"` | `"rtl"`| Computed via `Intl.Locale.textInfo` + fallback RTL language list.| | `l.script` | `string` | `undefined` | Inferred when possible (`"Latn"`, `"Cyrl"`, `"Arab"`, …). | | `l.region` | `string` | `undefined` | Inferred from BCP-47 (`"US"`, `"DE"`, `"SA"`, …). | Useful for layout decisions: ```tsx const l = useLingo(); return
...
; ``` ## Formatting methods `l` also carries `num`, `currency`, `percent`, `date`, `time`, `datetime`, `relative`, `list`, `displayName`, `sort`, `segment`, `fileSize`, `compact`, `unit` — see [Formatting](/labs/docs/react/i18n/formatting) for the full breakdown. These are thin wrappers around native `Intl.*` formatters keyed to `l.locale`. ## Outside of React `useLingo` only works inside components. For utilities, route loaders, or server code, build the same object directly: ```ts import { createLingo } from "@lingo.dev/react"; const l = createLingo("es", messages); l.text("Hello", { context: "Email subject" }); ``` It's the same `Lingo` shape, no provider needed. `LingoProvider` itself uses `createLingo` under the hood. - [LingoProvider](https://lingo.dev/en/docs/react/i18n/provider): The React context provider. Pass a locale and a messages bag; descendants read both through useLingo(). `LingoProvider` is the React context that holds the active locale and the messages map. Wrap it once at the root of your app — everything inside can call `useLingo()` to translate strings or read locale metadata. ## Basic usage ```tsx import { LingoProvider } from "@lingo.dev/react"; import messages from "./locales/es.json"; ``` ## Props | Prop | Type | Required | What it does | | ---------- | ----------- | -------- | ----------------------------------------------------------------------- | | `locale` | `string` | yes | BCP-47 tag, e.g. `"en"`, `"es"`, `"ar-SA"`. Drives all formatting + RTL detection. | | `messages` | `Messages` | no | Hash-keyed translations. Defaults to `{}` (everything falls back to source). | | `children` | `ReactNode` | yes | Your app. | `Messages` is just `Record` — the same shape the CLI writes to `locales/.json`. ## Nesting providers Providers can nest. The rules are different depending on whether the nested provider has the **same** locale as the parent or a **different** one. ### Same locale — messages merge ```tsx {/* Route-scoped messages override shared on collision; missing keys fall through to shared. */} ``` Use this to split bundles per route while keeping a common "shell" set of translations at the root. ### Different locales — standalone ```tsx
{/* This subtree is entirely Arabic; the parent's es messages are NOT visible. */} ``` Handy for rendering a single component in a fixed language (a quote, an embed, a preview pane) inside an otherwise different app. ## Switching locale at runtime Treat `locale` as React state — change it, and every `useLingo()` consumer below re-renders with the new locale and message bag. ```tsx function AppRoot() { const [locale, setLocale] = useState("en"); const [messages, setMessages] = useState({}); async function switchTo(next: string) { const next_messages = await import(`./locales/${next}.json`); setLocale(next); setMessages(next_messages.default); } return ( ); } ``` {% callout type="info" %} On Next.js, prefer `useLocaleSwitch()` from `@lingo.dev/react-next` — it handles router-aware locale changes plus persistence. {% /callout %} ## What you can read from the context `useLingo()` returns the active `Lingo` object. Beyond `text()` and `rich()` it carries: - `locale` — the BCP-47 string you passed in - `direction` — `"ltr"` or `"rtl"`, computed via `Intl.Locale.textInfo` with a fallback list of known RTL languages - `script` — e.g. `"Latn"`, `"Cyrl"`, `"Arab"` - `region` — e.g. `"US"`, `"DE"`, `"SA"` These are useful for conditional layout (mirroring icons in RTL) or analytics tagging — no extra parsing required. ## Common mistakes - **Forgetting ``.** `useLingo()` throws outside one. The error message tells you to add a provider; if you're seeing it in tests, wrap the render in a test-mode provider with empty `messages` to make assertions stable. - **Passing async-loaded messages directly.** `messages` must be a synchronous value. Resolve the promise in a parent (with Suspense or state), then pass the result down. - **Switching `locale` without updating `messages`.** The provider trusts both props together — change them in the same `useState` update or you'll briefly render the new locale with the old translations. - [Quickstart](https://lingo.dev/en/docs/react/i18n/quickstart): Install @lingo.dev/react, wrap your app, write your first translation, and see it render. This walks through translating a single React component end-to-end: install the runtime, wrap the app, write a translation, extract it, and render the result in another locale. {% steps %} {% step title="Install the runtime" %} ```bash npm install @lingo.dev/react ``` If you're on Next.js, also install `@lingo.dev/react-next` — it adds router-aware helpers on top of the same runtime. {% /step %} {% step title="Wrap the app in LingoProvider" %} ```tsx import { LingoProvider } from "@lingo.dev/react"; import esMessages from "./locales/es.json"; export function App() { return ( ); } ``` `messages` is an object keyed by content hash — exactly what the CLI emits to `locales/.json`. On first run it's empty, and that's fine: untranslated strings fall back to their source text. {% /step %} {% step title="Write a translation in source code" %} ```tsx import { useLingo } from "@lingo.dev/react"; function Page() { const l = useLingo(); return

{l.text("Hello", { context: "Hero heading" })}

; } ``` `l.text(source, { context })` is the canonical call. `context` is required — it lets translators disambiguate strings that read the same in English but differ across languages ("Save" the verb vs. "Save" the noun). {% /step %} {% step title="Extract the string" %} ```bash lingo extract ``` This scans your source, computes a stable hash for `"Hello"` + the context, and writes it to your source locale file (`locales/en.jsonc` by default). Re-run after any change — extraction is idempotent. {% /step %} {% step title="Push for translation" %} ```bash lingo push --backfill-missing ``` The CLI uploads the source file, asks the engine to translate into your configured target locales, and downloads the result back into `locales/.json`. From now on, every push only sends what changed. {% /step %} {% step title="Render the translated text" %} Import the JSON file for the locale your app is rendering in (or pick it dynamically based on the user) and pass it to `LingoProvider`. The hook call from step 3 stays the same — `l.text("Hello", ...)` now returns the translated value because the hash matches what was downloaded. {% callout type="success" %} **That's the whole loop:** write source-language code, extract, push, render. There's no separate i18n key namespace to maintain — the source string *is* the key (via hash). {% /callout %} {% /step %} {% /steps %} ## Where to go next - [LingoProvider](/labs/docs/react/i18n/provider) — what `messages` should look like, when to nest providers, locale switching. - [useLingo](/labs/docs/react/i18n/use-lingo) — the full hook API, including `l.rich()` for React subtrees inside translations. - [Plurals and select](/labs/docs/react/i18n/plurals-and-select) — handling "1 item" / "N items" properly. - [@lingo.dev/react](https://lingo.dev/en/docs/react/i18n): The runtime i18n library for React. Hash-keyed translations, ICU plurals, locale-aware formatting, and zero compile-time magic. `@lingo.dev/react` is a small runtime library for translating React apps. It loads translations from JSONC locale files, looks them up by stable content hash, and renders strings (or rich React trees) with ICU plural / select support and locale-aware number, date, list, and file-size formatting. The library has no build step of its own — you write `l.text("Hello")`, the CLI extracts it during `lingo extract`, and the translated string is fetched at runtime by the same hash. If a translation is missing, the source text is rendered as a fallback. {% callout type="info" %} This is the *runtime*. The companion tools — `@lingo.dev/cli` for extraction and pushing translations, and `@lingo.dev/react-next` for Next.js bindings — live in their own packages. {% /callout %} ## Install ```bash npm install @lingo.dev/react ``` ## At a glance ```tsx import { LingoProvider, useLingo } from "@lingo.dev/react"; import esMessages from "./locales/es.json"; export function App() { return ( ); } function Greeting({ name }: { name: string }) { const l = useLingo(); return

{l.text("Hello, {name}!", { values: { name }, context: "Hero greeting" })}

; } ``` That's the whole surface for the common case: wrap the app once, call `useLingo()` from any component, and the hook gives you back the `Lingo` object with `.text()`, `.rich()`, plurals, formatters, and metadata about the current locale. ## What's in this section {% link-card title="Quickstart" href="/labs/docs/react/i18n/quickstart" description="Install, write your first translation, run `lingo extract`, see it rendered." /%} {% link-card title="LingoProvider" href="/labs/docs/react/i18n/provider" description="The context provider. How merging works, when to nest, what messages must look like." /%} {% link-card title="useLingo" href="/labs/docs/react/i18n/use-lingo" description="The hook. `text()` for strings, `rich()` for React trees, when to use which." /%} {% link-card title="Plurals and select" href="/labs/docs/react/i18n/plurals-and-select" description="ICU plural and select forms — the friendly API that compiles to MessageFormat." /%} {% link-card title="Formatting" href="/labs/docs/react/i18n/formatting" description="Numbers, currency, percent, dates, relative time, lists, file sizes — all via native Intl." /%} - [Automatic Pluralization](https://lingo.dev/en/docs/react/compiler/automatic-pluralization): The Lingo.dev Compiler automatically detects plural forms in your JSX text and converts them to ICU MessageFormat, supporting all CLDR plural categories across languages. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler detects plural forms in your JSX text and automatically converts them to [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/). Instead of manually writing plural rules for each language, write natural text with numeric values and the compiler generates the correct plural forms using an LLM. ## How it works {% steps %} {% step title="Compiler detects numeric patterns" %} During AST analysis, the compiler identifies text nodes that contain interpolated numbers alongside count-dependent words. For example, `You have {count} items` contains a numeric variable next to a word that changes with quantity. {% /step %} {% step title="LLM classifies plural forms" %} A small, fast LLM (configurable via `pluralization.model`) analyzes the text and determines which words need plural inflection. It generates the appropriate [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules) for each target locale. {% /step %} {% step title="ICU MessageFormat is generated" %} The compiler produces an ICU MessageFormat string that handles all plural categories required by the target language. {% /step %} {% /steps %} ## Example Source JSX: ```tsx

You have {count} items in your cart

``` Generated output for English: ``` {count, plural, one {You have 1 item in your cart} other {You have # items in your cart}} ``` Generated output for Russian (which has four plural categories): ``` {count, plural, one {У вас # товар в корзине} few {У вас # товара в корзине} many {У вас # товаров в корзине} other {У вас # товаров в корзине}} ``` ## CLDR plural categories Different languages use different subsets of the six [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules). The compiler generates only the categories required by each target locale: | Category | Description | Example languages | | --- | --- | --- | | `zero` | Zero quantity | Arabic, Latvian | | `one` | Singular | English, French, German, Spanish | | `two` | Dual | Arabic, Hebrew, Slovenian | | `few` | Paucal / small quantity | Russian, Czech, Polish | | `many` | Large quantity | Russian, Arabic, Polish | | `other` | General / default (always required) | All languages | English uses `one` and `other`. Russian uses `one`, `few`, `many`, and `other`. Arabic uses all six categories. The compiler handles this automatically per locale. ## Configuration Pluralization is enabled by default. Configure it in the compiler options: ```ts { pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, } ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `pluralization.enabled` | `boolean` | `true` | Enable or disable automatic plural detection. | | `pluralization.model` | `string` | `"groq:llama-3.1-8b-instant"` | LLM model for plural form detection. A smaller model is sufficient since detection is simpler than translation. | To disable pluralization entirely: ```ts { pluralization: { enabled: false, }, } ``` {% callout type="info" %} Disabling pluralization means the compiler translates text containing numbers as plain strings. The translated output may not be grammatically correct for all quantities in languages with complex plural rules. {% /callout %} ## When pluralization applies The compiler detects plural patterns in these cases: - Text with interpolated numeric variables: `{count} items`, `{n} messages` - Text with numeric literals: `You have 5 items` (less common in dynamic UI) The compiler does **not** pluralize: - Text with no numeric reference: `Items in cart` (no number to branch on) - Text where the number is not directly related to a count-dependent word {% callout type="success" %} Write natural text in your JSX. The compiler and its LLM handle the plural detection and ICU formatting - you do not need to learn ICU MessageFormat syntax. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All pluralization options" icon="gear" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Configure the LLM used for translation" icon="plug" /%} {% link-card title="Manual Overrides" href="/docs/react/compiler/manual-overrides" description="Override specific translations when needed" icon="code" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="When to enable or disable pluralization" icon="book" /%} {% /card-grid %} - [Best Practices](https://lingo.dev/en/docs/react/compiler/best-practices): Recommended patterns for using the Lingo.dev Compiler in production - build mode strategy, version control, model selection, text placement, and testing with pseudotranslator. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} These practices are based on patterns that produce reliable, cost-effective results with the Lingo.dev Compiler. They cover the build pipeline, code organization, translation quality, and testing. ## Build pipeline ### Use the three-mode strategy {% steps %} {% step title="Dev - pseudotranslator" %} Enable `dev.usePseudotranslator: true` for instant feedback. No API calls, no cost, immediate results. Pseudotranslations help you spot untranslated strings and test layout. ```ts { buildMode: "translate", dev: { usePseudotranslator: true }, } ``` {% /step %} {% step title="CI - translate mode" %} Run with `buildMode: "translate"` and a real provider. Commit the updated `.lingo/metadata.json` after each CI run so translations are available for production. ```bash LINGO_BUILD_MODE=translate npm run build ``` {% /step %} {% step title="Production - cache-only mode" %} Deploy with `buildMode: "cache-only"`. No API keys needed in production. Builds are deterministic and fast. ```bash LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} ## Version control ### Commit .lingo/ to your repository The `.lingo/metadata.json` file is the source of truth for all cached translations. Production builds in `cache-only` mode depend on it. ```gitignore # .gitignore - do NOT ignore .lingo/ node_modules/ dist/ .env ``` {% callout type="error" %} If `.lingo/metadata.json` is not committed, production builds fail because `cache-only` mode has no translations to read. {% /callout %} ### Review translation diffs When CI commits updated translations, review the `.lingo/metadata.json` diff in pull requests. This lets you catch translation issues before they reach production - similar to reviewing code changes. ## Code organization ### Place translatable text directly in JSX The compiler scans JSX for translatable content. Text stored in JavaScript variables, constants, or external files is not detected: ```tsx // Good - compiler detects this text export function Header() { return

Welcome to our app

; } // Bad - compiler cannot detect text in a variable const title = "Welcome to our app"; export function Header() { return

{title}

; } ``` ### Use useDirective for large codebases In large projects, scanning every file increases build time. Enable `useDirective: true` and add `'use i18n'` only to files that contain user-facing text: ```ts { useDirective: true, } ``` ```tsx 'use i18n'; // Only this file is scanned for translations export function PublicPage() { return

Welcome

; } ``` ### Keep sourceRoot narrow Set `sourceRoot` to the smallest directory that contains your translatable components. A broad `sourceRoot` scans unnecessary files: | Project type | Recommended sourceRoot | | --- | --- | | Next.js App Router | `"./app"` | | Vite + React | `"src"` | | Monorepo (with useDirective) | `"."` | ## Translation quality ### Use manual overrides for brand terms Brand names, product names, and legal text should use [manual overrides](/docs/react/compiler/manual-overrides) rather than relying on AI translation: ```tsx

Localization Engine

``` ### Use locale-pair mapping for cost optimization Different models have different strengths and price points. Map expensive models to languages that need them and use cost-effective models elsewhere: ```ts { models: { "*:*": "groq:llama-3.3-70b-versatile", // Fast, cost-effective default "*:ja": "anthropic:claude-3-5-sonnet", // Higher quality for Japanese "*:zh-Hans": "anthropic:claude-3-5-sonnet", // Higher quality for Chinese }, } ``` ### Use the Lingo.dev engine for glossary and brand voice When you need consistent terminology across your app, configure a [localization engine](/docs/platform/engines) on Lingo.dev with a [glossary](/docs/platform/glossaries) and [brand voice](/docs/platform/brand-voices). These apply automatically to every translation request. ## Pluralization ### Disable pluralization if not needed If your app does not display numeric counts alongside text, disable pluralization to reduce build complexity: ```ts { pluralization: { enabled: false }, } ``` ### Write count-dependent text naturally When pluralization is enabled, write text with numeric variables naturally. The compiler handles the ICU MessageFormat conversion: ```tsx // Good - the compiler detects and pluralizes this

You have {count} items in your cart

// Also good - works with any numeric expression

{unreadCount} unread messages

``` ## Testing ### Test with pseudotranslator first Before generating real translations, run with the pseudotranslator to verify complete coverage: 1. Enable `dev.usePseudotranslator: true` 2. Navigate through every page and component 3. Any text without `[!!! ... !!!]` markers is not being translated 4. Fix text placement issues (move text into JSX, adjust `sourceRoot`, add `'use i18n'` directives) {% callout type="success" %} Catching untranslated strings with the pseudotranslator is faster and cheaper than discovering them after generating real translations. {% /callout %} ### Test with real translations before release Disable the pseudotranslator and generate real translations for at least one target locale before releasing: ```ts { dev: { usePseudotranslator: false }, } ``` Check for layout overflow, text truncation, and bidirectional text issues that pseudotranslations cannot reveal. ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="CI and production build configuration" icon="terminal" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Provider selection and locale-pair mapping" icon="plug" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="code" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Common issues and solutions" icon="book" /%} {% /card-grid %} - [Build Modes](https://lingo.dev/en/docs/react/compiler/build-modes): The Lingo.dev Compiler has two build modes - translate mode generates missing translations via LLM, cache-only mode uses only pre-generated translations from .lingo/metadata.json. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler operates in two build modes that control whether new translations are generated during the build. Understanding these modes is essential for setting up a reliable development, CI, and production pipeline. ## The two modes | Mode | Behavior | When to use | | --- | --- | --- | | `"translate"` | Generates missing translations by calling the configured LLM provider. Cached translations are reused. | Development and CI - when new or changed text needs translation. | | `"cache-only"` | Uses only translations from `.lingo/metadata.json`. Fails if any translation is missing. | Production builds - deterministic output with no external API calls. | ## How translate mode works In `translate` mode, the compiler checks each translatable string against `.lingo/metadata.json`. If a cached translation exists and the source text has not changed, the cached version is used. If the string is new or modified, the compiler calls the configured translation provider to generate a translation and updates the cache. ```ts { buildMode: "translate", } ``` This mode is the default. It works with both the pseudotranslator (for instant fake translations) and real LLM providers. ## How cache-only mode works In `cache-only` mode, the compiler reads translations exclusively from `.lingo/metadata.json`. No LLM calls are made. If any translatable string is missing from the cache, the build fails with an error listing the missing strings. {% callout type="warning" %} `.lingo/metadata.json` must be committed to version control. Production builds in `cache-only` mode depend on this file being present in the repository - not just generated locally. {% /callout %} ```ts { buildMode: "cache-only", } ``` This mode produces deterministic builds - the same source code and cache always produce the same output. It also eliminates external API dependencies during production builds. ## Recommended workflow {% steps %} {% step title="Development - pseudotranslator" %} Enable the pseudotranslator for instant feedback with no API calls: ```ts { buildMode: "translate", dev: { usePseudotranslator: true, }, } ``` Pseudotranslations appear as `[!!! Welcome !!!]`, making it easy to spot untranslated strings and test layout with varying text lengths. {% /step %} {% step title="CI - translate mode" %} Run with `buildMode: "translate"` and a real LLM provider. The CI job generates translations for any new or changed strings and commits the updated `.lingo/metadata.json` back to the repository. ```bash # CI environment LINGO_BUILD_MODE=translate npm run build ``` {% /step %} {% step title="Production - cache-only mode" %} Deploy with `buildMode: "cache-only"` to use only pre-generated translations. No API keys are needed in the production environment. ```bash # Production environment LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} ## Environment variable override The `LINGO_BUILD_MODE` environment variable overrides the `buildMode` config option. This lets you use the same config file across environments: ```bash # Override in any environment LINGO_BUILD_MODE=cache-only npm run build ``` The environment variable takes precedence over the config file value. ## CI examples {% tabs %} {% tab label="GitHub Actions" %} ```yaml # .github/workflows/translate.yml name: Generate Translations on: push: branches: [main] jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build env: LINGO_BUILD_MODE: translate LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: update translations" file_pattern: ".lingo/metadata.json" ``` {% /tab %} {% tab label="GitLab CI" %} ```yaml # .gitlab-ci.yml translate: stage: build image: node:20 script: - npm ci - npm run build variables: LINGO_BUILD_MODE: translate LINGODOTDEV_API_KEY: $LINGODOTDEV_API_KEY artifacts: paths: - .lingo/metadata.json only: - main ``` {% /tab %} {% /tabs %} {% callout type="warning" %} Always commit `.lingo/metadata.json` to version control. Production builds in `cache-only` mode depend on this file. If it is missing or incomplete, the build will fail. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Configure LLM providers for translate mode" icon="plug" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="terminal" /%} {% link-card title="Project Structure" href="/docs/react/compiler/project-structure" description="The .lingo/ directory and metadata" icon="file-code" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Common build mode issues" icon="book" /%} {% /card-grid %} - [Configuration Reference](https://lingo.dev/en/docs/react/compiler/configuration-reference): Complete reference for all Lingo.dev Compiler configuration options - core settings, dev tools, locale persistence, pluralization, environment variables, and per-locale model mapping. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler configuration object controls how your React application is translated at build time. This page documents every available option with types, defaults, and usage examples. ## Core options | Option | Type | Default | Description | | --- | --- | --- | --- | | `sourceRoot` | `string` | `"src"` | Directory containing translatable components. Relative to project root. | | `lingoDir` | `string` | `".lingo"` | Directory for translation metadata and cache files. | | `sourceLocale` | `string` | **required** | Language code of your source content (e.g., `"en"`). | | `targetLocales` | `string[]` | **required** | Array of target language codes (e.g., `["es", "de", "fr"]`). | | `useDirective` | `boolean` | `false` | When `true`, only files with `'use i18n'` directive are translated. When `false`, all files in `sourceRoot` are translated. | | `models` | `string \| object` | `"lingo.dev"` | Translation provider configuration. A string sets the default for all locale pairs. An object maps locale pairs to specific providers. | | `prompt` | `string` | `undefined` | Custom system prompt for the translation LLM. Supports `{SOURCE_LOCALE}` and `{TARGET_LOCALE}` placeholders. | | `buildMode` | `"translate" \| "cache-only"` | `"translate"` | Controls whether the compiler generates new translations or uses only cached translations. | ## Dev options Options under the `dev` key control development-time behavior: | Option | Type | Default | Description | | --- | --- | --- | --- | | `dev.usePseudotranslator` | `boolean` | `false` | Generate instant fake translations (e.g., `[!!! Welcome !!!]`) instead of calling an LLM. No API key needed. | | `dev.translationServerStartPort` | `number` | `60000` | Starting port for the local translation server. The compiler auto-finds an available port in the range 60000-60099. | | `dev.translationServerUrl` | `string` | `undefined` | Override the translation server URL. Useful for custom setups or remote translation servers. | ## Locale persistence Options under `localePersistence` control how the user's selected locale is stored and retrieved: | Option | Type | Default | Description | | --- | --- | --- | --- | | `localePersistence.type` | `string` | `"cookie"` | Persistence mechanism. Currently supports `"cookie"`. | | `localePersistence.config.name` | `string` | `"locale"` | Cookie name used to store the locale. | | `localePersistence.config.maxAge` | `number` | `31536000` | Cookie max-age in seconds (default is 1 year). | For custom persistence logic (localStorage, URL-based, headers), see [Custom Locale Resolvers](/docs/react/compiler/custom-locale-resolvers). ## Pluralization Options under `pluralization` control automatic plural form detection and generation: | Option | Type | Default | Description | | --- | --- | --- | --- | | `pluralization.enabled` | `boolean` | `true` | Enable or disable automatic pluralization detection. | | `pluralization.model` | `string` | `"groq:llama-3.1-8b-instant"` | LLM model used for detecting plural forms in source text. A smaller, faster model is recommended since detection is a simpler task than translation. | See [Automatic Pluralization](/docs/react/compiler/automatic-pluralization) for details on how plural detection works. ## Environment variables Environment variables override or supplement configuration: | Variable | When required | Description | | --- | --- | --- | | `LINGO_BUILD_MODE` | Optional | Overrides the `buildMode` config option. Set to `"translate"` or `"cache-only"`. | | `LINGODOTDEV_API_KEY` | When using `"lingo.dev"` models | API key for the Lingo.dev localization engine. Obtain via `npx lingo.dev@latest login`. | | `OPENAI_API_KEY` | When using `"openai:*"` models | OpenAI API key. | | `ANTHROPIC_API_KEY` | When using `"anthropic:*"` models | Anthropic API key. | | `GOOGLE_API_KEY` | When using `"google:*"` models | Google AI API key. | | `GROQ_API_KEY` | When using `"groq:*"` models | Groq API key. | | `MISTRAL_API_KEY` | When using `"mistral:*"` models | Mistral API key. | | `OPENROUTER_API_KEY` | When using `"openrouter:*"` models | OpenRouter API key. | ## Complete example {% tabs %} {% tab label="Next.js" %} ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", lingoDir: ".lingo", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], useDirective: false, models: { "*:*": "lingo.dev", "*:ja": "anthropic:claude-3-5-sonnet", }, prompt: "Translate UI text from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep it concise.", buildMode: "translate", dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, localePersistence: { type: "cookie", config: { name: "locale", maxAge: 31536000, }, }, pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, }); } ``` {% /tab %} {% tab label="Vite + React" %} ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", lingoDir: ".lingo", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], useDirective: false, models: { "*:*": "lingo.dev", "*:ja": "anthropic:claude-3-5-sonnet", }, prompt: "Translate UI text from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep it concise.", buildMode: "translate", dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, localePersistence: { type: "cookie", config: { name: "locale", maxAge: 31536000, }, }, pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, }), react(), ], }); ``` {% /tab %} {% /tabs %} ## Next Steps {% card-grid %} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="All supported LLM providers and locale-pair mapping" icon="plug" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="Implement custom locale detection" icon="gear" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Recommended patterns for production" icon="book" /%} {% /card-grid %} - [Custom Locale Resolvers](https://lingo.dev/en/docs/react/compiler/custom-locale-resolvers): Override default locale detection and persistence by creating custom resolver files in the .lingo/ directory - supports URL-based, cookie, localStorage, and header-based patterns. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Custom locale resolvers let you override how the Lingo.dev Compiler detects and persists the user's locale. By default, the compiler uses cookie-based persistence configured via the `localePersistence` option. For more control - URL-based routing, header detection, localStorage, or any custom logic - create resolver files in the `.lingo/` directory. ## Resolver files The compiler looks for two optional files: | File | Environment | Exports | | --- | --- | --- | | `.lingo/locale-resolver.server.ts` | Server-side (SSR, RSC) | `resolveLocale(request: Request): string` | | `.lingo/locale-resolver.client.ts` | Client-side (browser) | `resolveLocale(): string` and `persistLocale(locale: string): void` | If a resolver file exists, the compiler uses it instead of the default cookie-based behavior. If only one file exists, the other environment falls back to the default. ## Server-side resolver The server resolver receives the incoming `Request` object and returns a locale code string: ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const url = new URL(request.url); // Check URL path prefix: /es/about -> "es" const pathLocale = url.pathname.split("/")[1]; const supportedLocales = ["en", "es", "de", "fr", "ja"]; if (supportedLocales.includes(pathLocale)) { return pathLocale; } // Fall back to Accept-Language header const acceptLanguage = request.headers.get("Accept-Language"); if (acceptLanguage) { const preferred = acceptLanguage.split(",")[0].split("-")[0]; if (supportedLocales.includes(preferred)) { return preferred; } } return "en"; } ``` ## Client-side resolver The client resolver has two functions: one to read the current locale and one to persist a locale change: ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { // Check URL path prefix const pathLocale = window.location.pathname.split("/")[1]; const supportedLocales = ["en", "es", "de", "fr", "ja"]; if (supportedLocales.includes(pathLocale)) { return pathLocale; } // Fall back to localStorage const stored = localStorage.getItem("locale"); if (stored && supportedLocales.includes(stored)) { return stored; } return "en"; } export function persistLocale(locale: string): void { localStorage.setItem("locale", locale); // Navigate to the locale-prefixed URL const path = window.location.pathname.replace(/^\/[a-z]{2}/, ""); window.location.href = `/${locale}${path}`; } ``` ## Common resolver patterns {% tabs %} {% tab label="URL-based" %} Route by URL path prefix (`/es/about`, `/de/pricing`): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const url = new URL(request.url); const locale = url.pathname.split("/")[1]; const supported = ["en", "es", "de", "fr"]; return supported.includes(locale) ? locale : "en"; } ``` ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { const locale = window.location.pathname.split("/")[1]; const supported = ["en", "es", "de", "fr"]; return supported.includes(locale) ? locale : "en"; } export function persistLocale(locale: string): void { const path = window.location.pathname.replace(/^\/[a-z]{2}/, ""); window.location.href = `/${locale}${path}`; } ``` {% /tab %} {% tab label="Cookie-based" %} Use a custom cookie name or logic (the default behavior, reimplemented for customization): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const cookies = request.headers.get("Cookie") || ""; const match = cookies.match(/user_language=([a-z-]+)/i); return match ? match[1] : "en"; } ``` ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { const match = document.cookie.match(/user_language=([a-z-]+)/i); return match ? match[1] : "en"; } export function persistLocale(locale: string): void { document.cookie = `user_language=${locale};path=/;max-age=31536000`; window.location.reload(); } ``` {% /tab %} {% tab label="Header-based" %} Detect locale from a custom header (set by a reverse proxy or CDN): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const locale = request.headers.get("X-User-Locale"); const supported = ["en", "es", "de", "fr"]; return locale && supported.includes(locale) ? locale : "en"; } ``` {% /tab %} {% /tabs %} {% callout type="warning" %} The `resolveLocale` function must return a locale code that matches one of your configured `targetLocales` or `sourceLocale`. Returning an unsupported locale code causes the compiler to fall back to the source locale. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Build a language switcher component" icon="globe" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="localePersistence options" icon="gear" /%} {% link-card title="Project Structure" href="/docs/react/compiler/project-structure" description="The .lingo/ directory layout" icon="file-code" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Server-side locale resolution in Next.js" icon="code" /%} {% /card-grid %} - [Development Tools](https://lingo.dev/en/docs/react/compiler/development-tools): Development tools included with the Lingo.dev Compiler - pseudotranslator for instant fake translations, local translation server for on-demand generation, and the upcoming dev widget. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler includes development tools that make it fast to iterate on multilingual UI without calling external APIs. These tools help you verify that all text is translatable, test layout with varying text lengths, and debug translation issues during development. ## Pseudotranslator The pseudotranslator generates instant fake translations by wrapping source text in visual markers. No API key is needed, no network calls are made, and results appear immediately. Enable it in your compiler config: ```ts { dev: { usePseudotranslator: true, }, } ``` ### What it produces | Source text | Pseudotranslation | | --- | --- | | `Welcome` | `[!!! Welcome !!!]` | | `Sign in to your account` | `[!!! Sign in to your account !!!]` | | `Items: {count}` | `[!!! Items: {count} !!!]` | The markers (`[!!! ... !!!]`) make translated text visually distinct from untranslated text. If you see raw English in the UI while pseudotranslator is enabled, that text is not being processed by the compiler. ### Use cases {% steps %} {% step title="Identify untranslated strings" %} Run your app with pseudotranslator enabled. Any text that appears without the `[!!! ... !!!]` markers is not being detected by the compiler. This happens when text is stored in variables outside JSX, or when a component is outside the `sourceRoot` directory. {% /step %} {% step title="Test layout with longer text" %} Pseudotranslations are longer than the source text (due to the marker characters). This simulates languages like German or French that typically produce 20-30% longer text than English, revealing layout overflow issues early. {% /step %} {% step title="Verify interpolation" %} Placeholders like `{count}` and `{name}` should appear inside the pseudotranslation markers. If a placeholder appears outside the markers or is missing, the compiler may not be preserving it correctly. {% /step %} {% /steps %} {% callout type="info" %} The pseudotranslator respects the same translation pipeline as real providers - it processes the same AST analysis and code injection steps. The only difference is the translation generation step, where markers replace the LLM call. {% /callout %} ## Translation server During development, the compiler runs a local translation server that handles on-demand translation requests. The server starts automatically when you run `npm run dev`. ### How it works The translation server listens on a local port and handles translation requests from the dev build pipeline. When a new or changed string is detected, the compiler sends it to the server, which routes it to the configured translation provider (or pseudotranslator). ### Port configuration The server auto-finds an available port in a configurable range: ```ts { dev: { translationServerStartPort: 60000, }, } ``` | Option | Default | Description | | --- | --- | --- | | `translationServerStartPort` | `60000` | Starting port number. The server tries ports sequentially (60000, 60001, ..., 60099) until it finds one available. | | `translationServerUrl` | auto-detected | Override the server URL entirely. Useful for connecting to a remote translation server or custom proxy. | {% callout type="warning" %} If all ports in the range 60000-60099 are occupied, the server fails to start. See [Troubleshooting](/docs/react/compiler/troubleshooting) for how to resolve port conflicts. {% /callout %} ## Dev widget (coming soon) An in-browser translation editor that lets you view and edit translations in real time while navigating your app. The widget overlays your UI and shows translation details for each text element. Planned features: - Click any text element to see its source text, translations, and metadata - Edit translations directly in the browser - Changes save to `.lingo/metadata.json` immediately - Toggle between locales without reloading {% callout type="info" title="Status" %} The dev widget is under development and not yet available. Follow the [changelog](/changelog) for release updates. {% /callout %} ## Recommended dev configuration For the fastest development experience, combine pseudotranslator with the default translation server settings: ```ts { dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, } ``` When you are ready to preview real translations, disable the pseudotranslator and restart the dev server: ```ts { dev: { usePseudotranslator: false, }, } ``` The compiler then generates real translations for new or changed strings using your configured [translation provider](/docs/react/compiler/translation-providers). ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All dev options" icon="gear" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Port conflicts and other dev issues" icon="book" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Recommended dev workflow" icon="lightning" /%} {% /card-grid %} - [Lingo.dev Compiler](https://lingo.dev/en/docs/react/compiler): A free, open-source build-time translation system that makes React apps multilingual without modifying components - translations are extracted, generated, and embedded in per-locale bundles at build time. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Lingo.dev Compiler is a free, open-source build-time translation system for React applications. It detects translatable text in your JSX, generates AI-powered translations with full component context, and embeds them in per-locale bundles during the build process. Your source code stays unchanged - no manual translation key files to maintain, no separate dictionary loading at runtime. See it in action: [live demo on X](https://x.com/MaxPrilutskiy/status/1929946504216932746) ## Before and after ```tsx // Your code - unchanged export function Welcome() { return

Welcome to our app

; } // Renders "Bienvenido a nuestra aplicacion" in Spanish ``` No code changes needed. Translations are determined at compile time, creating optimized per-locale bundles. ## How it differs from traditional i18n libraries | | Traditional i18n libraries | Lingo.dev Compiler | | --- | --- | --- | | Translation management | Manual - you create and maintain key files | Automatic - the compiler extracts translatable strings from JSX | | Code changes required | Wrap every string in `t()` calls | None - write normal JSX | | How translations load | Separate dictionary files loaded at runtime | Embedded in per-locale bundles at build time | | Translation source | Manual or external TMS | AI-generated with full component context | | Dictionary fetching | Runtime fetch or import of translation files | No separate fetch - translations are part of the bundle | ## The build pipeline {% steps %} {% step title="AST analysis" %} The compiler parses your React code into an Abstract Syntax Tree using [Babel](https://babeljs.io/). It identifies translatable content: text nodes, string attributes (`alt`, `aria-label`, `placeholder`), and template expressions. {% /step %} {% step title="Content extraction" %} Each translatable string gets a stable hash-based identifier. The compiler preserves component context, rich text structure (nested ``, ``), and interpolation placeholders. Metadata is stored in `.lingo/metadata.json`. {% /step %} {% step title="Translation generation" %} In development, the pseudotranslator generates instant fake translations (no API calls). In CI, the configured LLM provider generates real translations with full component context - file location, surrounding elements, and interpolation semantics. Only new or changed strings are translated - the compiler uses content hashing to skip unchanged strings. {% /step %} {% step title="Code injection" %} Translation lookups are injected into your JSX. The compiler adds lightweight hash-based lookup calls against the embedded dictionary for each locale. Your source code is never modified. {% /step %} {% step title="Bundle optimization" %} Per-locale bundles are created. Only translations used by each component are included. Dead code elimination and tree-shaking keep bundles minimal. {% /step %} {% /steps %} ## Supported frameworks | Framework | Integration | | --- | --- | | [Next.js](/docs/react/compiler/nextjs) (App Router) | `withLingo()` config wrapper - supports RSC, Webpack, and Turbopack | | [Vite + React](/docs/react/compiler/vite-react) | `lingoCompilerPlugin` - Vite plugin with full HMR support | ## Key features - **Automatic by default** - all JSX text is translated unless you opt into `'use i18n'` directive mode - **No dictionary fetching** - translations embedded in per-locale bundles, no separate files to load - **[Build modes](/docs/react/compiler/build-modes)** - pseudotranslator in dev, real translations in CI, cache-only in production - **[Manual overrides](/docs/react/compiler/manual-overrides)** - `data-lingo-override` attribute for precise control - **[Custom locale resolvers](/docs/react/compiler/custom-locale-resolvers)** - implement your own locale detection and persistence - **[Automatic pluralization](/docs/react/compiler/automatic-pluralization)** - ICU MessageFormat support for plural forms - **[Development tools](/docs/react/compiler/development-tools)** - pseudotranslator and in-browser translation editor ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Add multilingual support in under 5 minutes" icon="rocket" /%} {% link-card title="Next.js" href="/docs/react/compiler/nextjs" description="Framework-specific integration guide" icon="code" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% /card-grid %} - [Locale Switching](https://lingo.dev/en/docs/react/compiler/locale-switching): Build a language switcher using the useLingoContext() hook - access the current locale, change it with setLocale, and configure persistence via cookies or custom resolvers. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler provides the `useLingoContext()` hook for reading and changing the active locale at runtime. Use it to build language switchers, locale-aware components, or any UI that responds to the user's language preference. ## useLingoContext() hook The hook returns an object with the current locale and a function to change it: ```tsx import { useLingoContext } from "@lingo.dev/compiler/react"; const { locale, setLocale } = useLingoContext(); ``` | Property | Type | Description | | --- | --- | --- | | `locale` | `string` | The active locale code (e.g., `"en"`, `"es"`). | | `setLocale` | `(locale: string) => void` | Sets the new locale. Triggers persistence and a page reload by default. | ## Language switcher example A dropdown language switcher component: ```tsx "use client"; // Required for Next.js App Router import { useLingoContext } from "@lingo.dev/compiler/react"; const localeLabels: Record = { en: "English", es: "Espanol", de: "Deutsch", fr: "Francais", ja: "日本語", }; export function LanguageSwitcher() { const { locale, setLocale } = useLingoContext(); return ( ); } ``` {% callout type="info" %} In Next.js, the language switcher must be a Client Component (`"use client"`) because it uses a React hook. {% /callout %} ## What happens when setLocale is called {% steps %} {% step title="Locale is persisted" %} By default, the new locale is saved to a cookie named `locale` with a max-age of 1 year. This ensures the preference survives page reloads and browser restarts. {% /step %} {% step title="Page reloads" %} The page reloads to re-render all components with the new locale. Server Components fetch translations for the new locale on the server, and Client Components receive the updated dictionary. {% /step %} {% step title="Subsequent requests use the new locale" %} On the next page load, the compiler reads the persisted locale and serves the corresponding translations. {% /step %} {% /steps %} ## Persistence options The default persistence mechanism is cookie-based, configured via `localePersistence`: ```ts { localePersistence: { type: "cookie", config: { name: "locale", // Cookie name maxAge: 31536000, // 1 year in seconds }, }, } ``` | Option | Default | Description | | --- | --- | --- | | `type` | `"cookie"` | Persistence mechanism. | | `config.name` | `"locale"` | Cookie name. | | `config.maxAge` | `31536000` | Cookie lifetime in seconds. | ## Custom persistence For URL-based locale routing, localStorage, or custom header-based detection, create custom locale resolvers. The `persistLocale` export in your client resolver controls what happens when `setLocale` is called: ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { return localStorage.getItem("locale") || "en"; } export function persistLocale(locale: string): void { localStorage.setItem("locale", locale); window.location.reload(); } ``` See [Custom Locale Resolvers](/docs/react/compiler/custom-locale-resolvers) for full examples of URL-based, cookie-based, and header-based patterns. ## Reading locale without switching If you need the current locale for display or conditional rendering without providing a switcher, use the same hook: ```tsx "use client"; import { useLingoContext } from "@lingo.dev/compiler/react"; export function LocaleBadge() { const { locale } = useLingoContext(); return {locale.toUpperCase()}; } ``` ## Next Steps {% card-grid %} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="URL-based, localStorage, and header-based persistence" icon="gear" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="localePersistence options" icon="gear" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Server and Client Component behavior" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Client-side locale switching" icon="code" /%} {% /card-grid %} - [Manual Overrides](https://lingo.dev/en/docs/react/compiler/manual-overrides): Use the data-lingo-override attribute to provide hand-crafted translations for specific elements - overrides take precedence over AI-generated translations. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The `data-lingo-override` attribute gives you precise control over specific translations. When you need an exact translation for a brand name, legal text, or marketing headline, add the attribute to any JSX element and the compiler uses your provided translations instead of generating them with AI. ## Basic usage Pass an object mapping locale codes to translations: ```tsx

Welcome

``` The compiler uses the override value for each specified locale. For locales not listed in the override object, the compiler generates translations normally. ## How overrides work {% steps %} {% step title="Compiler encounters a JSX element with data-lingo-override" %} During the AST analysis phase, the compiler detects the `data-lingo-override` attribute on the element. {% /step %} {% step title="Override values are extracted" %} The locale-to-translation mapping is read from the attribute value. {% /step %} {% step title="Overrides take precedence" %} For each locale present in the override object, the compiler uses the provided translation. AI translation is skipped for those locales. Locales not in the override are translated normally. {% /step %} {% /steps %} ## Use cases | Use case | Why override | Example | | --- | --- | --- | | Brand names | AI may localize names that should stay consistent across languages | `data-lingo-override={{ es: "Lingo.dev", de: "Lingo.dev" }}` | | Marketing copy | Specific phrasing crafted by a copywriter | `data-lingo-override={{ es: "Tu motor de localizacion" }}` | | Legal text | Regulatory requirements demand exact wording | `data-lingo-override={{ de: "Datenschutzerklarung" }}` | | Idioms and puns | Wordplay that requires human creativity | `data-lingo-override={{ fr: "C'est la vie" }}` | | UI with strict character limits | AI translations may exceed space constraints | `data-lingo-override={{ ja: "OK" }}` | ## Examples ### Paragraph text ```tsx

Create a localization engine on Lingo.dev

``` ### Attributes Overrides apply to the text content of the element. For translatable attributes like `placeholder`, `alt`, or `aria-label`, the compiler handles them separately through its standard attribute translation pipeline. ### Partial overrides You do not need to provide overrides for every target locale. Supply only the locales that need manual control: ```tsx

Getting Started

``` In this example, Japanese uses the override while all other target locales receive AI-generated translations. ## When to use overrides vs. other approaches | Approach | When to use | | --- | --- | | `data-lingo-override` | Specific elements where you know the exact translation. | | [Glossary](/docs/platform/glossaries) (Lingo.dev Engine) | Terms that should be translated consistently across the entire app. | | [Brand Voice](/docs/platform/brand-voices) (Lingo.dev Engine) | Tone and style preferences that apply to all translations. | | [Custom prompts](/docs/react/compiler/translation-providers) | General translation instructions for all content. | {% callout type="info" %} Overrides are the most granular option - they apply to a single element. For project-wide consistency, use a glossary or brand voice through the Lingo.dev localization engine instead. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Custom prompts and locale-pair mapping" icon="plug" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" description="Project-wide term consistency" icon="book" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="When and how to use overrides" icon="lightning" /%} {% /card-grid %} - [Migration Guide](https://lingo.dev/en/docs/react/compiler/migration-guide): Migrate from lingo.dev/compiler to @lingo.dev/compiler - updated package name, simplified imports, async Next.js config, Vite plugin, and new .lingo/ directory. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} This guide covers migrating from the previous `lingo.dev` compiler package to the current `@lingo.dev/compiler` package. The new package introduces a scoped npm name, simplified API, plugin-based Vite integration, and a new `.lingo/` directory for metadata. ## Summary of changes | Area | Before (`lingo.dev`) | After (`@lingo.dev/compiler`) | | --- | --- | --- | | Package name | `lingo.dev` | `@lingo.dev/compiler` | | Next.js integration | Direct config modification | `withLingo()` async wrapper | | Vite integration | Manual setup | `lingoCompilerPlugin` | | LingoProvider | Required `loadDictionary` prop | No props needed | | Metadata directory | `lingo/` | `.lingo/` | | Opt-in directive | `'use i18n'` required | Optional (default: translate all) | | Imports | `from "lingo.dev/react"` | `from "@lingo.dev/compiler/react"` | ## Step-by-step migration {% steps %} {% step title="Replace the package" %} Remove the old package and install the new one: ```bash npm uninstall lingo.dev npm install @lingo.dev/compiler ``` {% /step %} {% step title="Update imports" %} Replace all import paths: {% tabs %} {% tab label="React components" %} ```ts // Before import { LingoProvider, useLingoContext } from "lingo.dev/react"; // After import { LingoProvider, useLingoContext } from "@lingo.dev/compiler/react"; ``` {% /tab %} {% tab label="Next.js config" %} ```ts // Before import { withLingo } from "lingo.dev/next"; // After import { withLingo } from "@lingo.dev/compiler/next"; ``` {% /tab %} {% tab label="Vite config" %} ```ts // Before (manual setup) // No standard import - varied by project // After import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; ``` {% /tab %} {% /tabs %} {% /step %} {% step title="Update Next.js config (if applicable)" %} The Next.js config must now be an async function: ```ts // Before import { withLingo } from "lingo.dev/next"; const nextConfig = {}; export default withLingo(nextConfig, { /* options */ }); // After import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", }); } ``` {% callout type="warning" %} The async function wrapper is required. A synchronous export will cause the build to fail. See [Next.js Integration](/docs/react/compiler/nextjs) for details. {% /callout %} {% /step %} {% step title="Update Vite config (if applicable)" %} Replace any manual setup with the `lingoCompilerPlugin`: ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", }), react(), // Must come AFTER lingoCompilerPlugin ], }); ``` {% /step %} {% step title="Simplify LingoProvider" %} The `loadDictionary` prop is no longer needed. The compiler handles dictionary loading automatically: ```tsx // Before import { LingoProvider } from "lingo.dev/react"; // After import { LingoProvider } from "@lingo.dev/compiler/react"; ``` {% /step %} {% step title="Move metadata directory" %} Rename the metadata directory from `lingo/` to `.lingo/`: ```bash mv lingo/ .lingo/ ``` Update your `.gitignore` if it references the old directory name. The `.lingo/` directory should be committed to version control. {% /step %} {% step title="Update 'use i18n' directives (optional)" %} In the new package, `'use i18n'` is optional. By default, all files in `sourceRoot` are translated. If you want to keep opt-in behavior, set `useDirective: true` in your config: ```ts { useDirective: true, // Keep requiring 'use i18n' in each file } ``` If you remove `useDirective` (or set it to `false`), you can also remove the `'use i18n'` directives from your files - all files in `sourceRoot` will be translated automatically. {% /step %} {% step title="Rebuild and verify" %} Run the dev server and verify translations appear: ```bash npm run dev ``` Check that: - The pseudotranslator produces `[!!! ... !!!]` markers (if enabled) - All previously translated strings still work - The `.lingo/metadata.json` file is created or updated {% /step %} {% /steps %} ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All new configuration options" icon="gear" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Next.js-specific migration details" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Vite-specific migration details" icon="code" /%} {% /card-grid %} - [Next.js Integration](https://lingo.dev/en/docs/react/compiler/nextjs): Integrate the Lingo.dev Compiler with Next.js App Router using the withLingo() config wrapper - supports React Server Components, Webpack, and Turbopack. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler integrates with Next.js App Router through a `withLingo()` config wrapper that transforms your build pipeline to produce per-locale bundles. It supports React Server Components, Webpack, and Turbopack with no changes to your component code. ## Prerequisites {% callout type="info" title="Requirements" %} - Next.js 14+ with App Router - Node.js 18+ - `@lingo.dev/compiler` installed {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure next.config.ts Wrap your Next.js config with `withLingo`. The config function must be `async` - this is required because `withLingo` performs asynchronous initialization during the build. ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }); } ``` {% callout type="warning" title="Async config required" %} The config must be exported as an `async` function, not as a plain object. If you export a plain object, the compiler cannot initialize and the build will fail. See [Troubleshooting](/docs/react/compiler/troubleshooting) for details. {% /callout %} ## Add LingoProvider Wrap your root layout with `LingoProvider` to enable locale context throughout the component tree: ```tsx // app/layout.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `LingoProvider` handles locale resolution, persistence, and dictionary loading. It works with both Server Components and Client Components. ## Server Components and Client Components The compiler handles both component types transparently: | Component type | How translations work | | --- | --- | | React Server Components | Translations resolved at request time on the server. No client-side JS overhead. | | Client Components (`"use client"`) | Translations bundled into the client chunk. `useLingoContext()` available for locale switching. | | Shared components | Work in both contexts. The compiler detects the rendering environment automatically. | ```tsx // app/page.tsx - Server Component (default) export default function Home() { return

Welcome to our app

; // Renders translated text with zero client JS } ``` ```tsx // app/components/greeting.tsx - Client Component "use client"; export function Greeting() { return

Hello, world

; // Translations included in client bundle } ``` ## Bundler support The `withLingo()` wrapper works with both bundlers supported by Next.js: | Bundler | Support | Notes | | --- | --- | --- | | Webpack | Full | Default bundler. No additional configuration needed. | | Turbopack | Full | Enable with `next dev --turbopack`. The compiler detects Turbopack automatically. | ## sourceRoot configuration The `sourceRoot` option tells the compiler which directory contains your translatable components. For Next.js App Router projects, this is typically `./app`: ```ts { sourceRoot: "./app", } ``` If you have components outside `./app` (such as a shared `components/` directory), set `sourceRoot` to the common parent: ```ts { sourceRoot: ".", } ``` {% callout type="info" %} A broader `sourceRoot` means more files are scanned. For large projects, keep it as narrow as possible to reduce build times. Alternatively, use `useDirective: true` and add `'use i18n'` only to files that need translation. See [Project Structure](/docs/react/compiler/project-structure) for details. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough with authentication" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Add a language switcher to your app" icon="globe" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% /card-grid %} - [Optimization](https://lingo.dev/en/docs/react/compiler/optimization): Advanced compiler optimization techniques including tree shaking to reduce translation bundle size by 40-60%. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Advanced compiler optimization techniques. ## Tree Shaking The compiler analyzes usage to remove unused translations: ```typescript // Only bundle what you actually use import { t } from "./i18n"; t("welcome.title"); // ✅ Included // 'auth.login' never used → ❌ Not included in bundle ``` {% callout type="success" title="Bundle Size Savings" %} Typical projects see 40-60% reduction in translation bundle size. {% /callout %} - [Output Formats](https://lingo.dev/en/docs/react/compiler/output-formats): The Lingo.dev Compiler supports multiple output formats including TypeScript, JavaScript ESM, and JSON. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The compiler supports multiple output formats. ## TypeScript ```typescript export const translations = { welcome: "Welcome", } as const; ``` ## JavaScript (ESM) ```javascript export const translations = { welcome: "Welcome", }; ``` ## JSON ```json { "welcome": "Welcome" } ``` {% callout type="info" %} Choose the format that best fits your build pipeline. {% /callout %} - [Project Structure](https://lingo.dev/en/docs/react/compiler/project-structure): How the Lingo.dev Compiler organizes translation files - the .lingo/ directory, metadata.json cache, sourceRoot scanning, and the opt-in 'use i18n' directive mode. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler creates and maintains a `.lingo/` directory in your project root that stores translation metadata and cache. Understanding this directory structure helps you manage translations in version control, debug missing translations, and optimize build performance. ## The .lingo/ directory The compiler creates this directory automatically on the first build. It contains all translation metadata used by the build pipeline: ``` .lingo/ metadata.json # Translation cache and content hashes locale-resolver.server.ts # Optional: custom server-side locale resolver locale-resolver.client.ts # Optional: custom client-side locale resolver ``` ### metadata.json This is the primary file in the `.lingo/` directory. It stores: - **Content hashes** - Stable hash-based identifiers for each translatable string - **Cached translations** - Generated translations for each locale pair - **Source text snapshots** - The source text at the time of translation, used to detect changes The compiler reads this file at the start of each build. Strings with matching hashes reuse cached translations. Strings with changed or missing hashes are sent to the configured translation provider. {% callout type="warning" title="Commit to version control" %} Always commit `.lingo/metadata.json` to your repository. Production builds in `cache-only` mode read translations exclusively from this file. If it is not committed, production builds will fail. {% /callout %} ### .gitignore considerations Do **not** add `.lingo/` to `.gitignore`. The directory should be tracked in version control. A typical `.gitignore` for a project using the compiler: ```gitignore # Do NOT ignore .lingo/ - it contains translation cache node_modules/ dist/ .env ``` ## sourceRoot The `sourceRoot` option determines which directory the compiler scans for translatable React components: ```ts { sourceRoot: "./app", // Next.js App Router // or sourceRoot: "src", // Vite + React } ``` The compiler recursively scans all `.tsx`, `.ts`, `.jsx`, and `.js` files within `sourceRoot` for translatable JSX content. Files outside this directory are not processed. | sourceRoot value | What gets scanned | | --- | --- | | `"./app"` | All files in the `app/` directory (Next.js convention) | | `"src"` | All files in the `src/` directory (Vite convention) | | `"."` | All files in the project root (useful for monorepos with shared packages) | {% callout type="info" %} A broader `sourceRoot` scans more files, which increases build time. Keep it as narrow as possible. If only some files need translation, use the `useDirective` option instead. {% /callout %} ## Opt-in mode with 'use i18n' By default, the compiler translates all JSX text in `sourceRoot`. To switch to opt-in mode, set `useDirective: true`: ```ts { useDirective: true, } ``` In opt-in mode, only files that start with the `'use i18n'` directive are processed: ```tsx 'use i18n'; export function Welcome() { return

Welcome to our app

; // This text IS translated } ``` Files without the directive are skipped: ```tsx export function InternalAdmin() { return

Admin Dashboard

; // This text is NOT translated } ``` ### When to use opt-in mode | Scenario | Recommended mode | | --- | --- | | Small app where all content should be translated | Default (`useDirective: false`) | | Large codebase with only some user-facing pages | Opt-in (`useDirective: true`) | | Monorepo with shared internal and external components | Opt-in (`useDirective: true`) | | Gradual adoption - adding i18n to an existing app | Opt-in (`useDirective: true`) | ## lingoDir The `lingoDir` option changes the location of the metadata directory: ```ts { lingoDir: ".lingo", // Default // or lingoDir: ".translations", // Custom location } ``` This is useful if `.lingo/` conflicts with an existing directory in your project. ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="How metadata.json is used in each mode" icon="terminal" /%} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="Add resolver files to .lingo/" icon="gear" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="sourceRoot, lingoDir, and useDirective options" icon="gear" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Version control and project setup tips" icon="book" /%} {% /card-grid %} - [Compiler Quick Start](https://lingo.dev/en/docs/react/compiler/quick-start): Get started with the Lingo.dev Compiler in three steps: install, configure, and compile your translations for production. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Compile your translations in 3 steps. ## Install ```bash npm install -D @lingo.dev/compiler ``` ## Configure ```javascript // lingo.compiler.js export default { input: "./locales", output: "./src/i18n", format: "typescript", optimize: true, }; ``` ## Compile ```bash lingo compile ``` {% callout type="success" %} Your translations are now compiled and optimized for production! {% /callout %} - [Setup](https://lingo.dev/en/docs/react/compiler/setup): Add multilingual support to a React app in under 5 minutes - install the compiler, configure your framework, add LingoProvider, and generate your first translations. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Add multilingual support to your React application in under 5 minutes. {% callout type="info" title="Prerequisites" %} Node.js 18+ and a React application using Next.js (App Router) or Vite. {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure your framework {% tabs %} {% tab label="Next.js" %} Make your config async and wrap it with `withLingo`: ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }); } ``` {% /tab %} {% tab label="Vite + React" %} Add the Lingo plugin to your Vite config (before the React plugin): ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }), react(), ], }); ``` {% /tab %} {% /tabs %} ## Add LingoProvider {% tabs %} {% tab label="Next.js" %} ```tsx // app/layout.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` {% /tab %} {% tab label="Vite + React" %} ```tsx // src/main.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; createRoot(document.getElementById("root")!).render( ); ``` {% /tab %} {% /tabs %} ## Authenticate {% tabs %} {% tab label="Lingo.dev Engine (recommended)" %} ```bash npx lingo.dev@latest login ``` This opens your browser for authentication. The free Hobby tier works for most projects. If browser auth is blocked, add the key to `.env` manually: ```bash LINGODOTDEV_API_KEY=your_key_here ``` {% /tab %} {% tab label="Direct LLM provider" %} Configure the provider in your compiler config: ```ts { models: { "*:*": "groq:llama-3.3-70b-versatile" } } ``` Add the API key to `.env`: ```bash GROQ_API_KEY=your_key ``` See [Translation Providers](/docs/react/compiler/translation-providers) for all supported providers. {% /tab %} {% /tabs %} ## Run the dev server ```bash npm run dev ``` The compiler scans your JSX, generates pseudotranslations (instant fake translations to visualize what gets translated), and injects them into your components. Metadata is stored in `.lingo/metadata.json` - commit this to version control. ## Add a language switcher (optional) ```tsx "use client"; // For Next.js import { useLingoContext } from "@lingo.dev/compiler/react"; export function LanguageSwitcher() { const { locale, setLocale } = useLingoContext(); return ( ); } ``` ## Generate real translations When ready, disable pseudotranslator: ```ts { dev: { usePseudotranslator: false, } } ``` Restart the dev server. The compiler generates real AI translations for new or changed text. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/compiler" description="The build-time transformation pipeline" icon="book" /%} {% link-card title="Next.js" href="/docs/react/compiler/nextjs" description="Next.js-specific setup and features" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Vite-specific setup and features" icon="code" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% /card-grid %} - [Translation Providers](https://lingo.dev/en/docs/react/compiler/translation-providers): Configure translation providers for the Lingo.dev Compiler - use the Lingo.dev localization engine, direct LLM providers like OpenAI and Anthropic, or local models via Ollama. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler supports multiple translation providers, from the managed Lingo.dev localization engine to direct LLM provider connections and local models. You configure providers through the `models` option, which accepts either a single provider string or an object mapping locale pairs to specific providers. ## Lingo.dev Engine (recommended) The Lingo.dev localization engine is the default provider. It routes translations through a managed pipeline with dynamic model selection, automatic fallbacks, glossary enforcement, and brand voice profiles. ```ts { models: "lingo.dev", } ``` Authenticate via CLI: ```bash npx lingo.dev@latest login ``` Or set the API key in `.env`: ```bash LINGODOTDEV_API_KEY=your_key_here ``` {% callout type="success" title="Why use the Lingo.dev engine" %} The localization engine selects the optimal model per locale pair, applies your [glossary](/docs/platform/glossaries) and [brand voice](/docs/platform/brand-voices) rules, and falls back to alternative models if a provider is unavailable. Direct LLM providers do not include these features. {% /callout %} ## Direct LLM providers Connect directly to any supported LLM provider by specifying a `provider:model` string: | Provider | Model format | Environment variable | Example | | --- | --- | --- | --- | | OpenAI | `openai:` | `OPENAI_API_KEY` | `openai:gpt-4o` | | Anthropic | `anthropic:` | `ANTHROPIC_API_KEY` | `anthropic:claude-3-5-sonnet` | | Google | `google:` | `GOOGLE_API_KEY` | `google:gemini-2.0-flash` | | Groq | `groq:` | `GROQ_API_KEY` | `groq:llama-3.3-70b-versatile` | | Mistral | `mistral:` | `MISTRAL_API_KEY` | `mistral:mistral-large` | | OpenRouter | `openrouter:` | `OPENROUTER_API_KEY` | `openrouter:anthropic/claude-3.5-sonnet` | | Ollama | `ollama:` | None (local) | `ollama:llama3.2` | ### Single provider for all locales Set a string to use one provider for every locale pair: ```ts { models: "openai:gpt-4o", } ``` ### Ollama (local models) Ollama runs models locally with no API key required. Install [Ollama](https://ollama.com), pull a model, and configure: ```ts { models: "ollama:llama3.2", } ``` {% callout type="info" %} Local models are useful for offline development and for teams that cannot send content to external APIs. Translation quality varies by model size - larger models produce more accurate results. {% /callout %} ## Locale-pair mapping The `models` option accepts an object to route specific locale pairs to different providers. Keys use the format `source:target` with wildcard (`*`) support: ```ts { models: { "*:*": "lingo.dev", // Default for all pairs "*:ja": "anthropic:claude-3-5-sonnet", // Japanese via Anthropic "*:zh-Hans": "anthropic:claude-3-5-sonnet", // Simplified Chinese via Anthropic "en:de": "openai:gpt-4o", // English-to-German via OpenAI }, } ``` The compiler matches locale pairs from most specific to least specific: {% steps %} {% step title="Exact match" %} `en:de` matches only English-to-German translations. {% /step %} {% step title="Target wildcard" %} `*:ja` matches any source language translating to Japanese. {% /step %} {% step title="Full wildcard" %} `*:*` is the fallback for any pair without a more specific match. {% /step %} {% /steps %} This mapping lets you optimize for cost and quality. For example, use a fast model for European languages and a model with stronger CJK support for East Asian locales. ## Custom prompts The `prompt` option sets a system prompt for the translation LLM. Use `{SOURCE_LOCALE}` and `{TARGET_LOCALE}` as placeholders - the compiler replaces them with the actual locale codes at translation time: ```ts { prompt: "You are translating a SaaS application UI from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep translations concise. Preserve technical terms in English. Use formal register.", } ``` {% callout type="info" %} Custom prompts apply to direct LLM providers only. When using the Lingo.dev localization engine, configure [instructions](/docs/platform/instructions) and [brand voice](/docs/platform/brand-voices) through the Lingo.dev dashboard instead. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options in one place" icon="gear" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Cost optimization and model selection tips" icon="book" /%} {% link-card title="Lingo.dev Engines" href="/docs/platform/engines" description="Configure a localization engine on Lingo.dev" icon="plug" /%} {% /card-grid %} - [Troubleshooting](https://lingo.dev/en/docs/react/compiler/troubleshooting): Solutions for common Lingo.dev Compiler issues - missing modules, async config errors, missing translations, pseudotranslator artifacts, port conflicts, and slow builds. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Solutions for common issues when using the Lingo.dev Compiler. Each section describes the symptom, cause, and fix. ## Installation issues {% accordion title="Cannot find module '@lingo.dev/compiler'" %} **Symptom:** Build fails with `Cannot find module '@lingo.dev/compiler'` or `Cannot find module '@lingo.dev/compiler/next'`. **Cause:** The package is not installed, or your package manager has not resolved it correctly. **Fix:** ```bash # Remove node_modules and reinstall rm -rf node_modules npm install @lingo.dev/compiler npm install ``` If you are migrating from the old package name, make sure to uninstall it first: ```bash npm uninstall lingo.dev npm install @lingo.dev/compiler ``` See [Migration Guide](/docs/react/compiler/migration-guide) for the full migration procedure. {% /accordion %} ## Configuration issues {% accordion title="Config must be async function (Next.js)" %} **Symptom:** Build fails with an error about the Next.js config not being an async function, or `withLingo` returns a Promise that is not awaited. **Cause:** `withLingo` is an async function that performs initialization. Next.js requires the config export to be an async function when using async operations. **Fix:** Wrap your config in an async function: ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; // Must be async export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de"], }); } ``` {% /accordion %} {% accordion title="Plugin order error (Vite)" %} **Symptom:** Translations are not generated. The compiler reports that no translatable strings were found, even though your components contain text. **Cause:** The `lingoCompilerPlugin` is placed after the `react()` plugin in the Vite config. The React plugin transforms JSX before the compiler can analyze it. **Fix:** Move `lingoCompilerPlugin` before `react()`: ```ts // vite.config.ts export default defineConfig({ plugins: [ lingoCompilerPlugin({ /* ... */ }), // BEFORE react() react(), ], }); ``` {% /accordion %} ## Translation issues {% accordion title="Translations not showing in the UI" %} **Symptom:** The app renders source language text even though translations should be available. **Possible causes and fixes:** | Cause | Fix | | --- | --- | | `LingoProvider` is missing | Wrap your root component in ``. See [Next.js](/docs/react/compiler/nextjs) or [Vite + React](/docs/react/compiler/vite-react). | | `.lingo/metadata.json` is empty or missing | Run a build in `translate` mode to generate translations. Check that `.lingo/` exists. | | `buildMode` is `cache-only` with no cache | Switch to `translate` mode or run a CI build to populate the cache. | | Text is outside `sourceRoot` | Move the component into `sourceRoot` or broaden the `sourceRoot` path. | | `useDirective: true` but file lacks `'use i18n'` | Add `'use i18n'` at the top of the file. | | Text is in a variable, not JSX | Move text directly into JSX. The compiler only detects text in JSX elements. | {% /accordion %} {% accordion title="Seeing [!!! ... !!!] in translations" %} **Symptom:** The UI shows text like `[!!! Welcome !!!]` instead of real translations. **Cause:** The pseudotranslator is enabled. This is expected behavior during development - pseudotranslations help you identify which strings are being processed. **Fix:** Disable the pseudotranslator to generate real translations: ```ts { dev: { usePseudotranslator: false, }, } ``` Restart the dev server after changing this option. The compiler will call the configured translation provider for any new or changed strings. {% /accordion %} {% accordion title="Missing translations in production build" %} **Symptom:** Production build fails with errors about missing translations, or some strings render in the source language. **Cause:** The `.lingo/metadata.json` file is missing from the repository, or not all strings were translated before the production build. **Fix:** {% steps %} {% step title="Verify .lingo/ is committed" %} Check that `.lingo/metadata.json` exists in your repository and is not in `.gitignore`. {% /step %} {% step title="Run translate mode in CI" %} Your CI pipeline should run a build in `translate` mode before the production build: ```bash LINGO_BUILD_MODE=translate npm run build ``` Commit the updated `.lingo/metadata.json` back to the repository. {% /step %} {% step title="Verify build mode in production" %} Ensure the production build uses `cache-only` mode: ```bash LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} {% /accordion %} ## Performance issues {% accordion title="Build is slow" %} **Symptom:** Development builds take noticeably longer after adding the compiler. **Possible causes and fixes:** | Cause | Fix | | --- | --- | | Pseudotranslator is disabled in dev | Enable `dev.usePseudotranslator: true`. Real LLM calls are slower than instant pseudotranslations. | | `sourceRoot` is too broad | Narrow `sourceRoot` to the directory containing translatable components. | | Many new strings on each build | The compiler only translates new or changed strings. After the initial build, subsequent builds are incremental. | | Large number of target locales | Each locale pair requires a separate translation. Consider reducing target locales during development. | {% /accordion %} ## Port conflicts {% accordion title="Translation server port conflict" %} **Symptom:** The dev server fails to start, or logs show that the translation server cannot bind to a port. **Cause:** All ports in the range 60000-60099 are occupied by other processes. **Fix:** Change the starting port: ```ts { dev: { translationServerStartPort: 61000, // Try a different range }, } ``` Alternatively, check for processes using ports in the default range: ```bash lsof -i :60000-60099 ``` Kill any stale processes or choose a different port range. {% /accordion %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="Verify your configuration options" icon="gear" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Understand translate vs cache-only" icon="terminal" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="code" /%} {% link-card title="Migration Guide" href="/docs/react/compiler/migration-guide" description="Migrating from the old package" icon="book" /%} {% /card-grid %} - [Vite + React](https://lingo.dev/en/docs/react/compiler/vite-react): Integrate the Lingo.dev Compiler with Vite and React using the lingoCompilerPlugin - full HMR support with translations injected at build time. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler integrates with Vite through `lingoCompilerPlugin`, a Vite plugin that transforms your React components at build time to inject translations. It supports full Hot Module Replacement, so translations update instantly during development. ## Prerequisites {% callout type="info" title="Requirements" %} - Vite 5+ with React - Node.js 18+ - `@lingo.dev/compiler` installed {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure vite.config.ts Add `lingoCompilerPlugin` to your Vite config. The plugin must be placed **before** the `react()` plugin - this ordering is required because the compiler needs to transform JSX before the React plugin processes it. ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }), react(), ], }); ``` {% callout type="error" title="Plugin order matters" %} If `lingoCompilerPlugin` is placed after `react()`, the React plugin processes JSX first and the compiler cannot identify translatable text. Always place the Lingo plugin first in the `plugins` array. {% /callout %} ## Add LingoProvider Wrap your application root with `LingoProvider` in your entry file: ```tsx // src/main.tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { LingoProvider } from "@lingo.dev/compiler/react"; import App from "./App"; createRoot(document.getElementById("root")!).render( ); ``` `LingoProvider` initializes the locale context and loads the appropriate translation dictionary for the active locale. ## Hot Module Replacement The plugin integrates with Vite's HMR system. When you edit translatable text in a component: {% steps %} {% step title="Edit source text" %} Change any text in your JSX - for example, update a heading from "Welcome" to "Welcome back". {% /step %} {% step title="Compiler detects the change" %} The plugin intercepts the HMR update, identifies the changed string, and generates a new translation (or pseudotranslation in dev mode). {% /step %} {% step title="Browser updates instantly" %} The translated component re-renders without a full page reload. Translation metadata in `.lingo/metadata.json` is updated on disk. {% /step %} {% /steps %} ## sourceRoot configuration The `sourceRoot` option determines which files the compiler scans for translatable text. For a standard Vite + React project: ```ts { sourceRoot: "src", } ``` | Project structure | Recommended sourceRoot | | --- | --- | | Standard (`src/`) | `"src"` | | Monorepo with shared packages | `"."` (project root) | | Custom directory | Path to your components directory | {% callout type="info" %} For large codebases, a narrow `sourceRoot` reduces build times. If you only need translations in specific files, enable `useDirective: true` and add `'use i18n'` to those files. See [Project Structure](/docs/react/compiler/project-structure). {% /callout %} ## Example project structure ``` my-vite-app/ src/ main.tsx # LingoProvider wraps App.tsx # Translatable components components/ Header.tsx # Automatically scanned Footer.tsx # Automatically scanned .lingo/ metadata.json # Translation cache (commit this) vite.config.ts # lingoCompilerPlugin configured here ``` ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough with authentication" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Add a language switcher to your app" icon="globe" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and dev server" icon="terminal" /%} {% /card-grid %} - [Claude Code](https://lingo.dev/en/docs/react/mcp/claude-code): Set up the Lingo.dev i18n MCP server in Claude Code - one command to connect, then prompt 'Set up i18n' to implement internationalization in your project. Claude Code is Anthropic's CLI for agentic coding. It supports MCP servers natively via the `claude mcp add` command. ## Setup ```bash claude mcp add --transport http "lingo" https://mcp.lingo.dev/main ``` This registers the Lingo.dev i18n MCP server with Claude Code. No API key is needed. ## Usage Navigate to your project directory and prompt Claude Code: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Claude Code calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [Codex (OpenAI)](https://lingo.dev/en/docs/react/mcp/codex): Set up the Lingo.dev i18n MCP server in OpenAI Codex - add a TOML config entry, then prompt 'Set up i18n' to implement internationalization. Codex is OpenAI's autonomous software engineering agent available to ChatGPT Plus users. It supports MCP servers via a TOML configuration file. ## Setup Add to `~/.codex/config.toml`: ```toml [mcp_servers.lingo] command = "npx" args = ["mcp-remote", "https://mcp.lingo.dev/main"] ``` No API key is needed. ## Usage Prompt Codex: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Codex calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [Cursor](https://lingo.dev/en/docs/react/mcp/cursor): Set up the Lingo.dev i18n MCP server in Cursor - add a JSON config file to your project, then prompt 'Set up i18n' to implement internationalization. Cursor is an AI-powered code editor built on VS Code. It supports MCP servers via a project-level JSON configuration file. ## Setup Create `.cursor/mcp.json` in your project root: ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/main" } } } ``` No API key is needed. ## Usage Open your project in Cursor and prompt: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Cursor calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [GitHub Copilot Agents](https://lingo.dev/en/docs/react/mcp/github-copilot): Set up the Lingo.dev i18n MCP server in GitHub Copilot Agents - configure the MCP and agent definition, then assign an i18n setup task. GitHub Copilot coding agent is an autonomous AI tool that completes development tasks in the background and proposes pull requests. It supports MCP servers via the repository settings. ## Setup {% steps %} {% step title="Configure the MCP server" %} Navigate to your repository **Settings > Copilot > Coding agent**. In the **MCP configuration** field, enter: ```json { "mcpServers": { "lingo": { "command": "npx", "type": "stdio", "tools": ["*"], "args": ["mcp-remote", "https://mcp.lingo.dev/main"] } } } ``` Click **Save MCP configuration**. {% /step %} {% step title="Add the agent definition" %} Commit the following file to `.github/agents/i18n-setup.md` in your repository: ```markdown --- name: i18n-setup description: Expert at implementing internationalization (i18n) in web applications using a systematic, checklist-driven approach. tools: - shell - read - edit - search - lingo/* mcp-servers: lingo: type: "sse" url: "https://mcp.lingo.dev/main" tools: ["*"] --- You are an i18n implementation specialist. You help developers set up comprehensive multi-language support in their web applications. ## Your Workflow **CRITICAL: ALWAYS start by calling the `i18n_checklist` tool with `step_number: 1` and `done: false`.** This tool will tell you exactly what to do. Follow its instructions precisely: 1. Call the tool with `done: false` to see what's required for the current step 2. Complete the requirements 3. Call the tool with `done: true` and provide evidence 4. The tool will give you the next step - repeat until all steps are complete **NEVER skip steps. NEVER implement before checking the tool. ALWAYS follow the checklist.** ``` {% /step %} {% /steps %} ## Usage 1. Navigate to [Copilot Agents](https://github.com/copilot/agents) 2. Select your repository and the `i18n-setup` agent 3. Enter a prompt: ``` Set up i18n for the following locales: - en - es Use "en" as the default locale. ``` 4. Click **Start task** The agent works in the background and opens a pull request with the complete i18n implementation. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% /card-grid %} - [I18n MCP](https://lingo.dev/en/docs/react/mcp): The Lingo.dev i18n MCP server gives AI coding assistants the tools to set up internationalization in your codebase - locale-aware routes, language switcher, locale detection - from a single prompt. We found that AI coding agents get stuck when setting up internationalization in web apps from scratch. Too many interdependent steps - locale routing, middleware, translation files, provider wrappers, language switcher - and agents lose track of what's done and what's left. Inspired by the [Sequential Thinking MCP](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking), we built a free MCP server that breaks i18n setup into a guided checklist the agent follows step by step. Connect it to your AI agent, type "Set up i18n," and the full setup completes in minutes. {% youtube src="https://www.youtube.com/watch?v=7BX4t014B2I" title="How to set up i18n in Next.js 16 (App Router) with Lingo.dev MCP Server" /%} ## What the MCP provides The server exposes four tools to the AI agent: | Tool | Purpose | | --- | --- | | `i18n_checklist` | A step-by-step implementation guide that coordinates the entire setup. The agent calls it at each step to know what to do next. | | `get_project_context` | Captures the project's architecture - framework, router, directory structure - to inform the implementation strategy. | | `get_framework_docs` | Retrieves official framework documentation for the detected framework (Next.js, React Router, TanStack Start). | | `get_i18n_library_docs` | Retrieves documentation for i18n libraries (e.g., react-intl) used during provider and component setup. | The `i18n_checklist` tool is the coordinator. It walks the agent through 13 steps - from project analysis through locale routing, translation setup, language switcher, and build validation. Each step tells the agent exactly what to implement and which tools to call. ## What gets implemented A typical MCP-guided setup produces: - **Locale-aware routes** - URLs prefixed with the active locale (`/en/about`, `/es/about`) - **Language switcher** - A UI component for switching between supported locales - **Locale detection** - Automatic detection of the user's preferred language - **Translation infrastructure** - Provider setup, translation files, and helper functions ## Supported frameworks | Framework | Versions | | --- | --- | | Next.js App Router | v13-16 | | Next.js Pages Router | v13-16 | | TanStack Start | v1 | | React Router | v7 | ## Usage Once the MCP is connected to your AI coding assistant, prompt it: > Set up i18n Or be specific about locales: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". The agent calls `i18n_checklist` to start, then follows the guided steps - calling the other tools as needed. The result is a working i18n setup tailored to your framework and project structure. {% callout type="info" %} AI-assisted coding is inherently non-deterministic. The MCP improves consistency through its checklist-driven approach, but exact results may vary between runs. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/mcp/setup" description="Connect the MCP to your AI coding assistant" icon="rocket" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [Setup](https://lingo.dev/en/docs/react/mcp/setup): Connect the Lingo.dev i18n MCP server to your AI coding assistant - one configuration step for Claude Code, Cursor, Codex, or GitHub Copilot Agents. Connect the Lingo.dev i18n MCP server to your AI coding assistant in one step. The server is hosted at `https://mcp.lingo.dev/main` - no installation or API key required. ## Quick setup {% tabs %} {% tab label="Claude Code" %} ```bash claude mcp add --transport http "lingo" https://mcp.lingo.dev/main ``` {% /tab %} {% tab label="Cursor" %} Create `.cursor/mcp.json` in your project: ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/main" } } } ``` {% /tab %} {% tab label="Codex (OpenAI)" %} Add to `~/.codex/config.toml`: ```toml [mcp_servers.lingo] command = "npx" args = ["mcp-remote", "https://mcp.lingo.dev/main"] ``` {% /tab %} {% tab label="GitHub Copilot" %} 1. Navigate to your repository **Settings > Copilot > Coding agent** 2. In **MCP configuration**, enter: ```json { "mcpServers": { "lingo": { "command": "npx", "type": "stdio", "tools": ["*"], "args": ["mcp-remote", "https://mcp.lingo.dev/main"] } } } ``` 3. Click **Save MCP configuration** {% /tab %} {% /tabs %} ## Verify the connection After connecting, prompt the AI agent: > Set up i18n The agent should call the `i18n_checklist` tool as its first action. If it doesn't recognize the tool, check that the MCP server URL is correct and the configuration file is saved in the right location. ## What happens next The agent follows a 13-step checklist that covers: 1. Project context analysis (framework, router, directory structure) 2. Framework documentation retrieval 3. Locale routing setup 4. Translation infrastructure 5. Language switcher component 6. Build validation You can specify locales upfront or let the agent ask: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". For platform-specific details, see the individual guides: {% card-grid %} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Terminal-based setup with Claude Code" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Editor-based setup with Cursor" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Autonomous agent setup with Codex" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Repository-level setup with Copilot Agents" icon="code" /%} {% /card-grid %} ## Docs – React/compiler - [Automatic Pluralization](https://lingo.dev/en/docs/react/compiler/automatic-pluralization): The Lingo.dev Compiler automatically detects plural forms in your JSX text and converts them to ICU MessageFormat, supporting all CLDR plural categories across languages. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler detects plural forms in your JSX text and automatically converts them to [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/). Instead of manually writing plural rules for each language, write natural text with numeric values and the compiler generates the correct plural forms using an LLM. ## How it works {% steps %} {% step title="Compiler detects numeric patterns" %} During AST analysis, the compiler identifies text nodes that contain interpolated numbers alongside count-dependent words. For example, `You have {count} items` contains a numeric variable next to a word that changes with quantity. {% /step %} {% step title="LLM classifies plural forms" %} A small, fast LLM (configurable via `pluralization.model`) analyzes the text and determines which words need plural inflection. It generates the appropriate [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules) for each target locale. {% /step %} {% step title="ICU MessageFormat is generated" %} The compiler produces an ICU MessageFormat string that handles all plural categories required by the target language. {% /step %} {% /steps %} ## Example Source JSX: ```tsx

You have {count} items in your cart

``` Generated output for English: ``` {count, plural, one {You have 1 item in your cart} other {You have # items in your cart}} ``` Generated output for Russian (which has four plural categories): ``` {count, plural, one {У вас # товар в корзине} few {У вас # товара в корзине} many {У вас # товаров в корзине} other {У вас # товаров в корзине}} ``` ## CLDR plural categories Different languages use different subsets of the six [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules). The compiler generates only the categories required by each target locale: | Category | Description | Example languages | | --- | --- | --- | | `zero` | Zero quantity | Arabic, Latvian | | `one` | Singular | English, French, German, Spanish | | `two` | Dual | Arabic, Hebrew, Slovenian | | `few` | Paucal / small quantity | Russian, Czech, Polish | | `many` | Large quantity | Russian, Arabic, Polish | | `other` | General / default (always required) | All languages | English uses `one` and `other`. Russian uses `one`, `few`, `many`, and `other`. Arabic uses all six categories. The compiler handles this automatically per locale. ## Configuration Pluralization is enabled by default. Configure it in the compiler options: ```ts { pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, } ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `pluralization.enabled` | `boolean` | `true` | Enable or disable automatic plural detection. | | `pluralization.model` | `string` | `"groq:llama-3.1-8b-instant"` | LLM model for plural form detection. A smaller model is sufficient since detection is simpler than translation. | To disable pluralization entirely: ```ts { pluralization: { enabled: false, }, } ``` {% callout type="info" %} Disabling pluralization means the compiler translates text containing numbers as plain strings. The translated output may not be grammatically correct for all quantities in languages with complex plural rules. {% /callout %} ## When pluralization applies The compiler detects plural patterns in these cases: - Text with interpolated numeric variables: `{count} items`, `{n} messages` - Text with numeric literals: `You have 5 items` (less common in dynamic UI) The compiler does **not** pluralize: - Text with no numeric reference: `Items in cart` (no number to branch on) - Text where the number is not directly related to a count-dependent word {% callout type="success" %} Write natural text in your JSX. The compiler and its LLM handle the plural detection and ICU formatting - you do not need to learn ICU MessageFormat syntax. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All pluralization options" icon="gear" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Configure the LLM used for translation" icon="plug" /%} {% link-card title="Manual Overrides" href="/docs/react/compiler/manual-overrides" description="Override specific translations when needed" icon="code" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="When to enable or disable pluralization" icon="book" /%} {% /card-grid %} - [Best Practices](https://lingo.dev/en/docs/react/compiler/best-practices): Recommended patterns for using the Lingo.dev Compiler in production - build mode strategy, version control, model selection, text placement, and testing with pseudotranslator. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} These practices are based on patterns that produce reliable, cost-effective results with the Lingo.dev Compiler. They cover the build pipeline, code organization, translation quality, and testing. ## Build pipeline ### Use the three-mode strategy {% steps %} {% step title="Dev - pseudotranslator" %} Enable `dev.usePseudotranslator: true` for instant feedback. No API calls, no cost, immediate results. Pseudotranslations help you spot untranslated strings and test layout. ```ts { buildMode: "translate", dev: { usePseudotranslator: true }, } ``` {% /step %} {% step title="CI - translate mode" %} Run with `buildMode: "translate"` and a real provider. Commit the updated `.lingo/metadata.json` after each CI run so translations are available for production. ```bash LINGO_BUILD_MODE=translate npm run build ``` {% /step %} {% step title="Production - cache-only mode" %} Deploy with `buildMode: "cache-only"`. No API keys needed in production. Builds are deterministic and fast. ```bash LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} ## Version control ### Commit .lingo/ to your repository The `.lingo/metadata.json` file is the source of truth for all cached translations. Production builds in `cache-only` mode depend on it. ```gitignore # .gitignore - do NOT ignore .lingo/ node_modules/ dist/ .env ``` {% callout type="error" %} If `.lingo/metadata.json` is not committed, production builds fail because `cache-only` mode has no translations to read. {% /callout %} ### Review translation diffs When CI commits updated translations, review the `.lingo/metadata.json` diff in pull requests. This lets you catch translation issues before they reach production - similar to reviewing code changes. ## Code organization ### Place translatable text directly in JSX The compiler scans JSX for translatable content. Text stored in JavaScript variables, constants, or external files is not detected: ```tsx // Good - compiler detects this text export function Header() { return

Welcome to our app

; } // Bad - compiler cannot detect text in a variable const title = "Welcome to our app"; export function Header() { return

{title}

; } ``` ### Use useDirective for large codebases In large projects, scanning every file increases build time. Enable `useDirective: true` and add `'use i18n'` only to files that contain user-facing text: ```ts { useDirective: true, } ``` ```tsx 'use i18n'; // Only this file is scanned for translations export function PublicPage() { return

Welcome

; } ``` ### Keep sourceRoot narrow Set `sourceRoot` to the smallest directory that contains your translatable components. A broad `sourceRoot` scans unnecessary files: | Project type | Recommended sourceRoot | | --- | --- | | Next.js App Router | `"./app"` | | Vite + React | `"src"` | | Monorepo (with useDirective) | `"."` | ## Translation quality ### Use manual overrides for brand terms Brand names, product names, and legal text should use [manual overrides](/docs/react/compiler/manual-overrides) rather than relying on AI translation: ```tsx

Localization Engine

``` ### Use locale-pair mapping for cost optimization Different models have different strengths and price points. Map expensive models to languages that need them and use cost-effective models elsewhere: ```ts { models: { "*:*": "groq:llama-3.3-70b-versatile", // Fast, cost-effective default "*:ja": "anthropic:claude-3-5-sonnet", // Higher quality for Japanese "*:zh-Hans": "anthropic:claude-3-5-sonnet", // Higher quality for Chinese }, } ``` ### Use the Lingo.dev engine for glossary and brand voice When you need consistent terminology across your app, configure a [localization engine](/docs/platform/engines) on Lingo.dev with a [glossary](/docs/platform/glossaries) and [brand voice](/docs/platform/brand-voices). These apply automatically to every translation request. ## Pluralization ### Disable pluralization if not needed If your app does not display numeric counts alongside text, disable pluralization to reduce build complexity: ```ts { pluralization: { enabled: false }, } ``` ### Write count-dependent text naturally When pluralization is enabled, write text with numeric variables naturally. The compiler handles the ICU MessageFormat conversion: ```tsx // Good - the compiler detects and pluralizes this

You have {count} items in your cart

// Also good - works with any numeric expression

{unreadCount} unread messages

``` ## Testing ### Test with pseudotranslator first Before generating real translations, run with the pseudotranslator to verify complete coverage: 1. Enable `dev.usePseudotranslator: true` 2. Navigate through every page and component 3. Any text without `[!!! ... !!!]` markers is not being translated 4. Fix text placement issues (move text into JSX, adjust `sourceRoot`, add `'use i18n'` directives) {% callout type="success" %} Catching untranslated strings with the pseudotranslator is faster and cheaper than discovering them after generating real translations. {% /callout %} ### Test with real translations before release Disable the pseudotranslator and generate real translations for at least one target locale before releasing: ```ts { dev: { usePseudotranslator: false }, } ``` Check for layout overflow, text truncation, and bidirectional text issues that pseudotranslations cannot reveal. ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="CI and production build configuration" icon="terminal" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Provider selection and locale-pair mapping" icon="plug" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="code" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Common issues and solutions" icon="book" /%} {% /card-grid %} - [Build Modes](https://lingo.dev/en/docs/react/compiler/build-modes): The Lingo.dev Compiler has two build modes - translate mode generates missing translations via LLM, cache-only mode uses only pre-generated translations from .lingo/metadata.json. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler operates in two build modes that control whether new translations are generated during the build. Understanding these modes is essential for setting up a reliable development, CI, and production pipeline. ## The two modes | Mode | Behavior | When to use | | --- | --- | --- | | `"translate"` | Generates missing translations by calling the configured LLM provider. Cached translations are reused. | Development and CI - when new or changed text needs translation. | | `"cache-only"` | Uses only translations from `.lingo/metadata.json`. Fails if any translation is missing. | Production builds - deterministic output with no external API calls. | ## How translate mode works In `translate` mode, the compiler checks each translatable string against `.lingo/metadata.json`. If a cached translation exists and the source text has not changed, the cached version is used. If the string is new or modified, the compiler calls the configured translation provider to generate a translation and updates the cache. ```ts { buildMode: "translate", } ``` This mode is the default. It works with both the pseudotranslator (for instant fake translations) and real LLM providers. ## How cache-only mode works In `cache-only` mode, the compiler reads translations exclusively from `.lingo/metadata.json`. No LLM calls are made. If any translatable string is missing from the cache, the build fails with an error listing the missing strings. {% callout type="warning" %} `.lingo/metadata.json` must be committed to version control. Production builds in `cache-only` mode depend on this file being present in the repository - not just generated locally. {% /callout %} ```ts { buildMode: "cache-only", } ``` This mode produces deterministic builds - the same source code and cache always produce the same output. It also eliminates external API dependencies during production builds. ## Recommended workflow {% steps %} {% step title="Development - pseudotranslator" %} Enable the pseudotranslator for instant feedback with no API calls: ```ts { buildMode: "translate", dev: { usePseudotranslator: true, }, } ``` Pseudotranslations appear as `[!!! Welcome !!!]`, making it easy to spot untranslated strings and test layout with varying text lengths. {% /step %} {% step title="CI - translate mode" %} Run with `buildMode: "translate"` and a real LLM provider. The CI job generates translations for any new or changed strings and commits the updated `.lingo/metadata.json` back to the repository. ```bash # CI environment LINGO_BUILD_MODE=translate npm run build ``` {% /step %} {% step title="Production - cache-only mode" %} Deploy with `buildMode: "cache-only"` to use only pre-generated translations. No API keys are needed in the production environment. ```bash # Production environment LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} ## Environment variable override The `LINGO_BUILD_MODE` environment variable overrides the `buildMode` config option. This lets you use the same config file across environments: ```bash # Override in any environment LINGO_BUILD_MODE=cache-only npm run build ``` The environment variable takes precedence over the config file value. ## CI examples {% tabs %} {% tab label="GitHub Actions" %} ```yaml # .github/workflows/translate.yml name: Generate Translations on: push: branches: [main] jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build env: LINGO_BUILD_MODE: translate LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: update translations" file_pattern: ".lingo/metadata.json" ``` {% /tab %} {% tab label="GitLab CI" %} ```yaml # .gitlab-ci.yml translate: stage: build image: node:20 script: - npm ci - npm run build variables: LINGO_BUILD_MODE: translate LINGODOTDEV_API_KEY: $LINGODOTDEV_API_KEY artifacts: paths: - .lingo/metadata.json only: - main ``` {% /tab %} {% /tabs %} {% callout type="warning" %} Always commit `.lingo/metadata.json` to version control. Production builds in `cache-only` mode depend on this file. If it is missing or incomplete, the build will fail. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Configure LLM providers for translate mode" icon="plug" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="terminal" /%} {% link-card title="Project Structure" href="/docs/react/compiler/project-structure" description="The .lingo/ directory and metadata" icon="file-code" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Common build mode issues" icon="book" /%} {% /card-grid %} - [Configuration Reference](https://lingo.dev/en/docs/react/compiler/configuration-reference): Complete reference for all Lingo.dev Compiler configuration options - core settings, dev tools, locale persistence, pluralization, environment variables, and per-locale model mapping. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler configuration object controls how your React application is translated at build time. This page documents every available option with types, defaults, and usage examples. ## Core options | Option | Type | Default | Description | | --- | --- | --- | --- | | `sourceRoot` | `string` | `"src"` | Directory containing translatable components. Relative to project root. | | `lingoDir` | `string` | `".lingo"` | Directory for translation metadata and cache files. | | `sourceLocale` | `string` | **required** | Language code of your source content (e.g., `"en"`). | | `targetLocales` | `string[]` | **required** | Array of target language codes (e.g., `["es", "de", "fr"]`). | | `useDirective` | `boolean` | `false` | When `true`, only files with `'use i18n'` directive are translated. When `false`, all files in `sourceRoot` are translated. | | `models` | `string \| object` | `"lingo.dev"` | Translation provider configuration. A string sets the default for all locale pairs. An object maps locale pairs to specific providers. | | `prompt` | `string` | `undefined` | Custom system prompt for the translation LLM. Supports `{SOURCE_LOCALE}` and `{TARGET_LOCALE}` placeholders. | | `buildMode` | `"translate" \| "cache-only"` | `"translate"` | Controls whether the compiler generates new translations or uses only cached translations. | ## Dev options Options under the `dev` key control development-time behavior: | Option | Type | Default | Description | | --- | --- | --- | --- | | `dev.usePseudotranslator` | `boolean` | `false` | Generate instant fake translations (e.g., `[!!! Welcome !!!]`) instead of calling an LLM. No API key needed. | | `dev.translationServerStartPort` | `number` | `60000` | Starting port for the local translation server. The compiler auto-finds an available port in the range 60000-60099. | | `dev.translationServerUrl` | `string` | `undefined` | Override the translation server URL. Useful for custom setups or remote translation servers. | ## Locale persistence Options under `localePersistence` control how the user's selected locale is stored and retrieved: | Option | Type | Default | Description | | --- | --- | --- | --- | | `localePersistence.type` | `string` | `"cookie"` | Persistence mechanism. Currently supports `"cookie"`. | | `localePersistence.config.name` | `string` | `"locale"` | Cookie name used to store the locale. | | `localePersistence.config.maxAge` | `number` | `31536000` | Cookie max-age in seconds (default is 1 year). | For custom persistence logic (localStorage, URL-based, headers), see [Custom Locale Resolvers](/docs/react/compiler/custom-locale-resolvers). ## Pluralization Options under `pluralization` control automatic plural form detection and generation: | Option | Type | Default | Description | | --- | --- | --- | --- | | `pluralization.enabled` | `boolean` | `true` | Enable or disable automatic pluralization detection. | | `pluralization.model` | `string` | `"groq:llama-3.1-8b-instant"` | LLM model used for detecting plural forms in source text. A smaller, faster model is recommended since detection is a simpler task than translation. | See [Automatic Pluralization](/docs/react/compiler/automatic-pluralization) for details on how plural detection works. ## Environment variables Environment variables override or supplement configuration: | Variable | When required | Description | | --- | --- | --- | | `LINGO_BUILD_MODE` | Optional | Overrides the `buildMode` config option. Set to `"translate"` or `"cache-only"`. | | `LINGODOTDEV_API_KEY` | When using `"lingo.dev"` models | API key for the Lingo.dev localization engine. Obtain via `npx lingo.dev@latest login`. | | `OPENAI_API_KEY` | When using `"openai:*"` models | OpenAI API key. | | `ANTHROPIC_API_KEY` | When using `"anthropic:*"` models | Anthropic API key. | | `GOOGLE_API_KEY` | When using `"google:*"` models | Google AI API key. | | `GROQ_API_KEY` | When using `"groq:*"` models | Groq API key. | | `MISTRAL_API_KEY` | When using `"mistral:*"` models | Mistral API key. | | `OPENROUTER_API_KEY` | When using `"openrouter:*"` models | OpenRouter API key. | ## Complete example {% tabs %} {% tab label="Next.js" %} ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", lingoDir: ".lingo", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], useDirective: false, models: { "*:*": "lingo.dev", "*:ja": "anthropic:claude-3-5-sonnet", }, prompt: "Translate UI text from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep it concise.", buildMode: "translate", dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, localePersistence: { type: "cookie", config: { name: "locale", maxAge: 31536000, }, }, pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, }); } ``` {% /tab %} {% tab label="Vite + React" %} ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", lingoDir: ".lingo", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], useDirective: false, models: { "*:*": "lingo.dev", "*:ja": "anthropic:claude-3-5-sonnet", }, prompt: "Translate UI text from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep it concise.", buildMode: "translate", dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, localePersistence: { type: "cookie", config: { name: "locale", maxAge: 31536000, }, }, pluralization: { enabled: true, model: "groq:llama-3.1-8b-instant", }, }), react(), ], }); ``` {% /tab %} {% /tabs %} ## Next Steps {% card-grid %} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="All supported LLM providers and locale-pair mapping" icon="plug" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="Implement custom locale detection" icon="gear" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Recommended patterns for production" icon="book" /%} {% /card-grid %} - [Custom Locale Resolvers](https://lingo.dev/en/docs/react/compiler/custom-locale-resolvers): Override default locale detection and persistence by creating custom resolver files in the .lingo/ directory - supports URL-based, cookie, localStorage, and header-based patterns. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Custom locale resolvers let you override how the Lingo.dev Compiler detects and persists the user's locale. By default, the compiler uses cookie-based persistence configured via the `localePersistence` option. For more control - URL-based routing, header detection, localStorage, or any custom logic - create resolver files in the `.lingo/` directory. ## Resolver files The compiler looks for two optional files: | File | Environment | Exports | | --- | --- | --- | | `.lingo/locale-resolver.server.ts` | Server-side (SSR, RSC) | `resolveLocale(request: Request): string` | | `.lingo/locale-resolver.client.ts` | Client-side (browser) | `resolveLocale(): string` and `persistLocale(locale: string): void` | If a resolver file exists, the compiler uses it instead of the default cookie-based behavior. If only one file exists, the other environment falls back to the default. ## Server-side resolver The server resolver receives the incoming `Request` object and returns a locale code string: ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const url = new URL(request.url); // Check URL path prefix: /es/about -> "es" const pathLocale = url.pathname.split("/")[1]; const supportedLocales = ["en", "es", "de", "fr", "ja"]; if (supportedLocales.includes(pathLocale)) { return pathLocale; } // Fall back to Accept-Language header const acceptLanguage = request.headers.get("Accept-Language"); if (acceptLanguage) { const preferred = acceptLanguage.split(",")[0].split("-")[0]; if (supportedLocales.includes(preferred)) { return preferred; } } return "en"; } ``` ## Client-side resolver The client resolver has two functions: one to read the current locale and one to persist a locale change: ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { // Check URL path prefix const pathLocale = window.location.pathname.split("/")[1]; const supportedLocales = ["en", "es", "de", "fr", "ja"]; if (supportedLocales.includes(pathLocale)) { return pathLocale; } // Fall back to localStorage const stored = localStorage.getItem("locale"); if (stored && supportedLocales.includes(stored)) { return stored; } return "en"; } export function persistLocale(locale: string): void { localStorage.setItem("locale", locale); // Navigate to the locale-prefixed URL const path = window.location.pathname.replace(/^\/[a-z]{2}/, ""); window.location.href = `/${locale}${path}`; } ``` ## Common resolver patterns {% tabs %} {% tab label="URL-based" %} Route by URL path prefix (`/es/about`, `/de/pricing`): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const url = new URL(request.url); const locale = url.pathname.split("/")[1]; const supported = ["en", "es", "de", "fr"]; return supported.includes(locale) ? locale : "en"; } ``` ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { const locale = window.location.pathname.split("/")[1]; const supported = ["en", "es", "de", "fr"]; return supported.includes(locale) ? locale : "en"; } export function persistLocale(locale: string): void { const path = window.location.pathname.replace(/^\/[a-z]{2}/, ""); window.location.href = `/${locale}${path}`; } ``` {% /tab %} {% tab label="Cookie-based" %} Use a custom cookie name or logic (the default behavior, reimplemented for customization): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const cookies = request.headers.get("Cookie") || ""; const match = cookies.match(/user_language=([a-z-]+)/i); return match ? match[1] : "en"; } ``` ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { const match = document.cookie.match(/user_language=([a-z-]+)/i); return match ? match[1] : "en"; } export function persistLocale(locale: string): void { document.cookie = `user_language=${locale};path=/;max-age=31536000`; window.location.reload(); } ``` {% /tab %} {% tab label="Header-based" %} Detect locale from a custom header (set by a reverse proxy or CDN): ```ts // .lingo/locale-resolver.server.ts export function resolveLocale(request: Request): string { const locale = request.headers.get("X-User-Locale"); const supported = ["en", "es", "de", "fr"]; return locale && supported.includes(locale) ? locale : "en"; } ``` {% /tab %} {% /tabs %} {% callout type="warning" %} The `resolveLocale` function must return a locale code that matches one of your configured `targetLocales` or `sourceLocale`. Returning an unsupported locale code causes the compiler to fall back to the source locale. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Build a language switcher component" icon="globe" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="localePersistence options" icon="gear" /%} {% link-card title="Project Structure" href="/docs/react/compiler/project-structure" description="The .lingo/ directory layout" icon="file-code" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Server-side locale resolution in Next.js" icon="code" /%} {% /card-grid %} - [Development Tools](https://lingo.dev/en/docs/react/compiler/development-tools): Development tools included with the Lingo.dev Compiler - pseudotranslator for instant fake translations, local translation server for on-demand generation, and the upcoming dev widget. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler includes development tools that make it fast to iterate on multilingual UI without calling external APIs. These tools help you verify that all text is translatable, test layout with varying text lengths, and debug translation issues during development. ## Pseudotranslator The pseudotranslator generates instant fake translations by wrapping source text in visual markers. No API key is needed, no network calls are made, and results appear immediately. Enable it in your compiler config: ```ts { dev: { usePseudotranslator: true, }, } ``` ### What it produces | Source text | Pseudotranslation | | --- | --- | | `Welcome` | `[!!! Welcome !!!]` | | `Sign in to your account` | `[!!! Sign in to your account !!!]` | | `Items: {count}` | `[!!! Items: {count} !!!]` | The markers (`[!!! ... !!!]`) make translated text visually distinct from untranslated text. If you see raw English in the UI while pseudotranslator is enabled, that text is not being processed by the compiler. ### Use cases {% steps %} {% step title="Identify untranslated strings" %} Run your app with pseudotranslator enabled. Any text that appears without the `[!!! ... !!!]` markers is not being detected by the compiler. This happens when text is stored in variables outside JSX, or when a component is outside the `sourceRoot` directory. {% /step %} {% step title="Test layout with longer text" %} Pseudotranslations are longer than the source text (due to the marker characters). This simulates languages like German or French that typically produce 20-30% longer text than English, revealing layout overflow issues early. {% /step %} {% step title="Verify interpolation" %} Placeholders like `{count}` and `{name}` should appear inside the pseudotranslation markers. If a placeholder appears outside the markers or is missing, the compiler may not be preserving it correctly. {% /step %} {% /steps %} {% callout type="info" %} The pseudotranslator respects the same translation pipeline as real providers - it processes the same AST analysis and code injection steps. The only difference is the translation generation step, where markers replace the LLM call. {% /callout %} ## Translation server During development, the compiler runs a local translation server that handles on-demand translation requests. The server starts automatically when you run `npm run dev`. ### How it works The translation server listens on a local port and handles translation requests from the dev build pipeline. When a new or changed string is detected, the compiler sends it to the server, which routes it to the configured translation provider (or pseudotranslator). ### Port configuration The server auto-finds an available port in a configurable range: ```ts { dev: { translationServerStartPort: 60000, }, } ``` | Option | Default | Description | | --- | --- | --- | | `translationServerStartPort` | `60000` | Starting port number. The server tries ports sequentially (60000, 60001, ..., 60099) until it finds one available. | | `translationServerUrl` | auto-detected | Override the server URL entirely. Useful for connecting to a remote translation server or custom proxy. | {% callout type="warning" %} If all ports in the range 60000-60099 are occupied, the server fails to start. See [Troubleshooting](/docs/react/compiler/troubleshooting) for how to resolve port conflicts. {% /callout %} ## Dev widget (coming soon) An in-browser translation editor that lets you view and edit translations in real time while navigating your app. The widget overlays your UI and shows translation details for each text element. Planned features: - Click any text element to see its source text, translations, and metadata - Edit translations directly in the browser - Changes save to `.lingo/metadata.json` immediately - Toggle between locales without reloading {% callout type="info" title="Status" %} The dev widget is under development and not yet available. Follow the [changelog](/changelog) for release updates. {% /callout %} ## Recommended dev configuration For the fastest development experience, combine pseudotranslator with the default translation server settings: ```ts { dev: { usePseudotranslator: true, translationServerStartPort: 60000, }, } ``` When you are ready to preview real translations, disable the pseudotranslator and restart the dev server: ```ts { dev: { usePseudotranslator: false, }, } ``` The compiler then generates real translations for new or changed strings using your configured [translation provider](/docs/react/compiler/translation-providers). ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All dev options" icon="gear" /%} {% link-card title="Troubleshooting" href="/docs/react/compiler/troubleshooting" description="Port conflicts and other dev issues" icon="book" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Recommended dev workflow" icon="lightning" /%} {% /card-grid %} - [Locale Switching](https://lingo.dev/en/docs/react/compiler/locale-switching): Build a language switcher using the useLingoContext() hook - access the current locale, change it with setLocale, and configure persistence via cookies or custom resolvers. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler provides the `useLingoContext()` hook for reading and changing the active locale at runtime. Use it to build language switchers, locale-aware components, or any UI that responds to the user's language preference. ## useLingoContext() hook The hook returns an object with the current locale and a function to change it: ```tsx import { useLingoContext } from "@lingo.dev/compiler/react"; const { locale, setLocale } = useLingoContext(); ``` | Property | Type | Description | | --- | --- | --- | | `locale` | `string` | The active locale code (e.g., `"en"`, `"es"`). | | `setLocale` | `(locale: string) => void` | Sets the new locale. Triggers persistence and a page reload by default. | ## Language switcher example A dropdown language switcher component: ```tsx "use client"; // Required for Next.js App Router import { useLingoContext } from "@lingo.dev/compiler/react"; const localeLabels: Record = { en: "English", es: "Espanol", de: "Deutsch", fr: "Francais", ja: "日本語", }; export function LanguageSwitcher() { const { locale, setLocale } = useLingoContext(); return ( ); } ``` {% callout type="info" %} In Next.js, the language switcher must be a Client Component (`"use client"`) because it uses a React hook. {% /callout %} ## What happens when setLocale is called {% steps %} {% step title="Locale is persisted" %} By default, the new locale is saved to a cookie named `locale` with a max-age of 1 year. This ensures the preference survives page reloads and browser restarts. {% /step %} {% step title="Page reloads" %} The page reloads to re-render all components with the new locale. Server Components fetch translations for the new locale on the server, and Client Components receive the updated dictionary. {% /step %} {% step title="Subsequent requests use the new locale" %} On the next page load, the compiler reads the persisted locale and serves the corresponding translations. {% /step %} {% /steps %} ## Persistence options The default persistence mechanism is cookie-based, configured via `localePersistence`: ```ts { localePersistence: { type: "cookie", config: { name: "locale", // Cookie name maxAge: 31536000, // 1 year in seconds }, }, } ``` | Option | Default | Description | | --- | --- | --- | | `type` | `"cookie"` | Persistence mechanism. | | `config.name` | `"locale"` | Cookie name. | | `config.maxAge` | `31536000` | Cookie lifetime in seconds. | ## Custom persistence For URL-based locale routing, localStorage, or custom header-based detection, create custom locale resolvers. The `persistLocale` export in your client resolver controls what happens when `setLocale` is called: ```ts // .lingo/locale-resolver.client.ts export function resolveLocale(): string { return localStorage.getItem("locale") || "en"; } export function persistLocale(locale: string): void { localStorage.setItem("locale", locale); window.location.reload(); } ``` See [Custom Locale Resolvers](/docs/react/compiler/custom-locale-resolvers) for full examples of URL-based, cookie-based, and header-based patterns. ## Reading locale without switching If you need the current locale for display or conditional rendering without providing a switcher, use the same hook: ```tsx "use client"; import { useLingoContext } from "@lingo.dev/compiler/react"; export function LocaleBadge() { const { locale } = useLingoContext(); return {locale.toUpperCase()}; } ``` ## Next Steps {% card-grid %} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="URL-based, localStorage, and header-based persistence" icon="gear" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="localePersistence options" icon="gear" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Server and Client Component behavior" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Client-side locale switching" icon="code" /%} {% /card-grid %} - [Manual Overrides](https://lingo.dev/en/docs/react/compiler/manual-overrides): Use the data-lingo-override attribute to provide hand-crafted translations for specific elements - overrides take precedence over AI-generated translations. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The `data-lingo-override` attribute gives you precise control over specific translations. When you need an exact translation for a brand name, legal text, or marketing headline, add the attribute to any JSX element and the compiler uses your provided translations instead of generating them with AI. ## Basic usage Pass an object mapping locale codes to translations: ```tsx

Welcome

``` The compiler uses the override value for each specified locale. For locales not listed in the override object, the compiler generates translations normally. ## How overrides work {% steps %} {% step title="Compiler encounters a JSX element with data-lingo-override" %} During the AST analysis phase, the compiler detects the `data-lingo-override` attribute on the element. {% /step %} {% step title="Override values are extracted" %} The locale-to-translation mapping is read from the attribute value. {% /step %} {% step title="Overrides take precedence" %} For each locale present in the override object, the compiler uses the provided translation. AI translation is skipped for those locales. Locales not in the override are translated normally. {% /step %} {% /steps %} ## Use cases | Use case | Why override | Example | | --- | --- | --- | | Brand names | AI may localize names that should stay consistent across languages | `data-lingo-override={{ es: "Lingo.dev", de: "Lingo.dev" }}` | | Marketing copy | Specific phrasing crafted by a copywriter | `data-lingo-override={{ es: "Tu motor de localizacion" }}` | | Legal text | Regulatory requirements demand exact wording | `data-lingo-override={{ de: "Datenschutzerklarung" }}` | | Idioms and puns | Wordplay that requires human creativity | `data-lingo-override={{ fr: "C'est la vie" }}` | | UI with strict character limits | AI translations may exceed space constraints | `data-lingo-override={{ ja: "OK" }}` | ## Examples ### Paragraph text ```tsx

Create a localization engine on Lingo.dev

``` ### Attributes Overrides apply to the text content of the element. For translatable attributes like `placeholder`, `alt`, or `aria-label`, the compiler handles them separately through its standard attribute translation pipeline. ### Partial overrides You do not need to provide overrides for every target locale. Supply only the locales that need manual control: ```tsx

Getting Started

``` In this example, Japanese uses the override while all other target locales receive AI-generated translations. ## When to use overrides vs. other approaches | Approach | When to use | | --- | --- | | `data-lingo-override` | Specific elements where you know the exact translation. | | [Glossary](/docs/platform/glossaries) (Lingo.dev Engine) | Terms that should be translated consistently across the entire app. | | [Brand Voice](/docs/platform/brand-voices) (Lingo.dev Engine) | Tone and style preferences that apply to all translations. | | [Custom prompts](/docs/react/compiler/translation-providers) | General translation instructions for all content. | {% callout type="info" %} Overrides are the most granular option - they apply to a single element. For project-wide consistency, use a glossary or brand voice through the Lingo.dev localization engine instead. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Translation Providers" href="/docs/react/compiler/translation-providers" description="Custom prompts and locale-pair mapping" icon="plug" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" description="Project-wide term consistency" icon="book" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="When and how to use overrides" icon="lightning" /%} {% /card-grid %} - [Migration Guide](https://lingo.dev/en/docs/react/compiler/migration-guide): Migrate from lingo.dev/compiler to @lingo.dev/compiler - updated package name, simplified imports, async Next.js config, Vite plugin, and new .lingo/ directory. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} This guide covers migrating from the previous `lingo.dev` compiler package to the current `@lingo.dev/compiler` package. The new package introduces a scoped npm name, simplified API, plugin-based Vite integration, and a new `.lingo/` directory for metadata. ## Summary of changes | Area | Before (`lingo.dev`) | After (`@lingo.dev/compiler`) | | --- | --- | --- | | Package name | `lingo.dev` | `@lingo.dev/compiler` | | Next.js integration | Direct config modification | `withLingo()` async wrapper | | Vite integration | Manual setup | `lingoCompilerPlugin` | | LingoProvider | Required `loadDictionary` prop | No props needed | | Metadata directory | `lingo/` | `.lingo/` | | Opt-in directive | `'use i18n'` required | Optional (default: translate all) | | Imports | `from "lingo.dev/react"` | `from "@lingo.dev/compiler/react"` | ## Step-by-step migration {% steps %} {% step title="Replace the package" %} Remove the old package and install the new one: ```bash npm uninstall lingo.dev npm install @lingo.dev/compiler ``` {% /step %} {% step title="Update imports" %} Replace all import paths: {% tabs %} {% tab label="React components" %} ```ts // Before import { LingoProvider, useLingoContext } from "lingo.dev/react"; // After import { LingoProvider, useLingoContext } from "@lingo.dev/compiler/react"; ``` {% /tab %} {% tab label="Next.js config" %} ```ts // Before import { withLingo } from "lingo.dev/next"; // After import { withLingo } from "@lingo.dev/compiler/next"; ``` {% /tab %} {% tab label="Vite config" %} ```ts // Before (manual setup) // No standard import - varied by project // After import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; ``` {% /tab %} {% /tabs %} {% /step %} {% step title="Update Next.js config (if applicable)" %} The Next.js config must now be an async function: ```ts // Before import { withLingo } from "lingo.dev/next"; const nextConfig = {}; export default withLingo(nextConfig, { /* options */ }); // After import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", }); } ``` {% callout type="warning" %} The async function wrapper is required. A synchronous export will cause the build to fail. See [Next.js Integration](/docs/react/compiler/nextjs) for details. {% /callout %} {% /step %} {% step title="Update Vite config (if applicable)" %} Replace any manual setup with the `lingoCompilerPlugin`: ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", }), react(), // Must come AFTER lingoCompilerPlugin ], }); ``` {% /step %} {% step title="Simplify LingoProvider" %} The `loadDictionary` prop is no longer needed. The compiler handles dictionary loading automatically: ```tsx // Before import { LingoProvider } from "lingo.dev/react"; // After import { LingoProvider } from "@lingo.dev/compiler/react"; ``` {% /step %} {% step title="Move metadata directory" %} Rename the metadata directory from `lingo/` to `.lingo/`: ```bash mv lingo/ .lingo/ ``` Update your `.gitignore` if it references the old directory name. The `.lingo/` directory should be committed to version control. {% /step %} {% step title="Update 'use i18n' directives (optional)" %} In the new package, `'use i18n'` is optional. By default, all files in `sourceRoot` are translated. If you want to keep opt-in behavior, set `useDirective: true` in your config: ```ts { useDirective: true, // Keep requiring 'use i18n' in each file } ``` If you remove `useDirective` (or set it to `false`), you can also remove the `'use i18n'` directives from your files - all files in `sourceRoot` will be translated automatically. {% /step %} {% step title="Rebuild and verify" %} Run the dev server and verify translations appear: ```bash npm run dev ``` Check that: - The pseudotranslator produces `[!!! ... !!!]` markers (if enabled) - All previously translated strings still work - The `.lingo/metadata.json` file is created or updated {% /step %} {% /steps %} ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All new configuration options" icon="gear" /%} {% link-card title="Next.js Integration" href="/docs/react/compiler/nextjs" description="Next.js-specific migration details" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Vite-specific migration details" icon="code" /%} {% /card-grid %} - [Next.js Integration](https://lingo.dev/en/docs/react/compiler/nextjs): Integrate the Lingo.dev Compiler with Next.js App Router using the withLingo() config wrapper - supports React Server Components, Webpack, and Turbopack. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler integrates with Next.js App Router through a `withLingo()` config wrapper that transforms your build pipeline to produce per-locale bundles. It supports React Server Components, Webpack, and Turbopack with no changes to your component code. ## Prerequisites {% callout type="info" title="Requirements" %} - Next.js 14+ with App Router - Node.js 18+ - `@lingo.dev/compiler` installed {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure next.config.ts Wrap your Next.js config with `withLingo`. The config function must be `async` - this is required because `withLingo` performs asynchronous initialization during the build. ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }); } ``` {% callout type="warning" title="Async config required" %} The config must be exported as an `async` function, not as a plain object. If you export a plain object, the compiler cannot initialize and the build will fail. See [Troubleshooting](/docs/react/compiler/troubleshooting) for details. {% /callout %} ## Add LingoProvider Wrap your root layout with `LingoProvider` to enable locale context throughout the component tree: ```tsx // app/layout.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` `LingoProvider` handles locale resolution, persistence, and dictionary loading. It works with both Server Components and Client Components. ## Server Components and Client Components The compiler handles both component types transparently: | Component type | How translations work | | --- | --- | | React Server Components | Translations resolved at request time on the server. No client-side JS overhead. | | Client Components (`"use client"`) | Translations bundled into the client chunk. `useLingoContext()` available for locale switching. | | Shared components | Work in both contexts. The compiler detects the rendering environment automatically. | ```tsx // app/page.tsx - Server Component (default) export default function Home() { return

Welcome to our app

; // Renders translated text with zero client JS } ``` ```tsx // app/components/greeting.tsx - Client Component "use client"; export function Greeting() { return

Hello, world

; // Translations included in client bundle } ``` ## Bundler support The `withLingo()` wrapper works with both bundlers supported by Next.js: | Bundler | Support | Notes | | --- | --- | --- | | Webpack | Full | Default bundler. No additional configuration needed. | | Turbopack | Full | Enable with `next dev --turbopack`. The compiler detects Turbopack automatically. | ## sourceRoot configuration The `sourceRoot` option tells the compiler which directory contains your translatable components. For Next.js App Router projects, this is typically `./app`: ```ts { sourceRoot: "./app", } ``` If you have components outside `./app` (such as a shared `components/` directory), set `sourceRoot` to the common parent: ```ts { sourceRoot: ".", } ``` {% callout type="info" %} A broader `sourceRoot` means more files are scanned. For large projects, keep it as narrow as possible to reduce build times. Alternatively, use `useDirective: true` and add `'use i18n'` only to files that need translation. See [Project Structure](/docs/react/compiler/project-structure) for details. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough with authentication" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Add a language switcher to your app" icon="globe" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% /card-grid %} - [Optimization](https://lingo.dev/en/docs/react/compiler/optimization): Advanced compiler optimization techniques including tree shaking to reduce translation bundle size by 40-60%. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Advanced compiler optimization techniques. ## Tree Shaking The compiler analyzes usage to remove unused translations: ```typescript // Only bundle what you actually use import { t } from "./i18n"; t("welcome.title"); // ✅ Included // 'auth.login' never used → ❌ Not included in bundle ``` {% callout type="success" title="Bundle Size Savings" %} Typical projects see 40-60% reduction in translation bundle size. {% /callout %} - [Output Formats](https://lingo.dev/en/docs/react/compiler/output-formats): The Lingo.dev Compiler supports multiple output formats including TypeScript, JavaScript ESM, and JSON. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The compiler supports multiple output formats. ## TypeScript ```typescript export const translations = { welcome: "Welcome", } as const; ``` ## JavaScript (ESM) ```javascript export const translations = { welcome: "Welcome", }; ``` ## JSON ```json { "welcome": "Welcome" } ``` {% callout type="info" %} Choose the format that best fits your build pipeline. {% /callout %} - [Project Structure](https://lingo.dev/en/docs/react/compiler/project-structure): How the Lingo.dev Compiler organizes translation files - the .lingo/ directory, metadata.json cache, sourceRoot scanning, and the opt-in 'use i18n' directive mode. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler creates and maintains a `.lingo/` directory in your project root that stores translation metadata and cache. Understanding this directory structure helps you manage translations in version control, debug missing translations, and optimize build performance. ## The .lingo/ directory The compiler creates this directory automatically on the first build. It contains all translation metadata used by the build pipeline: ``` .lingo/ metadata.json # Translation cache and content hashes locale-resolver.server.ts # Optional: custom server-side locale resolver locale-resolver.client.ts # Optional: custom client-side locale resolver ``` ### metadata.json This is the primary file in the `.lingo/` directory. It stores: - **Content hashes** - Stable hash-based identifiers for each translatable string - **Cached translations** - Generated translations for each locale pair - **Source text snapshots** - The source text at the time of translation, used to detect changes The compiler reads this file at the start of each build. Strings with matching hashes reuse cached translations. Strings with changed or missing hashes are sent to the configured translation provider. {% callout type="warning" title="Commit to version control" %} Always commit `.lingo/metadata.json` to your repository. Production builds in `cache-only` mode read translations exclusively from this file. If it is not committed, production builds will fail. {% /callout %} ### .gitignore considerations Do **not** add `.lingo/` to `.gitignore`. The directory should be tracked in version control. A typical `.gitignore` for a project using the compiler: ```gitignore # Do NOT ignore .lingo/ - it contains translation cache node_modules/ dist/ .env ``` ## sourceRoot The `sourceRoot` option determines which directory the compiler scans for translatable React components: ```ts { sourceRoot: "./app", // Next.js App Router // or sourceRoot: "src", // Vite + React } ``` The compiler recursively scans all `.tsx`, `.ts`, `.jsx`, and `.js` files within `sourceRoot` for translatable JSX content. Files outside this directory are not processed. | sourceRoot value | What gets scanned | | --- | --- | | `"./app"` | All files in the `app/` directory (Next.js convention) | | `"src"` | All files in the `src/` directory (Vite convention) | | `"."` | All files in the project root (useful for monorepos with shared packages) | {% callout type="info" %} A broader `sourceRoot` scans more files, which increases build time. Keep it as narrow as possible. If only some files need translation, use the `useDirective` option instead. {% /callout %} ## Opt-in mode with 'use i18n' By default, the compiler translates all JSX text in `sourceRoot`. To switch to opt-in mode, set `useDirective: true`: ```ts { useDirective: true, } ``` In opt-in mode, only files that start with the `'use i18n'` directive are processed: ```tsx 'use i18n'; export function Welcome() { return

Welcome to our app

; // This text IS translated } ``` Files without the directive are skipped: ```tsx export function InternalAdmin() { return

Admin Dashboard

; // This text is NOT translated } ``` ### When to use opt-in mode | Scenario | Recommended mode | | --- | --- | | Small app where all content should be translated | Default (`useDirective: false`) | | Large codebase with only some user-facing pages | Opt-in (`useDirective: true`) | | Monorepo with shared internal and external components | Opt-in (`useDirective: true`) | | Gradual adoption - adding i18n to an existing app | Opt-in (`useDirective: true`) | ## lingoDir The `lingoDir` option changes the location of the metadata directory: ```ts { lingoDir: ".lingo", // Default // or lingoDir: ".translations", // Custom location } ``` This is useful if `.lingo/` conflicts with an existing directory in your project. ## Next Steps {% card-grid %} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="How metadata.json is used in each mode" icon="terminal" /%} {% link-card title="Custom Locale Resolvers" href="/docs/react/compiler/custom-locale-resolvers" description="Add resolver files to .lingo/" icon="gear" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="sourceRoot, lingoDir, and useDirective options" icon="gear" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Version control and project setup tips" icon="book" /%} {% /card-grid %} - [Compiler Quick Start](https://lingo.dev/en/docs/react/compiler/quick-start): Get started with the Lingo.dev Compiler in three steps: install, configure, and compile your translations for production. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Compile your translations in 3 steps. ## Install ```bash npm install -D @lingo.dev/compiler ``` ## Configure ```javascript // lingo.compiler.js export default { input: "./locales", output: "./src/i18n", format: "typescript", optimize: true, }; ``` ## Compile ```bash lingo compile ``` {% callout type="success" %} Your translations are now compiled and optimized for production! {% /callout %} - [Setup](https://lingo.dev/en/docs/react/compiler/setup): Add multilingual support to a React app in under 5 minutes - install the compiler, configure your framework, add LingoProvider, and generate your first translations. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Add multilingual support to your React application in under 5 minutes. {% callout type="info" title="Prerequisites" %} Node.js 18+ and a React application using Next.js (App Router) or Vite. {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure your framework {% tabs %} {% tab label="Next.js" %} Make your config async and wrap it with `withLingo`: ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }); } ``` {% /tab %} {% tab label="Vite + React" %} Add the Lingo plugin to your Vite config (before the React plugin): ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }), react(), ], }); ``` {% /tab %} {% /tabs %} ## Add LingoProvider {% tabs %} {% tab label="Next.js" %} ```tsx // app/layout.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` {% /tab %} {% tab label="Vite + React" %} ```tsx // src/main.tsx import { LingoProvider } from "@lingo.dev/compiler/react"; createRoot(document.getElementById("root")!).render( ); ``` {% /tab %} {% /tabs %} ## Authenticate {% tabs %} {% tab label="Lingo.dev Engine (recommended)" %} ```bash npx lingo.dev@latest login ``` This opens your browser for authentication. The free Hobby tier works for most projects. If browser auth is blocked, add the key to `.env` manually: ```bash LINGODOTDEV_API_KEY=your_key_here ``` {% /tab %} {% tab label="Direct LLM provider" %} Configure the provider in your compiler config: ```ts { models: { "*:*": "groq:llama-3.3-70b-versatile" } } ``` Add the API key to `.env`: ```bash GROQ_API_KEY=your_key ``` See [Translation Providers](/docs/react/compiler/translation-providers) for all supported providers. {% /tab %} {% /tabs %} ## Run the dev server ```bash npm run dev ``` The compiler scans your JSX, generates pseudotranslations (instant fake translations to visualize what gets translated), and injects them into your components. Metadata is stored in `.lingo/metadata.json` - commit this to version control. ## Add a language switcher (optional) ```tsx "use client"; // For Next.js import { useLingoContext } from "@lingo.dev/compiler/react"; export function LanguageSwitcher() { const { locale, setLocale } = useLingoContext(); return ( ); } ``` ## Generate real translations When ready, disable pseudotranslator: ```ts { dev: { usePseudotranslator: false, } } ``` Restart the dev server. The compiler generates real AI translations for new or changed text. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/compiler" description="The build-time transformation pipeline" icon="book" /%} {% link-card title="Next.js" href="/docs/react/compiler/nextjs" description="Next.js-specific setup and features" icon="code" /%} {% link-card title="Vite + React" href="/docs/react/compiler/vite-react" description="Vite-specific setup and features" icon="code" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% /card-grid %} - [Translation Providers](https://lingo.dev/en/docs/react/compiler/translation-providers): Configure translation providers for the Lingo.dev Compiler - use the Lingo.dev localization engine, direct LLM providers like OpenAI and Anthropic, or local models via Ollama. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler supports multiple translation providers, from the managed Lingo.dev localization engine to direct LLM provider connections and local models. You configure providers through the `models` option, which accepts either a single provider string or an object mapping locale pairs to specific providers. ## Lingo.dev Engine (recommended) The Lingo.dev localization engine is the default provider. It routes translations through a managed pipeline with dynamic model selection, automatic fallbacks, glossary enforcement, and brand voice profiles. ```ts { models: "lingo.dev", } ``` Authenticate via CLI: ```bash npx lingo.dev@latest login ``` Or set the API key in `.env`: ```bash LINGODOTDEV_API_KEY=your_key_here ``` {% callout type="success" title="Why use the Lingo.dev engine" %} The localization engine selects the optimal model per locale pair, applies your [glossary](/docs/platform/glossaries) and [brand voice](/docs/platform/brand-voices) rules, and falls back to alternative models if a provider is unavailable. Direct LLM providers do not include these features. {% /callout %} ## Direct LLM providers Connect directly to any supported LLM provider by specifying a `provider:model` string: | Provider | Model format | Environment variable | Example | | --- | --- | --- | --- | | OpenAI | `openai:` | `OPENAI_API_KEY` | `openai:gpt-4o` | | Anthropic | `anthropic:` | `ANTHROPIC_API_KEY` | `anthropic:claude-3-5-sonnet` | | Google | `google:` | `GOOGLE_API_KEY` | `google:gemini-2.0-flash` | | Groq | `groq:` | `GROQ_API_KEY` | `groq:llama-3.3-70b-versatile` | | Mistral | `mistral:` | `MISTRAL_API_KEY` | `mistral:mistral-large` | | OpenRouter | `openrouter:` | `OPENROUTER_API_KEY` | `openrouter:anthropic/claude-3.5-sonnet` | | Ollama | `ollama:` | None (local) | `ollama:llama3.2` | ### Single provider for all locales Set a string to use one provider for every locale pair: ```ts { models: "openai:gpt-4o", } ``` ### Ollama (local models) Ollama runs models locally with no API key required. Install [Ollama](https://ollama.com), pull a model, and configure: ```ts { models: "ollama:llama3.2", } ``` {% callout type="info" %} Local models are useful for offline development and for teams that cannot send content to external APIs. Translation quality varies by model size - larger models produce more accurate results. {% /callout %} ## Locale-pair mapping The `models` option accepts an object to route specific locale pairs to different providers. Keys use the format `source:target` with wildcard (`*`) support: ```ts { models: { "*:*": "lingo.dev", // Default for all pairs "*:ja": "anthropic:claude-3-5-sonnet", // Japanese via Anthropic "*:zh-Hans": "anthropic:claude-3-5-sonnet", // Simplified Chinese via Anthropic "en:de": "openai:gpt-4o", // English-to-German via OpenAI }, } ``` The compiler matches locale pairs from most specific to least specific: {% steps %} {% step title="Exact match" %} `en:de` matches only English-to-German translations. {% /step %} {% step title="Target wildcard" %} `*:ja` matches any source language translating to Japanese. {% /step %} {% step title="Full wildcard" %} `*:*` is the fallback for any pair without a more specific match. {% /step %} {% /steps %} This mapping lets you optimize for cost and quality. For example, use a fast model for European languages and a model with stronger CJK support for East Asian locales. ## Custom prompts The `prompt` option sets a system prompt for the translation LLM. Use `{SOURCE_LOCALE}` and `{TARGET_LOCALE}` as placeholders - the compiler replaces them with the actual locale codes at translation time: ```ts { prompt: "You are translating a SaaS application UI from {SOURCE_LOCALE} to {TARGET_LOCALE}. Keep translations concise. Preserve technical terms in English. Use formal register.", } ``` {% callout type="info" %} Custom prompts apply to direct LLM providers only. When using the Lingo.dev localization engine, configure [instructions](/docs/platform/instructions) and [brand voice](/docs/platform/brand-voices) through the Lingo.dev dashboard instead. {% /callout %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options in one place" icon="gear" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Dev, CI, and production workflows" icon="terminal" /%} {% link-card title="Best Practices" href="/docs/react/compiler/best-practices" description="Cost optimization and model selection tips" icon="book" /%} {% link-card title="Lingo.dev Engines" href="/docs/platform/engines" description="Configure a localization engine on Lingo.dev" icon="plug" /%} {% /card-grid %} - [Troubleshooting](https://lingo.dev/en/docs/react/compiler/troubleshooting): Solutions for common Lingo.dev Compiler issues - missing modules, async config errors, missing translations, pseudotranslator artifacts, port conflicts, and slow builds. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} Solutions for common issues when using the Lingo.dev Compiler. Each section describes the symptom, cause, and fix. ## Installation issues {% accordion title="Cannot find module '@lingo.dev/compiler'" %} **Symptom:** Build fails with `Cannot find module '@lingo.dev/compiler'` or `Cannot find module '@lingo.dev/compiler/next'`. **Cause:** The package is not installed, or your package manager has not resolved it correctly. **Fix:** ```bash # Remove node_modules and reinstall rm -rf node_modules npm install @lingo.dev/compiler npm install ``` If you are migrating from the old package name, make sure to uninstall it first: ```bash npm uninstall lingo.dev npm install @lingo.dev/compiler ``` See [Migration Guide](/docs/react/compiler/migration-guide) for the full migration procedure. {% /accordion %} ## Configuration issues {% accordion title="Config must be async function (Next.js)" %} **Symptom:** Build fails with an error about the Next.js config not being an async function, or `withLingo` returns a Promise that is not awaited. **Cause:** `withLingo` is an async function that performs initialization. Next.js requires the config export to be an async function when using async operations. **Fix:** Wrap your config in an async function: ```ts // next.config.ts import type { NextConfig } from "next"; import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = {}; // Must be async export default async function (): Promise { return await withLingo(nextConfig, { sourceRoot: "./app", sourceLocale: "en", targetLocales: ["es", "de"], }); } ``` {% /accordion %} {% accordion title="Plugin order error (Vite)" %} **Symptom:** Translations are not generated. The compiler reports that no translatable strings were found, even though your components contain text. **Cause:** The `lingoCompilerPlugin` is placed after the `react()` plugin in the Vite config. The React plugin transforms JSX before the compiler can analyze it. **Fix:** Move `lingoCompilerPlugin` before `react()`: ```ts // vite.config.ts export default defineConfig({ plugins: [ lingoCompilerPlugin({ /* ... */ }), // BEFORE react() react(), ], }); ``` {% /accordion %} ## Translation issues {% accordion title="Translations not showing in the UI" %} **Symptom:** The app renders source language text even though translations should be available. **Possible causes and fixes:** | Cause | Fix | | --- | --- | | `LingoProvider` is missing | Wrap your root component in ``. See [Next.js](/docs/react/compiler/nextjs) or [Vite + React](/docs/react/compiler/vite-react). | | `.lingo/metadata.json` is empty or missing | Run a build in `translate` mode to generate translations. Check that `.lingo/` exists. | | `buildMode` is `cache-only` with no cache | Switch to `translate` mode or run a CI build to populate the cache. | | Text is outside `sourceRoot` | Move the component into `sourceRoot` or broaden the `sourceRoot` path. | | `useDirective: true` but file lacks `'use i18n'` | Add `'use i18n'` at the top of the file. | | Text is in a variable, not JSX | Move text directly into JSX. The compiler only detects text in JSX elements. | {% /accordion %} {% accordion title="Seeing [!!! ... !!!] in translations" %} **Symptom:** The UI shows text like `[!!! Welcome !!!]` instead of real translations. **Cause:** The pseudotranslator is enabled. This is expected behavior during development - pseudotranslations help you identify which strings are being processed. **Fix:** Disable the pseudotranslator to generate real translations: ```ts { dev: { usePseudotranslator: false, }, } ``` Restart the dev server after changing this option. The compiler will call the configured translation provider for any new or changed strings. {% /accordion %} {% accordion title="Missing translations in production build" %} **Symptom:** Production build fails with errors about missing translations, or some strings render in the source language. **Cause:** The `.lingo/metadata.json` file is missing from the repository, or not all strings were translated before the production build. **Fix:** {% steps %} {% step title="Verify .lingo/ is committed" %} Check that `.lingo/metadata.json` exists in your repository and is not in `.gitignore`. {% /step %} {% step title="Run translate mode in CI" %} Your CI pipeline should run a build in `translate` mode before the production build: ```bash LINGO_BUILD_MODE=translate npm run build ``` Commit the updated `.lingo/metadata.json` back to the repository. {% /step %} {% step title="Verify build mode in production" %} Ensure the production build uses `cache-only` mode: ```bash LINGO_BUILD_MODE=cache-only npm run build ``` {% /step %} {% /steps %} {% /accordion %} ## Performance issues {% accordion title="Build is slow" %} **Symptom:** Development builds take noticeably longer after adding the compiler. **Possible causes and fixes:** | Cause | Fix | | --- | --- | | Pseudotranslator is disabled in dev | Enable `dev.usePseudotranslator: true`. Real LLM calls are slower than instant pseudotranslations. | | `sourceRoot` is too broad | Narrow `sourceRoot` to the directory containing translatable components. | | Many new strings on each build | The compiler only translates new or changed strings. After the initial build, subsequent builds are incremental. | | Large number of target locales | Each locale pair requires a separate translation. Consider reducing target locales during development. | {% /accordion %} ## Port conflicts {% accordion title="Translation server port conflict" %} **Symptom:** The dev server fails to start, or logs show that the translation server cannot bind to a port. **Cause:** All ports in the range 60000-60099 are occupied by other processes. **Fix:** Change the starting port: ```ts { dev: { translationServerStartPort: 61000, // Try a different range }, } ``` Alternatively, check for processes using ports in the default range: ```bash lsof -i :60000-60099 ``` Kill any stale processes or choose a different port range. {% /accordion %} ## Next Steps {% card-grid %} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="Verify your configuration options" icon="gear" /%} {% link-card title="Build Modes" href="/docs/react/compiler/build-modes" description="Understand translate vs cache-only" icon="terminal" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and translation server" icon="code" /%} {% link-card title="Migration Guide" href="/docs/react/compiler/migration-guide" description="Migrating from the old package" icon="book" /%} {% /card-grid %} - [Vite + React](https://lingo.dev/en/docs/react/compiler/vite-react): Integrate the Lingo.dev Compiler with Vite and React using the lingoCompilerPlugin - full HMR support with translations injected at build time. {% callout type="warning" title="Alpha" %} The Lingo.dev Compiler is in alpha. It is unstable, not recommended for production use, and APIs may change between releases. {% /callout %} The Lingo.dev Compiler integrates with Vite through `lingoCompilerPlugin`, a Vite plugin that transforms your React components at build time to inject translations. It supports full Hot Module Replacement, so translations update instantly during development. ## Prerequisites {% callout type="info" title="Requirements" %} - Vite 5+ with React - Node.js 18+ - `@lingo.dev/compiler` installed {% /callout %} ## Install ```bash pnpm install @lingo.dev/compiler ``` ## Configure vite.config.ts Add `lingoCompilerPlugin` to your Vite config. The plugin must be placed **before** the `react()` plugin - this ordering is required because the compiler needs to transform JSX before the React plugin processes it. ```ts // vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { lingoCompilerPlugin } from "@lingo.dev/compiler/vite"; export default defineConfig({ plugins: [ lingoCompilerPlugin({ sourceRoot: "src", sourceLocale: "en", targetLocales: ["es", "de", "fr", "ja"], models: "lingo.dev", dev: { usePseudotranslator: true, }, }), react(), ], }); ``` {% callout type="error" title="Plugin order matters" %} If `lingoCompilerPlugin` is placed after `react()`, the React plugin processes JSX first and the compiler cannot identify translatable text. Always place the Lingo plugin first in the `plugins` array. {% /callout %} ## Add LingoProvider Wrap your application root with `LingoProvider` in your entry file: ```tsx // src/main.tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { LingoProvider } from "@lingo.dev/compiler/react"; import App from "./App"; createRoot(document.getElementById("root")!).render( ); ``` `LingoProvider` initializes the locale context and loads the appropriate translation dictionary for the active locale. ## Hot Module Replacement The plugin integrates with Vite's HMR system. When you edit translatable text in a component: {% steps %} {% step title="Edit source text" %} Change any text in your JSX - for example, update a heading from "Welcome" to "Welcome back". {% /step %} {% step title="Compiler detects the change" %} The plugin intercepts the HMR update, identifies the changed string, and generates a new translation (or pseudotranslation in dev mode). {% /step %} {% step title="Browser updates instantly" %} The translated component re-renders without a full page reload. Translation metadata in `.lingo/metadata.json` is updated on disk. {% /step %} {% /steps %} ## sourceRoot configuration The `sourceRoot` option determines which files the compiler scans for translatable text. For a standard Vite + React project: ```ts { sourceRoot: "src", } ``` | Project structure | Recommended sourceRoot | | --- | --- | | Standard (`src/`) | `"src"` | | Monorepo with shared packages | `"."` (project root) | | Custom directory | Path to your components directory | {% callout type="info" %} For large codebases, a narrow `sourceRoot` reduces build times. If you only need translations in specific files, enable `useDirective: true` and add `'use i18n'` to those files. See [Project Structure](/docs/react/compiler/project-structure). {% /callout %} ## Example project structure ``` my-vite-app/ src/ main.tsx # LingoProvider wraps App.tsx # Translatable components components/ Header.tsx # Automatically scanned Footer.tsx # Automatically scanned .lingo/ metadata.json # Translation cache (commit this) vite.config.ts # lingoCompilerPlugin configured here ``` ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/react/compiler/setup" description="Full setup walkthrough with authentication" icon="rocket" /%} {% link-card title="Configuration Reference" href="/docs/react/compiler/configuration-reference" description="All configuration options" icon="gear" /%} {% link-card title="Locale Switching" href="/docs/react/compiler/locale-switching" description="Add a language switcher to your app" icon="globe" /%} {% link-card title="Development Tools" href="/docs/react/compiler/development-tools" description="Pseudotranslator and dev server" icon="terminal" /%} {% /card-grid %} ## Docs – React/i18n - [Formatting](https://lingo.dev/en/docs/react/i18n/formatting): Locale-aware number, currency, percent, date, time, relative, list, and file-size formatters — thin wrappers over native Intl APIs. Every `Lingo` instance carries a set of formatting methods keyed to the active locale. They're thin wrappers around the native `Intl.*` APIs — no extra bundle weight, no opinionated defaults beyond the locale string you already provided to `LingoProvider`. All methods are pure: pass the same input and locale, get the same output. They're safe to call inside render without memoization. ## Numbers ### `l.num(value, options?)` Format a number with locale-aware grouping and decimals. ```tsx l.num(1234567); // "1,234,567" (en) / "1.234.567" (de) / "1 234 567" (fr) l.num(3.14159, { maximumFractionDigits: 2 }); // "3.14" (en) / "3,14" (de) ``` `options` is forwarded to [`Intl.NumberFormat`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). ### `l.currency(value, code, options?)` ```tsx l.currency(29.99, "USD"); // "$29.99" (en) / "29,99 $US" (fr) l.currency(29.99, "EUR"); // "€29.99" (en) / "29,99 €" (de) ``` ### `l.percent(value, options?)` Pass a decimal (0.156, not 15.6). ```tsx l.percent(0.156); // "16%" (en) / "16 %" (fr) l.percent(0.156, { maximumFractionDigits: 1 }); // "15.6%" ``` ### `l.unit(value, unit, options?)` `unit` is an Intl-recognized unit name (`celsius`, `kilometer-per-hour`, `megabyte`, etc). ```tsx l.unit(32, "celsius"); // "32°C" (en) / "32 °C" (de) l.unit(120, "kilometer-per-hour"); // "120 km/h" ``` ### `l.compact(value, options?)` ```tsx l.compact(1234567); // "1.2M" (en) / "123万" (ja) / "1,2 Mio." (de) l.compact(950); // "950" ``` ## Dates and time ### `l.date(value, options?)` / `l.time` / `l.datetime` Accept `Date` or epoch milliseconds. ```tsx l.date(new Date()); // "3/16/2026" (en) / "16.03.2026" (de) l.time(new Date()); // "3:45 PM" (en) / "15:45" (de) l.datetime(new Date()); // "3/16/2026, 3:45 PM" (en) l.date(now, { dateStyle: "long" }); // "March 16, 2026" l.date(now, { weekday: "short", month: "short", day: "numeric" }); // "Mon, Mar 16" ``` ### `l.relative(value, unit, options?)` Signed offset — negative for past, positive for future. ```tsx l.relative(-3, "day"); // "3 days ago" (en) / "vor 3 Tagen" (de) l.relative(2, "hour"); // "in 2 hours" l.relative(0, "second", { numeric: "auto" }); // "now" ``` ## Lists ### `l.list(items, options?)` Locale-aware conjunction. Default style is `long` with type `conjunction` ("and"). ```tsx l.list(["apples", "oranges", "pears"]); // "apples, oranges, and pears" (en) // "apples, oranges y pears" (es) l.list(["red", "blue"], { type: "disjunction" }); // "red or blue" ``` ## File sizes ### `l.fileSize(bytes)` Convenience wrapper that picks an appropriate unit (B, KB, MB, GB, TB, PB) and formats the result with locale-aware decimals. ```tsx l.fileSize(1024); // "1 KB" l.fileSize(1073741824); // "1 GB" (en) / "1 Go" (fr) l.fileSize(1536); // "1.5 KB" ``` ## Display names ### `l.displayName(code, type)` Translate a language, region, script, or currency code into a localized name. ```tsx l.displayName("en", "language"); // "English" (en) / "Englisch" (de) / "Английский" (ru) l.displayName("US", "region"); // "United States" / "Vereinigte Staaten" l.displayName("USD", "currency"); // "US Dollar" / "US-Dollar" l.displayName("Cyrl", "script"); // "Cyrillic" / "Kyrillisch" ``` Returns `undefined` if the code isn't recognized for the requested type. ## Collation ### `l.sort(items, options?)` Returns a **new** sorted array — doesn't mutate the input. ```tsx l.sort(["ä", "z", "a"]); // de: ["a", "ä", "z"] sv: ["a", "z", "ä"] l.sort(["File 10", "File 2"], { numeric: true }); // ["File 2", "File 10"] ``` ## Segmentation ### `l.segment(text, granularity?)` Locale-aware splitting into graphemes, words, or sentences. Essential for CJK where spaces don't separate words. ```tsx l.segment("Hello world", "word"); // ["Hello", " ", "world"] l.segment("こんにちは世界", "word"); // ["こんにちは", "世界"] (ja) l.segment("café", "grapheme"); // ["c", "a", "f", "é"] ``` Granularity defaults to `"grapheme"`. ## Why thin wrappers? Every method delegates to a native `Intl` formatter — no parsing, no number libraries, no extra dependencies. The runtime keeps the bundle small and lets the platform handle the locale data, which means new locales added to the V8 / SpiderMonkey ICU tables work for free. The only added value over raw `Intl` is the locale string — you set it once via `` and every formatter reads it from there. If you'd prefer raw `Intl` (for code outside React), `createLingo(locale)` gives you the same object without the provider. ## Where to next - [useLingo](/labs/docs/react/i18n/use-lingo) — translating strings via `l.text` and `l.rich`. - [Plurals and select](/labs/docs/react/i18n/plurals-and-select) — picking forms by count or category. - [Plurals and select](https://lingo.dev/en/docs/react/i18n/plurals-and-select): Handle count-dependent ('1 item' vs 'N items') and category-dependent ('he/she/they') translations via the runtime's plural and select helpers. Plurals and select forms are the cases where one source string isn't enough — the translation depends on a number or a category. `@lingo.dev/react` exposes two friendly helpers (`l.plural` and `l.select`) that compile to ICU MessageFormat under the hood, so translators see the standard syntax and runtime stays the same. ## Plurals `l.plural(count, forms, { context })` picks the right form based on `count` and the locale's CLDR plural rules. ```tsx const l = useLingo(); l.plural(items.length, { one: "1 item", other: "{count} items", }, { context: "Cart summary" }); // → "1 item" (en, count=1) / "5 items" (en, count=5) // → "1 Eintrag" / "5 Einträge" (de, after translation) ``` ### Forms by locale The forms map accepts every CLDR plural category — `zero`, `one`, `two`, `few`, `many`, `other`. Locales pick what they need: - English uses `one` + `other` (1 vs everything else) - Russian uses `one` + `few` + `many` + `other` (1; 2-4; 5-20; 21, 31, ...) - Arabic uses all six - Japanese uses only `other` (no plural distinction) You only need to provide the forms the **source** locale uses — translators add the rest per target locale. {% callout type="info" %} `{count}` is interpolated automatically inside any plural form. You don't pass it via `values` — it comes from the first argument. {% /callout %} ### Combining with other placeholders For sentences with both a count and other variables, write the variables into the form strings; they'll be passed through to ICU. ```tsx l.plural(notifications.length, { one: "1 message from {sender}", other: "{count} messages from {sender}", }, { context: "Inbox header" }); ``` Then pass the values when you call — but wait, `l.plural`'s signature only has `{ context }`. For mixed cases, use `l.text` directly with ICU plural syntax: ```tsx l.text(`{count, plural, one {1 message from {sender}} other {# messages from {sender}}}`, { values: { count: notifications.length, sender: user.name }, context: "Inbox header", }); ``` The `#` token is replaced with the count value verbatim — useful when you want it without the curly-brace interpolation form. ## Select `l.select(value, forms, { context })` picks a form based on a string key (gender, role, content type — anything categorical). ```tsx l.select(user.gender, { male: "He uploaded a photo", female: "She uploaded a photo", other: "They uploaded a photo", }, { context: "Activity feed" }); ``` `other` is required as a fallback. The match is exact — there's no fuzzy or case-insensitive matching. ### Selectordinal For ordinal numbers (1st, 2nd, 3rd) use ICU `selectordinal` directly via `l.text`: ```tsx l.text(`You finished in {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place`, { values: { place: rank }, context: "Leaderboard", }); // → "You finished in 1st place" / "2nd" / "3rd" / "4th, 5th, ..." ``` ## What this compiles to Both `l.plural` and `l.select` build an ICU MessageFormat string and pass it to `l.text`. The compiled form is what gets extracted by `lingo extract` and stored in your locale files — translators edit ICU syntax directly, not the JS object literal. Example: `l.plural(n, { one: "1 item", other: "{count} items" }, { context: "Cart" })` extracts as: ```text {count, plural, one {1 item} other {{count} items}} ``` This means translators can adapt the categories per locale, including ones the source doesn't have. Russian becomes `{count, plural, one {...} few {...} many {...} other {...}}` without any code change. ## When not to use these - **A simple "1 or many" boolean.** Two `l.text` calls under an `if` are fine and easier for translators to spot. - **Programmatic enum that's not user-facing.** Plural / select are for *translation* of categorical messages, not for routing app logic. ## Where to next - [useLingo](/labs/docs/react/i18n/use-lingo) — base `l.text` and `l.rich` semantics. - [Formatting](/labs/docs/react/i18n/formatting) — number, currency, date, list formatting via native Intl. - [useLingo](https://lingo.dev/en/docs/react/i18n/use-lingo): The hook that returns the active Lingo object. Translate strings, render rich React subtrees, and read locale metadata. `useLingo()` is how components get to the runtime. It reads the nearest `` and returns the active `Lingo` object — call it once per component, hold the reference, and use whichever methods you need. ```tsx import { useLingo } from "@lingo.dev/react"; function Greeting() { const l = useLingo(); return

{l.text("Hello", { context: "Hero heading" })}

; } ``` It throws if called outside a provider, so test setups need to wrap the render — even with `messages: {}` if you don't care about translations in the test. ## `l.text(source, options)` — plain strings The everyday call. Returns a translated string, or the source if no translation exists for the current locale. ```tsx l.text("Save", { context: "Form button" }); // → "Speichern" (de) / "Save" (en, fallback) ``` ### Interpolation `{placeholder}` segments are substituted from `options.values`. ```tsx l.text("Welcome back, {name}!", { values: { name: user.firstName }, context: "Dashboard greeting", }); ``` Missing values render as `{name}` literally — handy for spotting bugs during dev, but make sure your tests assert on the rendered result so empty values don't slip through. ### When the source contains ICU syntax If the source string contains plural / select / number / date markers, the runtime upgrades automatically — no extra API. See [Plurals and select](/labs/docs/react/i18n/plurals-and-select) for the friendly wrapper. ## `l.rich(source, options)` — React subtrees When the translated text contains React components (links, bold, an ``), use `l.rich`. The translation string carries placeholder tags like `...`; you map each tag to a renderer. ```tsx l.rich("Click here for {topic}", { tags: { link: (children) =>
{children}, }, values: { topic: "details" }, context: "Footer help link", }); // → <>Click here for details ``` Self-closing tags work too: ```tsx l.rich("Loading ...", { tags: { spinner: () => }, context: "Inline loading state", }); ``` Tags **without** a renderer fall back to the raw text — so the missing-tag case is visible in dev, not silently swallowed. {% callout type="warning" %} Don't put markup directly inside translated strings. `l.rich` exists so translators see neutral placeholders (``) instead of ``, which they'd have to preserve verbatim and often break. Define the renderer in your code, not in the locale file. {% /callout %} ## Locale metadata on `l` Beyond translation, the object exposes: | Property | Type | Notes | | ------------- | --------------------- | ---------------------------------------------------------------- | | `l.locale` | `string` | Whatever you passed to `LingoProvider`. BCP-47. | | `l.direction` | `"ltr"` | `"rtl"`| Computed via `Intl.Locale.textInfo` + fallback RTL language list.| | `l.script` | `string` | `undefined` | Inferred when possible (`"Latn"`, `"Cyrl"`, `"Arab"`, …). | | `l.region` | `string` | `undefined` | Inferred from BCP-47 (`"US"`, `"DE"`, `"SA"`, …). | Useful for layout decisions: ```tsx const l = useLingo(); return
...
; ``` ## Formatting methods `l` also carries `num`, `currency`, `percent`, `date`, `time`, `datetime`, `relative`, `list`, `displayName`, `sort`, `segment`, `fileSize`, `compact`, `unit` — see [Formatting](/labs/docs/react/i18n/formatting) for the full breakdown. These are thin wrappers around native `Intl.*` formatters keyed to `l.locale`. ## Outside of React `useLingo` only works inside components. For utilities, route loaders, or server code, build the same object directly: ```ts import { createLingo } from "@lingo.dev/react"; const l = createLingo("es", messages); l.text("Hello", { context: "Email subject" }); ``` It's the same `Lingo` shape, no provider needed. `LingoProvider` itself uses `createLingo` under the hood. - [LingoProvider](https://lingo.dev/en/docs/react/i18n/provider): The React context provider. Pass a locale and a messages bag; descendants read both through useLingo(). `LingoProvider` is the React context that holds the active locale and the messages map. Wrap it once at the root of your app — everything inside can call `useLingo()` to translate strings or read locale metadata. ## Basic usage ```tsx import { LingoProvider } from "@lingo.dev/react"; import messages from "./locales/es.json"; ``` ## Props | Prop | Type | Required | What it does | | ---------- | ----------- | -------- | ----------------------------------------------------------------------- | | `locale` | `string` | yes | BCP-47 tag, e.g. `"en"`, `"es"`, `"ar-SA"`. Drives all formatting + RTL detection. | | `messages` | `Messages` | no | Hash-keyed translations. Defaults to `{}` (everything falls back to source). | | `children` | `ReactNode` | yes | Your app. | `Messages` is just `Record` — the same shape the CLI writes to `locales/.json`. ## Nesting providers Providers can nest. The rules are different depending on whether the nested provider has the **same** locale as the parent or a **different** one. ### Same locale — messages merge ```tsx {/* Route-scoped messages override shared on collision; missing keys fall through to shared. */} ``` Use this to split bundles per route while keeping a common "shell" set of translations at the root. ### Different locales — standalone ```tsx
{/* This subtree is entirely Arabic; the parent's es messages are NOT visible. */} ``` Handy for rendering a single component in a fixed language (a quote, an embed, a preview pane) inside an otherwise different app. ## Switching locale at runtime Treat `locale` as React state — change it, and every `useLingo()` consumer below re-renders with the new locale and message bag. ```tsx function AppRoot() { const [locale, setLocale] = useState("en"); const [messages, setMessages] = useState({}); async function switchTo(next: string) { const next_messages = await import(`./locales/${next}.json`); setLocale(next); setMessages(next_messages.default); } return ( ); } ``` {% callout type="info" %} On Next.js, prefer `useLocaleSwitch()` from `@lingo.dev/react-next` — it handles router-aware locale changes plus persistence. {% /callout %} ## What you can read from the context `useLingo()` returns the active `Lingo` object. Beyond `text()` and `rich()` it carries: - `locale` — the BCP-47 string you passed in - `direction` — `"ltr"` or `"rtl"`, computed via `Intl.Locale.textInfo` with a fallback list of known RTL languages - `script` — e.g. `"Latn"`, `"Cyrl"`, `"Arab"` - `region` — e.g. `"US"`, `"DE"`, `"SA"` These are useful for conditional layout (mirroring icons in RTL) or analytics tagging — no extra parsing required. ## Common mistakes - **Forgetting ``.** `useLingo()` throws outside one. The error message tells you to add a provider; if you're seeing it in tests, wrap the render in a test-mode provider with empty `messages` to make assertions stable. - **Passing async-loaded messages directly.** `messages` must be a synchronous value. Resolve the promise in a parent (with Suspense or state), then pass the result down. - **Switching `locale` without updating `messages`.** The provider trusts both props together — change them in the same `useState` update or you'll briefly render the new locale with the old translations. - [Quickstart](https://lingo.dev/en/docs/react/i18n/quickstart): Install @lingo.dev/react, wrap your app, write your first translation, and see it render. This walks through translating a single React component end-to-end: install the runtime, wrap the app, write a translation, extract it, and render the result in another locale. {% steps %} {% step title="Install the runtime" %} ```bash npm install @lingo.dev/react ``` If you're on Next.js, also install `@lingo.dev/react-next` — it adds router-aware helpers on top of the same runtime. {% /step %} {% step title="Wrap the app in LingoProvider" %} ```tsx import { LingoProvider } from "@lingo.dev/react"; import esMessages from "./locales/es.json"; export function App() { return ( ); } ``` `messages` is an object keyed by content hash — exactly what the CLI emits to `locales/.json`. On first run it's empty, and that's fine: untranslated strings fall back to their source text. {% /step %} {% step title="Write a translation in source code" %} ```tsx import { useLingo } from "@lingo.dev/react"; function Page() { const l = useLingo(); return

{l.text("Hello", { context: "Hero heading" })}

; } ``` `l.text(source, { context })` is the canonical call. `context` is required — it lets translators disambiguate strings that read the same in English but differ across languages ("Save" the verb vs. "Save" the noun). {% /step %} {% step title="Extract the string" %} ```bash lingo extract ``` This scans your source, computes a stable hash for `"Hello"` + the context, and writes it to your source locale file (`locales/en.jsonc` by default). Re-run after any change — extraction is idempotent. {% /step %} {% step title="Push for translation" %} ```bash lingo push --backfill-missing ``` The CLI uploads the source file, asks the engine to translate into your configured target locales, and downloads the result back into `locales/.json`. From now on, every push only sends what changed. {% /step %} {% step title="Render the translated text" %} Import the JSON file for the locale your app is rendering in (or pick it dynamically based on the user) and pass it to `LingoProvider`. The hook call from step 3 stays the same — `l.text("Hello", ...)` now returns the translated value because the hash matches what was downloaded. {% callout type="success" %} **That's the whole loop:** write source-language code, extract, push, render. There's no separate i18n key namespace to maintain — the source string *is* the key (via hash). {% /callout %} {% /step %} {% /steps %} ## Where to go next - [LingoProvider](/labs/docs/react/i18n/provider) — what `messages` should look like, when to nest providers, locale switching. - [useLingo](/labs/docs/react/i18n/use-lingo) — the full hook API, including `l.rich()` for React subtrees inside translations. - [Plurals and select](/labs/docs/react/i18n/plurals-and-select) — handling "1 item" / "N items" properly. ## Docs – React/mcp - [Claude Code](https://lingo.dev/en/docs/react/mcp/claude-code): Set up the Lingo.dev i18n MCP server in Claude Code - one command to connect, then prompt 'Set up i18n' to implement internationalization in your project. Claude Code is Anthropic's CLI for agentic coding. It supports MCP servers natively via the `claude mcp add` command. ## Setup ```bash claude mcp add --transport http "lingo" https://mcp.lingo.dev/main ``` This registers the Lingo.dev i18n MCP server with Claude Code. No API key is needed. ## Usage Navigate to your project directory and prompt Claude Code: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Claude Code calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [Codex (OpenAI)](https://lingo.dev/en/docs/react/mcp/codex): Set up the Lingo.dev i18n MCP server in OpenAI Codex - add a TOML config entry, then prompt 'Set up i18n' to implement internationalization. Codex is OpenAI's autonomous software engineering agent available to ChatGPT Plus users. It supports MCP servers via a TOML configuration file. ## Setup Add to `~/.codex/config.toml`: ```toml [mcp_servers.lingo] command = "npx" args = ["mcp-remote", "https://mcp.lingo.dev/main"] ``` No API key is needed. ## Usage Prompt Codex: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Codex calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [Cursor](https://lingo.dev/en/docs/react/mcp/cursor): Set up the Lingo.dev i18n MCP server in Cursor - add a JSON config file to your project, then prompt 'Set up i18n' to implement internationalization. Cursor is an AI-powered code editor built on VS Code. It supports MCP servers via a project-level JSON configuration file. ## Setup Create `.cursor/mcp.json` in your project root: ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/main" } } } ``` No API key is needed. ## Usage Open your project in Cursor and prompt: > Set up i18n Or specify locales upfront: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". Cursor calls the `i18n_checklist` tool and follows the guided steps - analyzing your project, fetching framework docs, and implementing locale routing, translations, and a language switcher. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Set up in GitHub Copilot Agents" icon="code" /%} {% /card-grid %} - [GitHub Copilot Agents](https://lingo.dev/en/docs/react/mcp/github-copilot): Set up the Lingo.dev i18n MCP server in GitHub Copilot Agents - configure the MCP and agent definition, then assign an i18n setup task. GitHub Copilot coding agent is an autonomous AI tool that completes development tasks in the background and proposes pull requests. It supports MCP servers via the repository settings. ## Setup {% steps %} {% step title="Configure the MCP server" %} Navigate to your repository **Settings > Copilot > Coding agent**. In the **MCP configuration** field, enter: ```json { "mcpServers": { "lingo": { "command": "npx", "type": "stdio", "tools": ["*"], "args": ["mcp-remote", "https://mcp.lingo.dev/main"] } } } ``` Click **Save MCP configuration**. {% /step %} {% step title="Add the agent definition" %} Commit the following file to `.github/agents/i18n-setup.md` in your repository: ```markdown --- name: i18n-setup description: Expert at implementing internationalization (i18n) in web applications using a systematic, checklist-driven approach. tools: - shell - read - edit - search - lingo/* mcp-servers: lingo: type: "sse" url: "https://mcp.lingo.dev/main" tools: ["*"] --- You are an i18n implementation specialist. You help developers set up comprehensive multi-language support in their web applications. ## Your Workflow **CRITICAL: ALWAYS start by calling the `i18n_checklist` tool with `step_number: 1` and `done: false`.** This tool will tell you exactly what to do. Follow its instructions precisely: 1. Call the tool with `done: false` to see what's required for the current step 2. Complete the requirements 3. Call the tool with `done: true` and provide evidence 4. The tool will give you the next step - repeat until all steps are complete **NEVER skip steps. NEVER implement before checking the tool. ALWAYS follow the checklist.** ``` {% /step %} {% /steps %} ## Usage 1. Navigate to [Copilot Agents](https://github.com/copilot/agents) 2. Select your repository and the `i18n-setup` agent 3. Enter a prompt: ``` Set up i18n for the following locales: - en - es Use "en" as the default locale. ``` 4. Click **Start task** The agent works in the background and opens a pull request with the complete i18n implementation. ## Next Steps {% card-grid %} {% link-card title="How It Works" href="/docs/react/mcp" description="What the MCP server provides" icon="book" /%} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Set up in Claude Code instead" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Set up in Cursor instead" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Set up in Codex instead" icon="code" /%} {% /card-grid %} - [Setup](https://lingo.dev/en/docs/react/mcp/setup): Connect the Lingo.dev i18n MCP server to your AI coding assistant - one configuration step for Claude Code, Cursor, Codex, or GitHub Copilot Agents. Connect the Lingo.dev i18n MCP server to your AI coding assistant in one step. The server is hosted at `https://mcp.lingo.dev/main` - no installation or API key required. ## Quick setup {% tabs %} {% tab label="Claude Code" %} ```bash claude mcp add --transport http "lingo" https://mcp.lingo.dev/main ``` {% /tab %} {% tab label="Cursor" %} Create `.cursor/mcp.json` in your project: ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/main" } } } ``` {% /tab %} {% tab label="Codex (OpenAI)" %} Add to `~/.codex/config.toml`: ```toml [mcp_servers.lingo] command = "npx" args = ["mcp-remote", "https://mcp.lingo.dev/main"] ``` {% /tab %} {% tab label="GitHub Copilot" %} 1. Navigate to your repository **Settings > Copilot > Coding agent** 2. In **MCP configuration**, enter: ```json { "mcpServers": { "lingo": { "command": "npx", "type": "stdio", "tools": ["*"], "args": ["mcp-remote", "https://mcp.lingo.dev/main"] } } } ``` 3. Click **Save MCP configuration** {% /tab %} {% /tabs %} ## Verify the connection After connecting, prompt the AI agent: > Set up i18n The agent should call the `i18n_checklist` tool as its first action. If it doesn't recognize the tool, check that the MCP server URL is correct and the configuration file is saved in the right location. ## What happens next The agent follows a 13-step checklist that covers: 1. Project context analysis (framework, router, directory structure) 2. Framework documentation retrieval 3. Locale routing setup 4. Translation infrastructure 5. Language switcher component 6. Build validation You can specify locales upfront or let the agent ask: > Set up i18n with the following locales: en, es, and pt-BR. The default locale is "en". For platform-specific details, see the individual guides: {% card-grid %} {% link-card title="Claude Code" href="/docs/react/mcp/claude-code" description="Terminal-based setup with Claude Code" icon="terminal" /%} {% link-card title="Cursor" href="/docs/react/mcp/cursor" description="Editor-based setup with Cursor" icon="code" /%} {% link-card title="Codex (OpenAI)" href="/docs/react/mcp/codex" description="Autonomous agent setup with Codex" icon="code" /%} {% link-card title="GitHub Copilot" href="/docs/react/mcp/github-copilot" description="Repository-level setup with Copilot Agents" icon="code" /%} {% /card-grid %} ## Docs – Workflows - [GitLab CI/CD](https://lingo.dev/en/docs/workflows/gitlab): Run Lingo.dev in GitLab CI so every merge request comes back with new and changed strings already translated, committed onto the MR branch for review. Run Lingo.dev inside GitLab CI so every merge request that adds or changes source strings comes back with translations already filled in. The pipeline runs `lingo push`, the engine translates only the new or changed keys, and the result is committed straight onto the MR branch — translations show up in the MR diff and get reviewed before a human merges. Nothing lands on your default branch unseen. {% callout type="info" title="Working example" %} A complete, runnable setup lives at [gitlab.com/lingo.dev/gitlab-cicd-example](https://gitlab.com/lingo.dev/gitlab-cicd-example). {% /callout %} ## Prerequisites - A Lingo.dev organization and engine, plus a **service API key** (Dashboard → API Keys → create, type _service_). - A project configured for Lingo.dev. Generate it once with: ```bash npx @lingo.dev/cli@latest init # scaffolds .lingo/config.json npx @lingo.dev/cli@latest link # connects the project to your org + engine ``` `.lingo/config.json` declares the source/target locales and source globs: ```json { "sourceLocale": "en", "targetLocales": ["es", "fr", "de", "zh"], "files": [{ "pattern": "locales/en.json" }], "orgId": "org_...", "engineId": "eng_..." } ``` - A committed baseline. The very first time, translate everything and commit it so CI has a lockfile to diff against: ```bash npx @lingo.dev/cli@latest push --backfill-missing --wait git add locales .lingo && git commit -m "chore(i18n): baseline translations" ``` ## Access tokens Add two **masked** CI/CD variables under **Settings → CI/CD → Variables**: - **`LINGO_API_KEY`** — your Lingo.dev service key (`lingo_sk_...`). The CLI reads it automatically to authenticate. - **`GITLAB_PUSH_TOKEN`** — a **Project Access Token** with the `write_repository` scope (role _Developer_). This lets CI commit the translations back onto the MR branch. {% callout type="info" %} Create the Project Access Token under **Settings → Access tokens**. `CI_JOB_TOKEN` can't push commits, so a dedicated token is required for the commit-back step. Project access tokens require a paid GitLab plan. {% /callout %} ## Pipeline Commit this `.gitlab-ci.yml`. It runs on merge requests targeting the default branch and pushes the translations back onto the MR's source branch: ```yaml stages: - localize localize: stage: localize image: node:22-alpine rules: # Only on merge requests that target the default branch. - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH' before_script: - apk add --no-cache git script: # Pin the CLI version — never @latest; bump deliberately after testing. # --wait blocks until the engine finishes and writes files: since 1.6.0 # `push` is async by default (it submits the run and exits), so CI must # wait to have something to commit. - npx -y @lingo.dev/cli@1.6.0 push --wait - | if [ -z "$(git status --porcelain)" ]; then echo "Translations already up to date — nothing to commit." exit 0 fi git config user.name "lingo-bot" git config user.email "bot@lingo.dev" git add locales .lingo/lock.json # [skip ci] keeps the bot's own commit from re-triggering this pipeline. git commit -m "chore(i18n): sync translations [skip ci]" git push "https://oauth2:${GITLAB_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" ``` ## Try it ```bash git checkout -b feat/new-strings # add or change a key in locales/en.json git commit -am "feat: add strings" && git push -u origin feat/new-strings # open an MR feat/new-strings -> main (UI, or: glab mr create --fill --target-branch main) ``` The MR pipeline runs `lingo push --wait`, commits `locales/{...}.json` plus the updated `.lingo/lock.json` to the MR branch, and the translations appear in the diff. A reviewer adjusts any value, then merges. ## How human edits survive `lingo push` preserves manual edits **per key**: - Edit a target string (its English source unchanged) → that string is kept; every other key keeps getting translated. - The English source behind an edited key changes → a fresh translation is generated for that key (the meaning changed). - A new source key is added → translated and added, even into files that contain manual edits. So a reviewer's fix in the MR survives every later pipeline run, while new and changed keys flow in automatically. ## push modes - **`lingo push`** — incremental; the CI default. Translates only new/changed keys, preserves everything else. **Add `--wait` in CI** so it blocks until outputs are written (1.6.0+ is async by default). - **`lingo push --backfill-missing`** — first-push / new-locale bootstrap; fills target files that don't exist yet. Not for ongoing changes. - **`lingo push --force --yes`** — re-translate everything from scratch (overwrites manual edits). Rare. ## Customization - **Auto-commit to the default branch instead of MRs:** trigger on `$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH` and push back to `$CI_DEFAULT_BRANCH`. Simpler, but AI output lands on the default branch without review. - **Pin specific strings:** use `preservedKeys` / `lockedKeys` in `.lingo/config.json` to keep chosen keys fixed even when their source changes. - **Self-hosted GitLab:** works unchanged. On gitlab.com, new accounts must pass identity verification before shared runners execute CI jobs. ## Next Steps {% card-grid %} {% link-card title="Full example repo" href="https://gitlab.com/lingo.dev/gitlab-cicd-example" description="A complete, runnable GitLab CI setup" icon="code" /%} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Set up GitHub Actions integration" icon="code" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Translation checks, merge conflicts, workflow selection" icon="gear" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route CI/CD translations through your engine" icon="plug" /%} {% /card-grid %} - [Setup](https://lingo.dev/en/docs/workflows/setup): Set up continuous localization with the Lingo.dev GitHub Action or CLI - configure the CLI, add your API key as a secret, and choose a workflow. On GitHub, the GitHub App is a managed alternative that needs no API key secret or i18n.json. Set up continuous localization for the GitHub Action, GitLab CI/CD, Bitbucket Pipelines, or the standalone CLI. These all run the Lingo.dev CLI in your pipeline, so the setup is the same three steps: configure the CLI, add your API key, and choose a workflow. {% callout type="info" title="Setting up the GitHub App instead?" %} The [GitHub App](/docs/workflows/github-app) doesn't use this flow - there's no local CLI, `i18n.json`, or API key secret. You install the app once and add a `.lingo/config.json` to the repository. Follow the [GitHub App guide](/docs/workflows/github-app) instead. {% /callout %} {% callout type="info" title="Prerequisites" %} You need a working [CLI setup](/docs/cli/setup) with an `i18n.json` file and the ability to run `npx lingo.dev@latest run` locally before adding CI/CD. {% /callout %} ## Step 1. Configure the CLI If you haven't already, follow the [CLI Setup](/docs/cli/setup) guide. You should end up with: - An `i18n.json` file in your project root - An API key (either `LINGO_API_KEY` for Lingo.dev Engine or a provider key like `OPENAI_API_KEY`) - The ability to generate translations locally with `npx lingo.dev@latest run` ## Step 2. Add your API key as a CI secret Store your API key in your CI platform's secret management: {% tabs %} {% tab label="GitHub" %} 1. Navigate to **Settings > Secrets and variables > Actions** 2. Click **New repository secret** 3. Name: `LINGODOTDEV_API_KEY`, Value: your API key 4. Click **Add secret** {% /tab %} {% tab label="GitLab" %} 1. Navigate to **Settings > CI/CD > Variables** 2. Click **Add variable** 3. Key: `LINGODOTDEV_API_KEY`, Value: your API key 4. Select **Visibility > Masked** 5. Click **Add variable** {% callout type="warning" %} By default, GitLab CI/CD variables are only available on protected branches. Disable **Protect variable** if you need translations on feature branches. {% /callout %} {% /tab %} {% tab label="Bitbucket" %} 1. Navigate to **Repository settings > Repository variables** 2. Name: `LINGODOTDEV_API_KEY`, Value: your API key 3. Click **Add** {% /tab %} {% /tabs %} ## Step 3. Choose a workflow and add the config Pick the workflow that fits your team, then follow the platform-specific guide: | Workflow | Best for | | --- | --- | | Commit to main | Small teams that want zero-friction, invisible translation updates | | PR from main | Teams that want to review translations before they land on main | | Commit to feature branch | Teams with long-lived feature branches | | PR from feature branch | Teams that want maximum control over every translation change | {% callout type="info" %} Not sure which to pick? Start with "Commit to main" - it's the simplest. You can switch later without changing your `i18n.json`. {% /callout %} For platform-specific setup instructions and workflow examples, see: {% card-grid %} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Official GitHub Action with workflow examples" icon="code" /%} {% link-card title="GitLab CI/CD" href="/docs/workflows/gitlab" description="Docker image with pipeline examples" icon="code" /%} {% link-card title="Bitbucket Pipelines" href="/docs/workflows/bitbucket" description="Official Pipe with workflow examples" icon="code" /%} {% /card-grid %} ## Verify the setup After configuring your CI workflow, push a change to trigger it. The integration should: 1. Run the translation pipeline 2. Commit translations or open a PR (depending on your workflow) 3. Update the `i18n.lock` file To verify translations are complete in CI without generating new ones, use the `--frozen` flag: ```bash npx lingo.dev@latest run --frozen ``` This exits with a non-zero status if any content is untranslated - useful as a deployment gate. See [Advanced Patterns](/docs/workflows/advanced) for examples. ## Next Steps {% card-grid %} {% link-card title="GitHub App" href="/docs/workflows/github-app" description="Managed setup with no API key secret or i18n.json" icon="git-branch" /%} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Set up the official GitHub Action" icon="code" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Translation checks, merge conflicts, workflow selection" icon="gear" /%} {% link-card title="How It Works" href="/docs/workflows" description="The CI/CD localization pipeline" icon="book" /%} {% /card-grid %} - [GitHub Actions](https://lingo.dev/en/docs/workflows/github): Set up the official Lingo.dev GitHub Action to translate content on every push - with workflow examples for commit-to-main, PR-from-main, feature branches, and GPG signing. The official Lingo.dev GitHub Action runs the localization pipeline on every push, committing translations directly or opening a pull request depending on your workflow. {% callout type="info" title="Prefer a managed setup?" %} The [GitHub App](/docs/workflows/github-app) is the easiest way to run continuous localization on GitHub - install it once and it reacts to pushes and pull requests with no runner, no API key secret, and no lockfile. Use the GitHub Action (below) when you want translation to run inside your own pipeline alongside other CI steps. {% /callout %} {% callout type="info" title="Prerequisites" %} Complete the [CI/CD Setup](/docs/workflows/setup) first. You need a working `i18n.json` and `LINGODOTDEV_API_KEY` stored as a repository secret. {% /callout %} ## Minimal setup Create `.github/workflows/translate.yml`: ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` This commits translations directly to `main` on every push. ## Workflow examples ### Commit to main ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` ### Pull request from main ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` {% callout type="info" %} Enable **Settings > Actions > General > Allow GitHub Actions to create and approve pull requests** for PR-based workflows. {% /callout %} ### Commit to feature branch ```yaml name: Translate on: push: branches-ignore: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` ### Pull request from feature branch ```yaml name: Translate on: push: branches-ignore: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Available inputs | Input | Default | Description | | --- | --- | --- | | `api-key` | Required | Lingo.dev API key | | `pull-request` | `false` | Create a pull request instead of committing directly | | `commit-message` | `feat: update translations via @LingoDotDev` | Custom commit message | | `pull-request-title` | `feat: update translations via @LingoDotDev` | Custom PR title | | `commit-author-name` | `Lingo.dev` | Git commit author name | | `commit-author-email` | `support@lingo.dev` | Git commit author email | | `working-directory` | `.` | Working directory for monorepos | | `process-own-commits` | `false` | Process commits made by this action | | `parallel` | `false` | Run in parallel mode | | `version` | `latest` | Lingo.dev CLI version | ## Next Steps {% card-grid %} {% link-card title="GitHub App" href="/docs/workflows/github-app" description="Managed alternative - no runner, secret, or lockfile" icon="git-branch" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Translation checks, merge conflicts, workflow selection" icon="gear" /%} {% link-card title="GitLab CI/CD" href="/docs/workflows/gitlab" description="Set up GitLab CI/CD integration" icon="code" /%} {% link-card title="Bitbucket Pipelines" href="/docs/workflows/bitbucket" description="Set up Bitbucket Pipelines integration" icon="code" /%} {% /card-grid %} - [GitHub App](https://lingo.dev/en/docs/workflows/github-app): Set up continuous localization with the Lingo.dev GitHub App - install it once, add a repository config, and let translations update from pushes, pull requests, or PR comments. The Lingo.dev GitHub App sets up continuous localization for a repository using `.lingo/config.json`. ## Prerequisites Before you install the app, make sure you have: - A Lingo.dev organization with the GitHub integration feature enabled - A localization engine in that organization - Admin access to the GitHub organization or repository you want to connect {% callout type="info" %} If you are not an admin of the GitHub organization, GitHub lets you request the installation instead. A GitHub organization admin must approve that request before Lingo.dev can access the selected repositories. {% /callout %} ## Connect the GitHub App 1. In Lingo.dev, open your organization. 2. Go to **Settings**. 3. Find **GitHub (App)** in the integrations section. 4. Click **Connect** on the **GitHub (App)** row. 5. On GitHub, choose the account or organization to install the app into. 6. Choose either **All repositories** or **Only select repositories**. 7. Click **Install**. After installation, Lingo.dev shows the connected GitHub account and repositories in **Settings > GitHub (App)**. Use **Manage repositories on GitHub** from the same settings card when you need to add or remove repositories later. If your install request is waiting for approval, a GitHub admin can review it in GitHub under the organization's **Settings > GitHub Apps** area. GitHub also shows pending app requests to organization owners in the organization settings. ## Add the repository config Create `.lingo/config.json` in the repository you installed the app on: ```json { "engineId": "eng_abc123", "sourceLocale": "en", "targetLocales": ["es", "fr", "de"], "files": [ { "pattern": "docs/en/**/*.md" }, { "pattern": "docs/en/**/*.mdx" }, { "pattern": "locales/en.json" } ], "github": { "workflows": { "onPushToDefaultBranch": { "enabled": true }, "onPullRequest": { "enabled": true } }, "safety": { "requireApproval": false } } } ``` | Field | Required | Description | | --- | --- | --- | | `engineId` | Yes | The Lingo.dev engine that should translate this repository. | | `sourceLocale` | Yes | The source locale used in your source file paths, such as `en` or `en-US`. | | `targetLocales` | Yes | Locale codes to translate into. Up to 50 unique locales are supported. | | `files` | Yes | Repo-relative source file patterns. Up to 100 patterns are supported. | | `github.workflows.onPushToDefaultBranch.enabled` | No | Runs when source files change on the default branch. Enabled by default. | | `github.workflows.onPullRequest.enabled` | No | Runs when source files change in pull requests. Disabled by default. | | `github.safety.requireApproval` | No | Requires approval before automatic push or PR workflows translate. Disabled by default. | ## File patterns `files` patterns point to source (default locale) files. The app checks changed files against these patterns and only processes supported source files that match. Patterns are repo-relative, case-sensitive, and may use: - `*` to match inside one path segment - `**/` to match any directory depth Patterns cannot start with `/` and cannot contain `..`. ```json { "files": [ { "pattern": "docs/en/**/*.md" }, { "pattern": "src/content/en/**/*.mdx" }, { "pattern": "messages/en.jsonc" } ] } ``` ## File options Each entry in `files` can carry options beyond its `pattern`. All are optional, and each applies only to certain formats. | Option | Applies to | Description | | --- | --- | --- | | `format` | All | Overrides the format inferred from the file extension. Required for OpenAPI YAML (`"yaml-openapi"`). | | `include` / `exclude` | All | Glob lists to refine which files the entry matches, used with or instead of `pattern`. | | `translateFrontmatterFields` | Markdown, MDX, Markdoc | Frontmatter keys to translate. Defaults to `title` and `description`. | | `translateComponentProps` | MDX, Markdoc | MDX component props and Markdoc tag attributes to translate. | | `lockedKeys` | JSON, JSONC | Key paths kept at the source value, never translated. | | `preservedKeys` | JSON, JSONC | Key paths kept at the existing target value, not re-translated. | | `injectLocale` | JSON, JSONC | Writes the target locale code into the output at the given key (defaults to `language`). | `translateComponentProps` entries are either a prop name, which applies to that prop on any component or tag, or an object that scopes the props to named components or tags: ```json { "files": [ { "pattern": "src/content/en/**/*.mdx", "translateFrontmatterFields": ["title", "description"], "translateComponentProps": [ "alt", { "component": ["Callout", "Hero"], "props": ["title", "subtitle"] } ] }, { "pattern": "locales/en.json", "lockedKeys": ["app.version"], "injectLocale": { "enabled": true, "key": "language" } } ] } ``` ## Where localized files are written The app derives each target path from the source path and the locale codes in your config: | Source path | Target locale | Output path | | --- | --- | --- | | `docs/en/guide.md` | `es` | `docs/es/guide.md` | | `docs/en-US/guide.md` | `fr-FR` | `docs/fr-FR/guide.md` | | `locales/en.json` | `de` | `locales/de.json` | | `README.md` | `es` | `es/README.md` | Use the full directory name or filename as the locale code. For example, if source files live in `docs/en-US/`, set `"sourceLocale": "en-US"`, not `"en"`. If source strings live in `messages/en.json`, set `"sourceLocale": "en"`. When the source path contains a locale directory, the app replaces that directory. When the source path is a locale-named file, the app replaces the filename. If neither pattern exists, the app places the translated file in a new target-locale directory next to the source file. ## Workflows ### Push to default branch When a matching source file is added or changed on the default branch, the app translates it and commits the localized files to: ```txt lingo/translations/ ``` It then opens or updates a pull request from that translations branch back into the default branch. If the translations branch already exists, new commits are appended to it. If a target file is added or changed in the same push, the app treats that file as already handled and carries it into the translation PR instead of overwriting it. ### Pull requests When `github.workflows.onPullRequest.enabled` is `true`, the app checks pull request changes for matching source files. It commits translated files directly to the PR branch. The app does not write to forked PR branches, and it does not write to the default branch from a PR comment. Pull requests must be open for the app to commit translations. On PRs, Lingo.dev updates a PR comment with the translated files and any failures. ## Incremental updates and recovery For existing target files, the app translates only the source changes it can detect instead of regenerating the whole file. For new target files, it creates the localized file for each configured target locale. If a previous PR localization missed a source change or failed before updating the target file, the next PR synchronization can recover by comparing the PR source against the base branch source and translating the missing change. If a source file no longer exists at the commit being processed, the app skips it. ## Approval mode Set `github.safety.requireApproval` to `true` when you want a human approval step before automatic translations run. On default-branch pushes, the Lingo.dev check run shows **Approve** and **Deny** actions. On pull requests, the app posts a translation proposal comment; reply with: ```txt /lingo approve ``` Scoped `/lingo translate` commands do not require this approval gate. ## Manual PR command Use `/lingo` in a pull request comment to backfill or force translations for specific files. ```txt /lingo translate docs/en/**/*.md ``` ```txt /lingo translate docs/en/**/*.mdx --locales fr,es ``` ```txt /lingo translate docs/en/**/*.md --force ``` Command reference: | Command | Description | | --- | --- | | `/lingo` | Shows help. | | `/lingo help` | Shows help. | | `/lingo translate ...` | Translates missing target files for matching source files. | | `/lingo translate ... --locales fr,es` | Limits the run to configured target locales. Locale values are lowercased by the command parser. | | `/lingo translate ... --force` | Translates every matching source and locale in scope, overwriting existing targets. | | `/lingo approve` | Approves a pending translation proposal on the PR. | The command must be on its own line. Globs are matched against files that also match your configured `files` source patterns. Like config patterns, command globs cannot start with `/` and cannot contain `..`. By default, `/lingo translate` runs in backfill mode: it only creates target files that are missing on the PR branch. Add `--force` when you want to regenerate existing target files. ## Supported formats The GitHub App detects these formats from the file extension: - JSON (`.json`) - JSONC (`.jsonc`) - Markdown (`.md`) - MDX (`.mdx`) - Markdoc OpenAPI documents written in YAML are also supported, but are not detected automatically. Set `"format": "yaml-openapi"` on the file pattern: ```json { "files": [ { "pattern": "openapi/en.yaml", "format": "yaml-openapi" } ] } ``` ## Large updates The app may split translation output across multiple commits. This happens when a run would write more than 100 files in one commit or more than 5 MB of translated file content in one commit. When that happens, commit messages include the batch number, such as: ```txt feat: Lingo.dev translations (1/3) feat: Lingo.dev translations (2/3) feat: Lingo.dev translations (3/3) ``` ## What happens when nothing matches If `.lingo/config.json` is missing, the app skips the repository silently. Once the config exists, invalid config creates a failing check run and, on PRs, a comment with the validation error. If no changed files match your source patterns, the app completes without writing translations. For `/lingo translate`, the bot replies with a short explanation, such as no matching files, no matching locales, or all target files already existing. ## Next Steps {% card-grid %} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Use the GitHub Action instead of the GitHub App" icon="code" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Translation checks, merge conflicts, workflow selection" icon="gear" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route GitHub App translations through your engine" icon="plug" /%} {% /card-grid %} - [Advanced Patterns](https://lingo.dev/en/docs/workflows/advanced): Advanced CI/CD localization patterns - choosing a workflow, verifying translation completeness with --frozen, and resolving i18n.lock merge conflicts. Advanced patterns for CI/CD localization - workflow selection, translation completeness checks, and merge conflict resolution. ## Choosing a workflow Four workflow patterns cover most team setups. Each has different trade-offs around automation, review overhead, and branch hygiene. | Workflow | Best for | Trade-off | | --- | --- | --- | | **Commit to main** | Small teams, zero-friction updates | No review step for translations | | **PR from main** | Teams that want to review translations | Requires manual PR approval | | **Commit to feature branch** | Long-lived feature branches | Translation commits in branch history | | **PR from feature branch** | Maximum control per feature | Multiple PRs per feature to manage | {% callout type="info" %} Start with "Commit to main" if you're unsure. It's the simplest workflow and prevents merge conflicts entirely since there's no branch divergence. {% /callout %} ## Checking translation completeness The `--frozen` flag verifies that all content is translated without generating new translations. It exits with a non-zero status code if any content is missing: ```bash npx lingo.dev@latest run --frozen ``` Use this as a deployment gate to prevent shipping untranslated content. {% tabs %} {% tab label="GitHub Actions" %} ```yaml name: Check translations on: [push, pull_request] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npx lingo.dev@latest run --frozen ``` {% /tab %} {% tab label="GitLab CI/CD" %} ```yaml check_translations: image: node:20-alpine script: - npx lingo.dev@latest run --frozen ``` {% /tab %} {% tab label="Bitbucket Pipelines" %} ```yaml pipelines: default: - step: image: node:20 script: - npx lingo.dev@latest run --frozen ``` {% /tab %} {% /tabs %} ## Resolving merge conflicts Merge conflicts occur when the `i18n.lock` file diverges between branches - typically when translations are updated independently in different branches. ### Prevention Committing translations directly to `main` (instead of using feature branches for translations) eliminates lockfile conflicts entirely. ### Resolution via merge {% steps %} {% step title="Start the merge" %} ```bash git merge ``` {% /step %} {% step title="Delete the conflicting lockfile" %} ```bash rm i18n.lock ``` {% /step %} {% step title="Complete the merge" %} ```bash git add . git merge --continue ``` {% /step %} {% step title="Regenerate the lockfile" %} ```bash npx lingo.dev@latest lockfile --force ``` This rebuilds the lockfile from the current state of your source files without triggering new translations. {% /step %} {% /steps %} ### Resolution via rebase The same approach works with rebase - delete `i18n.lock` during each conflict step, continue the rebase, then regenerate the lockfile at the end: ```bash git rebase # On each conflict: rm i18n.lock && git add . && git rebase --continue npx lingo.dev@latest lockfile --force ``` ## Next Steps {% card-grid %} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Set up the official GitHub Action" icon="code" /%} {% link-card title="i18n.lock" href="/docs/cli/lockfile" description="How the lockfile tracks translation state" icon="shield" /%} {% link-card title="How It Works" href="/docs/workflows" description="The CI/CD localization pipeline" icon="book" /%} {% link-card title="Setup" href="/docs/workflows/setup" description="Configure CI/CD for your project" icon="rocket" /%} {% /card-grid %} - [Bitbucket Pipelines](https://lingo.dev/en/docs/workflows/bitbucket): Set up Lingo.dev continuous localization in Bitbucket Pipelines using the official Pipe - with workflow examples for direct commits and pull request modes. The Lingo.dev Bitbucket integration uses an official Pipe to run the localization pipeline. It commits translations directly or creates pull requests, with automatic conflict resolution through rebasing. {% callout type="info" title="Prerequisites" %} Complete the [CI/CD Setup](/docs/workflows/setup) first. You need a working `i18n.json` and `LINGODOTDEV_API_KEY` stored as a repository variable. {% /callout %} ## Authentication Add your API key as a repository variable: **Repository settings > Repository variables**. For pull request mode, also create a Bitbucket access token: 1. **Repository settings > Access tokens > Create Repository Access Token** 2. Grant scopes: **Read & write repositories**, **Read & write pull requests** 3. Add as repository variable named `BB_TOKEN` ## Workflow examples ### Direct commit (default) ```yaml image: name: atlassian/default-image:2 pipelines: branches: main: - step: name: Translate script: - pipe: lingodotdev/lingo.dev:main ``` ### Pull request mode ```yaml image: name: atlassian/default-image:2 pipelines: branches: main: - step: name: Translate script: - pipe: lingodotdev/lingo.dev:main variables: LINGODOTDEV_PULL_REQUEST: "true" ``` ### Feature branch with full configuration ```yaml image: name: atlassian/default-image:2 pipelines: branches: feat/*: - step: name: Translate script: - pipe: lingodotdev/lingo.dev:main variables: LINGODOTDEV_API_KEY: "${MY_LINGODOTDEV_API_KEY}" BB_TOKEN: "${MY_ACCESS_TOKEN}" LINGODOTDEV_PULL_REQUEST: "true" LINGODOTDEV_PULL_REQUEST_TITLE: "feat: update translations" LINGODOTDEV_COMMIT_MESSAGE: "feat: update translations" LINGODOTDEV_WORKING_DIRECTORY: "apps/web" ``` ## Configuration variables | Variable | Default | Description | | --- | --- | --- | | `LINGODOTDEV_API_KEY` | Required | Lingo.dev API key | | `BB_TOKEN` | Required for PR mode | Bitbucket access token | | `LINGODOTDEV_PULL_REQUEST` | `false` | Create pull request instead of direct commit | | `LINGODOTDEV_PULL_REQUEST_TITLE` | `feat: update translations via @lingodotdev` | Custom PR title | | `LINGODOTDEV_COMMIT_MESSAGE` | `feat: update translations via @lingodotdev` | Custom commit message | | `LINGODOTDEV_WORKING_DIRECTORY` | `.` | Working directory for monorepos | | `LINGODOTDEV_PROCESS_OWN_COMMITS` | `false` | Process commits made by this integration | ## Next Steps {% card-grid %} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Set up GitHub Actions integration" icon="code" /%} {% link-card title="GitLab CI/CD" href="/docs/workflows/gitlab" description="Set up GitLab CI/CD integration" icon="code" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Translation checks, merge conflicts, workflow selection" icon="gear" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route CI/CD translations through your engine" icon="plug" /%} {% /card-grid %} ## Docs - [Welcome](https://lingo.dev/en/docs/api): The Lingo.dev API translates your content through a localization engine you configure once – glossary, brand voice, instructions, and per-locale model selection applied on every call. Translate one locale in a single request, or fan out to many locales as background jobs. You have content in one language and users who read in many. The API exists to close that gap programmatically: you send text, you get it back translated, and every translation runs through a [localization engine](/docs/platform/engines) you configure once. The engine applies your [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), [instructions](/docs/platform/instructions), and [per-locale model selection](/docs/platform/llm-models) on every call – so the output reads the way your team decided it should, not the way a generic model guessed. That leaves one decision: how much you need to translate at once. Translate a single locale in one request and read the result straight out of the response, or hand the platform many locales and let it translate each one as an independent background job while your app stays responsive. This page covers the base URL, that one decision, and where to go next. {% callout type="info" title="What this API assumes" %} This is the reference for translating programmatically. It assumes you have decided to localize from your own code or pipeline – not through the dashboard – and that you have an API key. New to the platform? The [localization engine](/docs/platform/engines) concept is the one to understand first; everything here runs through it. {% /callout %} **On this page** - [Base URL](#base-url) - [Two ways to translate](#two-ways-to-translate) - [Asynchronous: many locales as jobs](#asynchronous-many-locales-as-jobs) - [Synchronous: one request, one response](#synchronous-one-request-one-response) - [Next steps](#next-steps) ## Base URL Every REST endpoint in this reference lives under one host: ``` https://api.lingo.dev ``` Every request also carries one header, `X-API-Key`. The key is organization-scoped and shown once at creation; the full rules for sending it live on [Authentication](/docs/api/authentication), and what comes back when a request is rejected lives on [Errors and status codes](/docs/api/errors). ## Two ways to translate The same engine, the same glossary and brand voice, sits behind both modes. What differs is who owns the wait. A **synchronous** call translates one locale pair and returns the translated data in the response. It is the simpler call – one request, one response, no endpoint to run on your side – and it is the right reach when you need a single locale and can wait for one round-trip. But content rarely ships to one locale. A training module reaches 14 languages; a CMS entry fans out to every market you sell in. Fire one sync call per locale and you own 14 round-trips and the retry logic when one fails; wait on a single sync call for all of them and you block on the slowest. **Therefore** the API also offers an asynchronous mode: you POST your content once with the target locales, get a `202` immediately, and the platform translates each locale as an independent background job – owning the retries and the failure isolation while your app stays responsive. Reach for async when the job is too big, too slow, or too many-locale to block on. Reach for sync when one locale pair in one response is all you need. The async pages come first below because they carry the most, but neither is the "real" API – they are two shapes of the same engine. ## Asynchronous: many locales as jobs One request, every locale, results as they land. The async API takes one submission, creates one job per target locale, and delivers each result the moment it finishes – by webhook or WebSocket – without your app blocking on any of it. {% card-grid %} {% link-card title="Async Localization API" href="/docs/api/localization" icon="lightning" description="POST content once, fan out to one independent job per target locale, receive each result as it lands." /%} {% link-card title="Localization Pipeline" href="/docs/api/pipeline" icon="gear" description="Wrap the translate step with optional stages – pre-edit, human review, rephrase, back-translation." /%} {% link-card title="Async Provisioning API" href="/docs/api/provisioning" icon="globe" description="Configure an engine from what you already have – point it at links or text and let the AI extract glossary, brand voice, and instructions." /%} {% /card-grid %} ## Synchronous: one request, one response When you need one locale pair and can wait for the round-trip, call a sync endpoint and read the result straight out of the response – no webhook endpoint, no polling. {% card-grid %} {% link-card title="Localize" href="/docs/api/localize" icon="lightning" description="Translate key-value pairs from one locale to another." /%} {% link-card title="Recognize" href="/docs/api/recognize" icon="globe" description="Detect the language of arbitrary text and get structured locale metadata." /%} {% /card-grid %} ## Next steps Whichever mode you pick, the engine behind it is yours to tune. Mint a key, then shape what the engine does on every call – the model it selects per locale, the terms it must translate exactly. {% card-grid %} {% link-card title="API Keys" href="/docs/platform/api-keys" icon="shield" description="Generate and manage API keys for your organization." /%} {% link-card title="LLM Models" href="/docs/platform/llm-models" icon="gear" description="Configure per-locale model selection and fallback chains." /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Map source terms to exact translations per locale." /%} {% /card-grid %} - [@lingo.dev/cli](https://lingo.dev/en/docs/cli-async): A CLI for the Lingo.dev localization engine: push source files, wait while the engine translates them, pull the results — works across machines via lockfile-tracked run state. `@lingo.dev/cli` ships your source content to a [localization engine](/docs/platform/engines), waits while the engine produces translations, and writes the outputs back to disk. It's the replacement for the legacy `npx lingo.dev` flow — same project, fundamentally different architecture. ## What changed vs. the legacy CLI The legacy CLI (`npx lingo.dev run`) extracted strings, called an LLM directly from your machine, and wrote files in one synchronous pass. The new CLI is **async-by-design**: - **`lingo push`** uploads sources to your engine, kicks off a server-side workflow, and either waits for completion or returns immediately with a run ID - **`lingo pull`** fetches outputs from the most recent push — works even if you closed the terminal mid-translation, or are pulling from a different machine - A **lockfile** (`.lingo/lock.json`) tracks the last-known-server version of every target so conflict detection can flag local edits before they get overwritten This unlocks two things the legacy CLI couldn't do: long-running translations without a hanging terminal, and pulling results on a different machine than the one that ran push (or in CI). ## Waiting for results Today, `lingo push` uploads sources, starts the server-side workflow, **waits** for it to finish, and writes the outputs — all in one command. Passing `--wait` (`-w`) makes that blocking behavior explicit. You can also re-attach to a finished run later with `lingo pull`. ```bash lingo push # submit, wait, and write outputs (current default) lingo push --wait # same thing, made explicit lingo pull # later: re-attach to the most recent push and download its outputs ``` {% callout type="info" %} **Upcoming change:** a pending release flips the default to **async**. `lingo push` will submit the run and exit immediately; you'll run `lingo pull` to download the finished translations, and `--wait` (`-w`) becomes how you opt back into the one-command blocking flow. {% /callout %} - `--wait` (`-w`) blocks until the workflow finishes and writes outputs in the same command. - `lingo pull` re-attaches to the most recent push for this project and downloads its outputs — works even after you closed the terminal. Run state is per-machine at `~/.lingo/runs/.json`, so `pull` resumes on the same machine. Auth: both commands read `LINGO_API_KEY` (or `--api-key`, or a `lingo login` session). In CI, set `LINGO_API_KEY` and nothing else is needed. ## push modes | Command | Mode | When | |---|---|---| | `lingo push` | Incremental — diffs source vs `.lingo/lock.json`, translates only new/changed keys into existing targets, preserves the rest | Every routine run / CI | | `lingo push --backfill-missing` | Bootstrap — fills target FILES that don't exist yet | First push, or after adding a new locale | | `lingo push --force` | Full re-translate — overwrites every target (incl. manual edits); `--yes`/`-y` skips the prompt | Rarely (e.g. after a glossary/engine change) | {% callout type="warning" %} `--backfill-missing` is a bootstrap flag. It does a scoped fresh request and only adds whole target files that are missing — it does NOT translate newly-added keys into already-translated files (the run reports "already up-to-date" and the key is skipped). For ongoing edits use plain `lingo push`. {% /callout %} ## Editing translations by hand Plain `lingo push` preserves manual edits per key: - Edit a target string (its source unchanged) → that string is kept; other keys keep updating. - The source behind an edited key changes → a fresh translation is generated for that key, replacing the manual edit. - A new source key is added → translated and added, even into files with manual edits. ## What's in this section {% link-card title="Quickstart" href="/docs/cli-async/quickstart" description="Install, authenticate, link to an engine, run your first push and pull." /%} {% link-card title="Configuration" href="/docs/cli-async/configuration" description="`.lingo/config.json`, `.lingo/lock.json`, and per-machine run state at `~/.lingo/runs/.json`." /%} {% link-card title="lingo push" href="/docs/cli-async/push" description="Send sources, wait for translation, write outputs. Scoped patterns, `--force`, retry semantics." /%} {% link-card title="lingo pull" href="/docs/cli-async/pull" description="Fetch the last push's outputs — across machines, across terminal sessions. Conflict detection." /%} {% link-card title="Other commands" href="/docs/cli-async/commands" description="login, logout, link, unlink, whoami — the setup and identity commands." /%} - [Continuous Localization](https://lingo.dev/en/docs/workflows): Lingo.dev keeps translations in sync with your code - use the GitHub App to run localization automatically, or the GitHub Action and CLI to run it in your own pipeline. Translate changed content, commit results or open pull requests, and keep incomplete translations out of production. Lingo.dev keeps translations in sync with your code. On every change, it detects what content changed, translates it using your connected [localization engine](/docs/platform/engines) - with glossary rules, brand voice, and per-locale model configuration applied consistently - and commits the results or opens a pull request. Incomplete translations never reach production. ## Choose your integration Each integration has its own guide. Pick the one that matches your setup: | Integration | How it runs | | --- | --- | | **[GitHub App](/docs/workflows/github-app)** | Install once. Lingo.dev runs localization for you on pushes to the default branch, and on pull requests when enabled - no runner, no API key secret, no lockfile. | | **[GitHub Actions](/docs/workflows/github)** | Runs the CLI in your GitHub Actions pipeline via the official Action. | | **[GitLab CI/CD](/docs/workflows/gitlab)** | Runs the CLI in GitLab pipelines via the official Docker image. | | **[Bitbucket Pipelines](/docs/workflows/bitbucket)** | Runs the CLI in Bitbucket pipelines via the official Pipe. | Apart from the GitHub App, every integration runs the [Lingo.dev CLI](/docs/cli) - so any CI/CD environment with Node.js can run localization directly, even without a first-party integration. ## How the GitHub App works Install the app once and add a `.lingo/config.json` to the repository. From then on, Lingo.dev runs localization for you - no pipeline, no API key secret, no lockfile: 1. **Watches for changes** - reacts to pushes on the default branch out of the box, and to pull requests once you enable `onPullRequest`, checking changed files against the source patterns you configure 2. **Translates the delta** - sends changed source content through the engine named by `engineId` 3. **Writes results back to GitHub** - on default-branch pushes it opens or updates a translation pull request; on pull requests it commits translated files to the PR branch and posts a status comment 4. **Recovers and batches** - detects changes missed by an earlier run and splits very large updates across multiple commits You can gate runs behind an approval step or trigger translations manually with `/lingo` commands in a pull request. See the [GitHub App guide](/docs/workflows/github-app) for the full configuration. ## How the pipeline integrations work The GitHub Action, GitLab CI/CD, Bitbucket Pipelines, and the standalone CLI all run the same Lingo.dev CLI as a step in your existing pipeline. They need two things: your [`i18n.json`](/docs/cli/configuration) configuration and an API key. On each run, the integration: 1. **Discovers source files** - reads your [bucket configuration](/docs/cli/configuration) to find translatable content 2. **Detects changes** - compares against the [`i18n.lock`](/docs/cli/lockfile) lockfile to identify new or modified strings, so only the delta gets translated 3. **Translates** - sends changed content through your configured [localization engine](/docs/platform/engines) with all rules applied - glossary, brand voice, per-locale model settings 4. **Writes results** - updates target locale files in place 5. **Commits or opens a PR** - depending on the workflow you choose Because only changed strings are translated, runs are fast and cost-efficient - even across dozens of locales. ## Workflow options ### GitHub App The App's behavior is configured in `.lingo/config.json`: | Option | What it does | | --- | --- | | Push to default branch (`onPushToDefaultBranch`) | Enabled by default. Opens or updates a translation PR when source changes land on the default branch. | | Pull request translation (`onPullRequest`) | Disabled by default. Commits translations to the PR branch as the PR changes. | | Approval gate (`requireApproval`) | Disabled by default. Requires Approve/Deny on the check run, or `/lingo approve` on a PR, before automatic runs translate. | | Manual commands (`/lingo translate`) | Backfill or force translations for specific files from a PR comment, any time. | See the [GitHub App guide](/docs/workflows/github-app) for full config and command reference. ### GitHub Action, GitLab CI, Bitbucket, and CLI Four workflow patterns cover most team setups: | Workflow | Trigger | Output | | --- | --- | --- | | Commit to main | Push to `main` | Translations committed directly to `main` | | PR from main | Push to `main` | Pull request with translations | | Commit to feature branch | Push to feature branch | Translations committed to the branch | | PR from feature branch | Push to feature branch | Pull request from the branch | The first option - commit to main - is the simplest. Translations appear automatically with zero developer intervention. The PR-based options add a review step before translations land. For details on choosing between these, see [Advanced Patterns](/docs/workflows/advanced). ## Next Steps {% card-grid %} {% link-card title="GitHub App" href="/docs/workflows/github-app" description="Managed continuous localization - install once, no pipeline" icon="git-branch" /%} {% link-card title="Setup" href="/docs/workflows/setup" description="Configure the GitHub Action or CLI" icon="rocket" /%} {% link-card title="GitHub Actions" href="/docs/workflows/github" description="Set up the official GitHub Action" icon="code" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" description="Workflow selection, translation checks, merge conflicts" icon="gear" /%} {% /card-grid %} - [Localization MCP](https://lingo.dev/en/docs/mcp): Give AI coding assistants direct access to your localization engine configuration through the Lingo.dev MCP server. The Lingo.dev MCP server gives AI coding assistants direct access to your localization engine configuration. Your assistant can create glossary entries, adjust brand voice, add instructions, configure models, and manage API keys - without leaving the conversation. ## Why this matters When we studied how teams configure localization engines, we found that the majority of configuration changes happen during development - while reviewing localized UI, debugging a locale-specific issue, or onboarding a new market. Switching context to a dashboard breaks flow. The Lingo.dev MCP server was purpose-built to keep localization engineering inside the development environment. Your AI assistant becomes a direct interface to your localization engine: it reads your current configuration, makes precise changes, and confirms the result - all within the same conversation where the problem surfaced. ## Getting Started {% card-grid %} {% link-card title="Setup" href="/docs/mcp/setup" icon="rocket" description="Connect the MCP server to your AI assistant" /%} {% link-card title="Capabilities" href="/docs/mcp/capabilities" icon="gear" description="Full reference of every capability exposed" /%} {% /card-grid %} ## Workflows {% card-grid %} {% link-card title="Provision" href="/docs/mcp/provision" icon="lightning" description="Create a new engine from links and content" /%} {% link-card title="Import" href="/docs/mcp/import" icon="arrows" description="Migrate glossaries from legacy vendors" /%} {% link-card title="Localize" href="/docs/mcp/localize" icon="lightning" description="Run localization directly from the editor" /%} {% link-card title="Observe" href="/docs/mcp/observe" icon="chart" description="Debug localization quality with request logs" /%} {% link-card title="Triage" href="/docs/mcp/triage" icon="target" description="Reproduce a bug and find root cause" /%} {% link-card title="Tune" href="/docs/mcp/tune" icon="sliders" description="Apply feedback to engine configuration" /%} {% link-card title="Review" href="/docs/mcp/review" icon="target" description="Spot-check localizations against engine rules" /%} {% link-card title="Compare" href="/docs/mcp/compare" icon="arrows" description="A/B test two engines side by side" /%} {% link-card title="Extend" href="/docs/mcp/extend" icon="globe" description="Add a new locale to your engine" /%} {% /card-grid %} - [Lingo.dev CLI](https://lingo.dev/en/docs/cli): The Lingo.dev CLI reads your i18n.json config, discovers translatable content, computes a delta against the lockfile, sends only changed strings through your localization engine, and writes results back to disk. The Lingo.dev CLI translates apps and content by reading a single `i18n.json` configuration file, extracting translatable strings from your source files, and routing them through a [localization engine](/docs/platform/engines) or a raw LLM provider. It writes the translations back to disk and tracks what changed so the next run only processes the delta. ## The five-step pipeline When you run `npx lingo.dev@latest run`, the CLI executes five steps in sequence: {% steps %} {% step title="Content discovery" %} The CLI scans your project for source and target files based on the [bucket configurations](/docs/cli/supported-formats) in `i18n.json`. Each bucket defines a file format and a set of include/exclude patterns that tell the CLI where translatable content lives. ```json { "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "json": { "include": ["locales/[locale].json"] }, "markdown": { "include": ["docs/[locale]/*.md"] } } } ``` The `[locale]` placeholder resolves to your configured source and target locale codes at runtime. {% /step %} {% step title="Data cleaning" %} Not all content requires translation. The CLI filters out values that should remain unchanged across languages - numbers, booleans, ISO dates, UUIDs, URLs, and empty strings. This reduces the payload sent to the translation backend, lowering token consumption and processing time. {% /step %} {% step title="Delta calculation" %} The CLI computes SHA-256 fingerprints for every source string and compares them against the previous state stored in [`i18n.lock`](/docs/cli/lockfile). Only new or modified content enters the translation pipeline. Unchanged strings are skipped entirely. This incremental approach means a project with 10,000 keys where 12 changed only translates those 12 keys - not the full set. {% /step %} {% step title="Localization" %} The delta is sent to the configured translation backend. The CLI supports two modes: | Mode | How it works | | --- | --- | | **Lingo.dev Engine** | Routes requests through your [localization engine](/docs/platform/engines), applying [brand voice](/docs/platform/brand-voices), [glossary](/docs/platform/glossaries), [instructions](/docs/platform/instructions), and [model configuration](/docs/platform/llm-models) automatically. | | **Raw LLM provider** | Sends translation requests directly to OpenAI, Anthropic, Google, Mistral, OpenRouter, or Ollama with a custom prompt. | The CLI retries failed requests with exponential backoff, saves partial progress, and processes multiple target languages concurrently. {% /step %} {% step title="Content injection" %} Translated strings are written back to disk at the exact positions where source content exists. The CLI preserves file structure and formatting to produce minimal, reviewable diffs. If Prettier is configured in your project, the output respects your formatting rules. {% /step %} {% /steps %} ## Output files A typical run produces two types of changes: 1. **Locale files** - target language files updated with new and modified translations 2. **`i18n.lock`** - updated with content fingerprints for tracking state Both should be committed to version control - either manually or automatically through [CI/CD integration](/docs/cli/large-projects). ## Next Steps {% card-grid %} {% link-card title="Setup" href="/docs/cli/setup" description="Install the CLI and generate your first translations" icon="rocket" /%} {% link-card title="i18n.json" href="/docs/cli/configuration" description="Full configuration reference" icon="gear" /%} {% link-card title="Connect Your Engine" href="/docs/platform/connect-your-engine" description="Route CLI translations through your localization engine" icon="plug" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" description="JSON, YAML, Markdown, and 20+ file formats" icon="file-code" /%} {% /card-grid %} - [The Localization Engineering Platform](https://lingo.dev/en/docs/platform): Lingo.dev is an AI-powered localization engineering platform. It helps product teams turn LLMs into stateful translation APIs - to produce consistent, production-grade translations for apps, docs, and content across every language. Lingo.dev is an AI-powered localization engineering platform. It helps product teams turn LLMs into stateful translation APIs - to produce consistent, production-grade translations for apps, docs, and content across every language. ## Context and Localization Engineering Using LLMs for translation is obvious. Any team can send a string to a model and get a translation back. What makes translations *perfect* is two things: **context** and **localization engineering**. **Context** is what the model knows beyond the string itself - the product, the audience, the brand voice, the locale-specific conventions. Without it, the model guesses. With it, the model localizes. **Localization engineering** is the practice of encoding that context into reproducible infrastructure - glossary rules, formality preferences, cultural adaptations - so every translation, across every locale, applies them consistently. Without both, you get translations. With both, you get localization. ## The Problem Before LLMs, teams had two options - both flawed. **Machine translation** was fast but structurally incapable of understanding product context. Teams shipped MT output knowing it would erode trust in every market. **Manual translation** was accurate but scaled linearly. Every new locale required training linguists on product terminology, brand voice, and domain concepts. After processing 100M+ words across 42 languages, we found that 89% of localization delays happen in handoffs between teams, not in translation itself. Both treated localization as a project management workflow. Lingo.dev treats it as an engineering concern. ## What You Build On Lingo.dev, teams build their own localization engines. Each engine combines: - **LLM models per locale** - Pick the right model for each language pair with ranked fallbacks. - **Brand voice** - Define how your product speaks per language. Formal "Sie" in German, informal "tu" in Italian, polite "vous" in French. - **Glossary** - Map source terms to exact translations per locale. "911" becomes "112" for European markets. Product names stay untranslated. - **Instructions** - Encode linguistic rules generic models miss. Adjective positioning in Spanish, space before percentage signs, pronoun formality per market. - **Quality scoring** - GEMBA scores, BERTScore, glossary compliance, locale-specific validators. Continuous, automatic. The result: teams use their own unique insights and preferences - combined with Lingo.dev's language engineering research, ongoing since 2023 - to scale globally from day one, predictably, in languages they don't speak. ## Open Source Developer Tools The Lingo.dev open-source community (5,100+ GitHub stars) builds developer tools that connect codebases to localization engines: - **CLI** - Translate from the command line. From install to first translated build in 4 minutes. - **CI/CD** - GitHub Actions, GitLab CI, Bitbucket Pipelines. Translations ship with your code. - **Compiler** - Build-time i18n. No runtime overhead, no layout shift. - **I18n MCP** - Localization awareness for AI coding assistants: Claude Code, Cursor, GitHub Copilot. ## Next Steps {% card-grid %} {% link-card title="Localization Engines" href="/docs/platform/engines" icon="gear" description="Learn how engines combine models, glossaries, brand voice, and scoring" /%} {% link-card title="AI Reviewers" href="/docs/platform/ai-reviewers" icon="lightning" description="Set up automated translation quality monitoring" /%} {% link-card title="CLI Quick Start" href="/docs/cli" icon="terminal" description="Install the CLI and run your first translation" /%} {% link-card title="API Reference" href="/docs/api" icon="code" description="Integrate the localization API into your workflow" /%} {% /card-grid %} ## Engineering - [Every RAG-based localization pipeline has the same blind spot](https://lingo.dev/en/engineering/rag-localization-glossary-retrieval): Sentence-level embeddings dissolve phrase-level glossary terms. N-gram decomposition, adaptive retrieval modes, and continuous threshold calibration fix the granularity mismatch that makes terminology drift invisible. If a localization pipeline uses retrieval augmented generation to inject glossary terms into the model's context window, it has a retrieval recall problem that has never been measured. The pattern is universal: embed the input text, cosine-search a term bank, inject top-k results into the prompt. The output is grammatically correct. The terminology is wrong. The error is invisible unless someone speaks both languages and knows the glossary. We built this naive version first. Then we measured retrieval recall against production glossaries – and it turned out the system was missing the majority of applicable terms on real payloads. | | | | --- | --- | | **Technique** | Retrieval augmented localization (RAL) – context enrichment at inference time | | **Core fix** | N-gram decomposition before embedding, not sentence-level embedding | | **Retrieval modes** | 3 (skip / preload / vector search), selected per-request by glossary cardinality | | **Threshold calibration** | Continuous, weekly, against per-locale-pair quality scores | | **Terminology error reduction** | 17–45% across five LLM providers ([controlled study](/research/retrieval-augmented-localization), 42,000+ quality judgments) | | **Scoring** | Independent cross-model evaluation, asynchronous, per-request | ## Why do sentence embeddings miss glossary terms? A glossary term is 1–3 words. "Localization engine." "Access token." "Deployment pipeline." Input text is a JSON object with values ranging from two words (a button label) to two hundred words (a product description). When the full string "Configure the localization engine for production deployment" is embedded, the resulting vector captures the semantic meaning of the sentence – something about configuration and production systems. The glossary-relevant phrase "localization engine" dissolves into the sentence-level representation. Cosine similarity between that sentence vector and the glossary entry "localization engine" lands in the 0.6–0.7 range. Below retrieval threshold. The term exists in the input. The retrieval system misses it. The issue is granularity: sentence-level representations querying phrase-level targets. The embedding model faithfully represents the meaning of the sentence as a whole. Constituent terminology occupies no independent region of the vector space. We found this out the hard way. On production payloads – nested JSON objects with 20–50 keys, values of varying length – sentence-level retrieval was missing the majority of applicable glossary terms. The localization request completed fine. The output read fluently. But "localization engine" was becoming "translation tool" – grammatically valid, semantically adjacent, terminologically wrong. And the pipeline reported success. ## How does n-gram decomposition fix glossary retrieval? The fix turned out to be decomposing input into phrase-level units before embedding. Every string value becomes a set of overlapping n-gram windows: ``` Input: "Configure the localization engine for production" 1-grams: [configure, the, localization, engine, for, production] 2-grams: [configure the, the localization, localization engine, engine for, for production] 3-grams: [configure the localization, the localization engine, localization engine for, engine for production] ``` Each n-gram becomes an independent retrieval query. "Localization engine" queries the glossary as a standalone phrase – and finds its match at high similarity. The decomposition pipeline: 1. Recursively extract all string values from nested JSON structures 2. Split into sentences, strip HTML and markup annotations 3. Normalize whitespace, remove enclosing quotes, unescape formatting 4. Generate overlapping 1-gram, 2-gram, and 3-gram phrases from each sentence A 50-word paragraph yields approximately 150 n-grams. A typical API payload with 20 keys yields 1,000–3,000 searchable phrases. Each phrase is embedded independently, each embedding runs a nearest-neighbor query against the glossary's vector index. We measured the difference on the same production payloads that exposed the original problem. Glossary terms now match regardless of the sentence context surrounding them – a 2-word term buried in a 200-word product description retrieves with the same recall as a standalone label. ## How does adaptive retrieval work for different glossary sizes? N-gram decomposition and batch embedding is the correct approach for large glossaries. For small ones, it turned out to be computationally wasteful. A localization engine configured with 8 glossary terms resolves faster with direct injection – one database query, deterministic, sub-millisecond. A localization engine with 2,000 terms requires vector search – context window limits and relevance dilution make full injection impossible. Three retrieval modes operate per-request, selected based on glossary cardinality for the locale pair: | Mode | Condition | Behavior | | --- | --- | --- | | **Skip** | Zero matching items | No embedding, no search, no injection | | **Preload** | Below cardinality threshold | Single database query loads all matching items; direct injection | | **Search** | Above cardinality threshold | Full n-gram decomposition → batch embedding → vector nearest-neighbor search | The cardinality threshold that separates preload from search is derived from latency profiling across production traffic and adjusted as embedding model performance, glossary size distributions, and infrastructure characteristics shift. The initial value we shipped lasted approximately three weeks before telemetry indicated it should move. It has been adjusted multiple times since – we discovered that the optimal threshold drifts as engines accumulate glossary terms and embedding model characteristics evolve between provider updates. Retrieval latency scales with glossary complexity, not payload size. A localization engine with 10 terms resolves in single-digit milliseconds regardless of input length. A localization engine with 500 terms uses the full decomposition pipeline but resolves within the latency budget of a durable background workflow step. ## How is the similarity threshold calibrated for glossary retrieval? Each n-gram embedding queries the vector index for nearest neighbors above a similarity threshold. Matches below the threshold are discarded as noise. The threshold determines retrieval precision and recall simultaneously: - **Too permissive:** unrelated terms leak into the prompt. The model sees glossary context that does not apply to the input and occasionally follows it – producing output that uses terminology from an unrelated domain. - **Too strict:** legitimate variant phrasings and morphological forms get excluded. "Deploying" fails to match the glossary entry for "deploy." Recall drops. We found that the right threshold varies by locale pair. English→German retrieval has different similarity distributions than English→Japanese, where morphological distance between source n-grams and glossary entries differs structurally. A single global threshold was producing inconsistent recall across the locale pairs we measured. The threshold is now calibrated continuously against per-locale-pair quality scores from an independent scoring pipeline. When the scoring system detects an increase in glossary non-adherence (terms present in input but absent from output), retrieval recall has degraded and the threshold is loosened. When scoring detects the model applying irrelevant terminology, false-positive injection has increased and the threshold is tightened. This calibration runs weekly. It has to – embedding model behavior shifts between provider updates, glossary distributions change as teams add terms, and input text characteristics evolve as products grow. ## How are retrieved glossary terms injected into the localization model? Retrieved glossary items split into two constraint classes with different enforcement behavior in the model's system prompt: **Non-translatable terms** – source-language strings that must appear unchanged in the target output. Brand names, technical identifiers, product names. The model preserves these verbatim. **Custom translations** – source→target mappings that override the model's own judgment. "Localization engine" must become "moteur de localisation." The model treats these as non-negotiable lexical constraints. Both classes are injected into the system prompt as rules with explicit precedence over the model's default behavior. The prompt hierarchy enforces glossary compliance above the model's linguistic preferences. The distinction matters at scoring time: the independent scoring model checks whether non-translatables were preserved unchanged and whether custom translations were applied exactly. Two verification criteria for two constraint types. We discovered early that conflating them into a single "glossary" category made scoring unreliable – a term preserved verbatim when it should have been translated (or vice versa) would score as correct under a unified check. ## How do you validate localization quality in languages you don't speak? The entire retrieval and localization pipeline can execute without error and produce terminologically incorrect output. A missed glossary term produces no error signal. A misapplied custom translation returns a 200. The pipeline succeeds. The output is wrong. This is the localization observability gap that most teams never close. Retrieval is coupled with independent asynchronous scoring. After a localization request completes, separate scoring models evaluate the output against the localization engine's configuration: - **Glossary adherence** – were non-translatable terms preserved? Were custom translations applied exactly? - **Instruction adherence** – were locale-specific rules followed? - **Custom scoring criteria** – per-engine quality dimensions defined by the localization team The scoring models run on different infrastructure than the localization model. They operate asynchronously in background workflows, triggered after every request that passes through a localization engine with scoring enabled. One model localizes; a different model scores. Cross-model evaluation removes the self-grading problem. Scoring results feed back into retrieval calibration: 1. Scoring detects glossary non-adherence trending upward for a locale pair 2. Investigation reveals retrieval recall has dropped – the threshold has drifted relative to the current glossary distribution 3. Threshold is adjusted; recall recovers; adherence scores stabilize The loop is what makes the system self-correcting. Scoring creates the observability that retrieval alone lacks. Without it, teams are shipping localized content into languages they do not speak, with no signal on whether the glossary they built is actually being applied. ## Why does retrieval recall compound over time? Every localization request that correctly applies glossary terms reinforces terminology consistency across the product. Every request that misses a term introduces drift – one surface says "localization engine," another says "localization tool," a third says "localization module." Across 30 locales and weekly releases, these inconsistencies compound. The difference between high and low retrieval recall is not a per-request quality delta. It is a compounding consistency mechanism. High recall means the glossary enforces uniformly across every surface, every locale, every release. Low recall means the glossary occasionally fires – structurally equivalent to having no glossary, just slower to degrade. ## What this means for localization engineering The retrieval problem described here is not specific to one implementation. It is structural to any system that attempts glossary-aware localization using embedding-based search. The granularity mismatch between sentence-level input representations and phrase-level glossary targets exists regardless of which embedding model, which vector database, or which LLM generates the output. Teams building localization automation face a choice: accept sentence-level retrieval with its invisible recall gap, or build the decomposition and calibration infrastructure that closes it. The second path requires three systems – n-gram decomposition, adaptive retrieval, and a scoring loop that feeds back into threshold management. Each system has its own operational cadence: decomposition logic evolves as input formats change, retrieval thresholds shift as glossaries grow, and scoring criteria are refined as localization teams learn what dimensions matter for their content. Retrieval augmented localization at production quality is an ongoing engineering practice – a system that is built, instrumented, observed, and tuned continuously. The localization engineering discipline emerging around this work reflects the operational reality: localization infrastructure requires the same continuous attention that backend services, CI/CD pipelines, and observability stacks demand. --- ## Next steps {% card-grid %} {% link-card title="RAL research" href="/research/retrieval-augmented-localization" description="Controlled study: 42,000+ quality judgments, 17–45% terminology error reduction" icon="book" /%} {% link-card title="Localization engines" href="/docs/platform/engines" description="Configure glossary, brand voice, model chains, and AI reviewers" icon="gear" /%} {% link-card title="The Localization API" href="/blog/the-localization-api" description="The async API that runs this pipeline behind a single POST" icon="code" /%} {% /card-grid %} --- ## FAQ **What is retrieval augmented localization (RAL)?** Retrieval augmented localization enriches each localization request with glossary terms, brand voice rules, and locale-specific instructions at inference time – the same retrieve-inject pattern behind RAG, applied to localization. In a controlled study across five LLM providers and five European languages, RAL reduced terminology errors by 17–45% compared to the same models without context enrichment. **Why does sentence-level embedding miss glossary terms?** Glossary terms are typically 1–3 words. When embedded as part of a full sentence, they dissolve into the sentence-level semantic vector. The embedding captures the meaning of the sentence as a whole – "localization engine" inside "Configure the localization engine for production" does not independently register. Cosine similarity between the sentence vector and the glossary entry falls below retrieval threshold. **How does n-gram decomposition improve retrieval recall?** Instead of embedding full input strings, the system decomposes text into overlapping 1-gram, 2-gram, and 3-gram phrases before embedding. Each phrase becomes an independent retrieval query. A 2-word glossary term buried in a 200-word paragraph matches at the same recall as a standalone label – because it is queried independently of its surrounding context. **How many retrieval modes does the system use?** Three. Skip (zero glossary items – no retrieval needed), preload (below a cardinality threshold – load all items directly), and vector search (above threshold – full n-gram decomposition and embedding). The mode is selected per-request based on glossary cardinality for the specific locale pair. **How is the similarity threshold maintained?** The threshold is calibrated weekly against per-locale-pair quality scores from an independent scoring pipeline. When glossary non-adherence trends upward, the threshold is loosened to improve recall. When irrelevant terms leak into prompts, the threshold is tightened. Different locale pairs require different thresholds due to varying morphological distances. **How does cross-model scoring work for localization quality?** After each localization request completes, a separate model – running on different infrastructure – evaluates whether glossary terms were correctly applied, whether locale-specific instructions were followed, and whether custom quality criteria were met. One model localizes; a different model scores. This removes self-grading bias and creates the observability that retrieval alone lacks. **What happens when glossary retrieval recall is low?** Low retrieval recall means the glossary fires inconsistently – one surface gets the correct term, another does not. Across 30+ locales and weekly releases, these inconsistencies compound into terminology drift. The glossary exists but does not enforce. Over months, this is structurally equivalent to having no glossary. **What is the localization observability gap?** A localization pipeline can execute without error and produce terminologically incorrect output. Missed glossary terms produce no error signal – the API returns 200, the translation is grammatically valid. The observability gap is the space between "pipeline succeeded" and "terminology is correct." Independent scoring closes this gap by measuring glossary adherence on every request. ## Guides - [CI/CD Localization Workflows](https://lingo.dev/en/guides/ci-cd-workflows): How to automate localization in CI/CD using Lingo.dev - run it with the GitHub App, or the CLI in GitHub Actions, GitLab CI, or Bitbucket Pipelines to translate on every push. The Lingo.dev [CLI](/docs/cli) runs in any CI/CD environment with Node.js. Add it as a pipeline step to translate on every push - the [lockfile](/docs/cli/lockfile) ensures only changed strings are processed, so translations stay fast and cost-efficient as your project grows. {% callout type="info" title="On GitHub? Two ways to run" %} The [**GitHub App**](/docs/workflows/github-app) is the easiest option on GitHub - install it once and it reacts to pushes and pull requests automatically. No runner, no API key secret, and no lockfile; you configure the repository with `.lingo/config.json` and an `engineId`. The **GitHub Action** and the other integrations below run the CLI inside your own pipeline using `i18n.json`, an `i18n.lock` lockfile, and a `LINGODOTDEV_API_KEY` secret. Use this path when you want translation to run alongside other CI steps, or when you're not on GitHub. The rest of this guide covers the GitHub Action and CLI. {% /callout %} ## How It Works The CI/CD pipeline runs the CLI as a step after checkout. The CLI reads your `i18n.json` configuration, compares source files against the lockfile to identify changes, translates the delta through a configured [localization engine](/docs/platform/engines), and writes results to target locale files. The pipeline then commits the translated files or opens a pull request - depending on your workflow preference. ## Choose Your Workflow Four workflow patterns cover most team structures. Start with the simplest and graduate as your team grows. | Workflow | How it works | Best for | Trade-off | | --- | --- | --- | --- | | **Commit to main** | Translates and commits directly to main | Small teams, zero friction | No review step for translations | | **PR from main** | Translates and opens a PR for review | Teams that review translations | Requires manual PR approval | | **Commit to feature branch** | Translates on feature branch push | Long-lived feature branches | Translation commits in branch history | | **PR from feature branch** | Translates and opens PR from feature branch | Maximum control per feature | Multiple PRs per feature | {% callout type="info" title="Starting recommendation" %} Commit to main works well for most teams. Translations ship with every push, the lockfile ensures consistency, and the localization engine's glossary and brand voice rules handle quality. Move to PR-based workflows when you need human review of translations. {% /callout %} ## Quick Setup Store your Lingo.dev API key as a CI/CD secret, then add the translation step to your pipeline. {% tabs %} {% tab label="GitHub Actions" %} Lingo.dev provides an [official GitHub Action](/docs/workflows/github) that handles checkout, translation, and commit/PR creation. {% callout type="info" %} Prefer not to manage a workflow file, an API key secret, or a lockfile? The [GitHub App](/docs/workflows/github-app) does continuous localization on GitHub with none of that - install once and configure `.lingo/config.json`. {% /callout %} **Commit to main:** ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` **PR from main** - add `pull-request: true` and a `GH_TOKEN`: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ github.token }} ``` See the full [GitHub Actions integration guide](/docs/workflows/github) for feature branch workflows, custom commit messages, monorepo support, and GPG signing. {% /tab %} {% tab label="GitLab CI" %} Use the [official Docker image](/docs/workflows/gitlab) `lingodotdev/ci-action:latest` in your `.gitlab-ci.yml`. **Commit to main:** ```yaml image: name: lingodotdev/ci-action:latest entrypoint: [""] stages: - translate translate: stage: translate script: - npx lingo.dev@latest ci --api-key "$LINGODOTDEV_API_KEY" only: - main ``` **Merge request from main** - add `--pull-request`: ```yaml translate: stage: translate script: - npx lingo.dev@latest ci --api-key "$LINGODOTDEV_API_KEY" --pull-request only: - main ``` Store your API key as a masked CI/CD variable. See the full [GitLab CI integration guide](/docs/workflows/gitlab) for access token setup and feature branch workflows. {% /tab %} {% tab label="Bitbucket Pipelines" %} Use the [official Bitbucket Pipe](/docs/workflows/bitbucket) `lingodotdev/lingo.dev:main` in your `bitbucket-pipelines.yml`. **Commit to main:** ```yaml image: name: atlassian/default-image:2 pipelines: branches: main: - step: name: Translate script: - pipe: lingodotdev/lingo.dev:main ``` **PR from main** - add the `LINGODOTDEV_PULL_REQUEST` variable: ```yaml pipelines: branches: main: - step: name: Translate script: - pipe: lingodotdev/lingo.dev:main variables: LINGODOTDEV_PULL_REQUEST: "true" ``` Store your API key as a repository variable. See the full [Bitbucket Pipelines integration guide](/docs/workflows/bitbucket) for access token setup and feature branch workflows. {% /tab %} {% tab label="Other" %} Any CI/CD environment with Node.js can run the CLI directly: ```bash npx lingo.dev@latest ci --api-key "$LINGODOTDEV_API_KEY" ``` Add `--pull-request` to open a PR instead of committing directly. The `ci` command handles git configuration, translation, and commit/PR creation in a single step. {% /tab %} {% /tabs %} ## Translation Verification {% callout type="info" %} The `--frozen` flag and the lockfile are part of the GitHub Action and CLI. The [GitHub App](/docs/workflows/github-app) tracks translation state server-side and has no lockfile or `--frozen` equivalent. {% /callout %} Use the `--frozen` flag as a deployment gate to ensure no untranslated content ships to production. The CLI exits with a non-zero status if any strings need translation. ```bash npx lingo.dev@latest run --frozen ``` Add this as a separate pipeline step that runs before deploy: {% tabs %} {% tab label="GitHub Actions" %} ```yaml - name: Verify translations run: npx lingo.dev@latest run --frozen ``` {% /tab %} {% tab label="GitLab CI" %} ```yaml verify-translations: stage: test image: node:20-alpine script: - npx lingo.dev@latest run --frozen ``` {% /tab %} {% tab label="Bitbucket" %} ```yaml - step: name: Verify translations image: node:20 script: - npx lingo.dev@latest run --frozen ``` {% /tab %} {% /tabs %} ## Monorepo Workflows For monorepos with multiple packages that each have their own translation files, use the `working-directory` option to target specific packages: {% tabs %} {% tab label="GitHub Actions" %} ```yaml - uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} working-directory: apps/web ``` {% /tab %} {% tab label="GitLab CI" %} ```yaml translate: script: - cd apps/web && npx lingo.dev@latest ci --api-key "$LINGODOTDEV_API_KEY" ``` {% /tab %} {% tab label="CLI" %} ```bash npx lingo.dev@latest run apps/web apps/mobile ``` {% /tab %} {% /tabs %} ## Merge Conflicts {% callout type="info" %} This applies to the GitHub Action and CLI. The [GitHub App](/docs/workflows/github-app) does not use a lockfile, so it has no `i18n.lock` conflicts to resolve. {% /callout %} The lockfile (`i18n.lock`) may conflict when branches with translation changes merge. The resolution is straightforward - delete the conflicting lockfile, complete the merge, and regenerate it: ```bash git merge feature-branch rm i18n.lock git add . git merge --continue npx lingo.dev@latest lockfile --force ``` The `lockfile --force` command rebuilds the lockfile from the current state of your source files without triggering new translations. See the [advanced integration patterns](/docs/workflows/advanced) guide for rebase-based resolution and conflict prevention strategies. ## Next Steps {% card-grid %} {% link-card title="GitHub App" href="/docs/workflows/github-app" icon="git-branch" description="Managed continuous localization on GitHub - no runner, secret, or lockfile" /%} {% link-card title="GitHub Actions" href="/docs/workflows/github" icon="git-branch" description="Full GitHub Actions setup with GPG signing and custom configuration" /%} {% link-card title="GitLab CI" href="/docs/workflows/gitlab" icon="git-branch" description="Full GitLab CI/CD setup with access tokens and merge requests" /%} {% link-card title="Bitbucket Pipelines" href="/docs/workflows/bitbucket" icon="git-branch" description="Full Bitbucket Pipelines setup with pipes and pull requests" /%} {% link-card title="Advanced Patterns" href="/docs/workflows/advanced" icon="gear" description="Workflow selection, conflict resolution, and deployment gates" /%} {% /card-grid %} - [Translation API](https://lingo.dev/en/guides/translation-api): How to localize content programmatically using the Lingo.dev localization API - send key-value data, get translations back through a configured localization engine. The Lingo.dev [localization API](/docs/api) translates key-value data through a configured localization engine. One HTTP call - your strings go in, translated strings come back, with glossary rules, brand voice, and model selection applied automatically. ## When to Use the API Use the localization API when translations happen at runtime or in a backend service - not at build time. | Use case | Example | | --- | --- | | Dynamic content | Translate course descriptions, lesson titles, or quiz questions stored in a database | | User-generated content | Translate reviews, comments, or forum posts on demand | | API responses | Return localized content from your backend based on the user's locale | | Notifications | Translate email subjects, push notifications, or in-app messages before sending | {% callout type="info" title="Build-time vs runtime" %} If your content lives in static files (JSON, Markdown, `.strings`), the [CLI](/docs/cli) or [CI/CD integration](/guides/ci-cd-workflows) is a better fit. The API is designed for localizing content on the backend. {% /callout %} ## Prerequisites {% steps %} {% step title="Create a localization engine" %} Every API call runs through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard. {% /step %} {% step title="Generate an API key" %} API requests authenticate with an `X-API-Key` header. Generate a key in the [API Keys](/docs/platform/api-keys) section. Keys are shown once at creation - store yours securely. {% /step %} {% /steps %} ## Localizing Content Send a `POST` request to the localize endpoint with your source locale, target locale, and key-value data. The full request/response schema is documented in the [API reference](/docs/api). This example translates a paragraph from a JavaScript course into Spanish: ```javascript const content = { intro: "JavaScript is a programming language that powers the interactive elements of most websites. When you click a button, submit a form, or see content update without the page reloading, JavaScript is making that happen.", }; const response = await fetch("https://api.lingo.dev/process/localize", { method: "POST", headers: { "X-API-Key": "your_api_key", "Content-Type": "application/json", }, body: JSON.stringify({ engineId: "eng_abc123", sourceLocale: "en", targetLocale: "es", data: content, }), }); const { data } = await response.json(); // { // intro: "JavaScript es un lenguaje de programacion que impulsa los elementos interactivos de la mayoria de los sitios web. Cuando haces clic en un boton, envias un formulario o ves contenido actualizarse sin que la pagina se recargue, JavaScript lo esta haciendo posible." // } ``` Keys are preserved: you send `intro` and get `intro` back. The engine translates only the values. ## Structuring Keys The `data` object is flat - each key maps to a single string. Structure your keys so they carry semantic context: ```javascript const content = { "variables.intro": "Variables are containers for storing data values. In JavaScript, you declare them with let, const, or var.", "variables.example": "Use const for values that never change, and let for values that do.", "variables.exercise": "Declare a variable called age and assign it your age as a number.", }; ``` Semantically grouped keys help the localization engine produce more coherent translations. The engine sees all keys in a single request as related context - `variables.intro` and `variables.example` translate more consistently when sent together than separately. {% callout type="info" title="Technical terms" %} Terms like "const" or "let" are programming lingo that should not be translated. Use a [glossary](/docs/platform/glossaries) to mark terms as non-translatable, or map them to locale-specific equivalents. If you use an AI coding assistant like Claude Code or Cursor, the [Localization MCP](/docs/mcp) can help configure glossary rules as part of your development workflow. {% /callout %} ## Localizing on Save The typical pattern is to translate content when it's created or updated - not when it's read. This way, translated content is already in the database when a user requests it. ```javascript app.post("/api/lessons", async (req, res) => { const lesson = await db.lessons.create(req.body); const content = { intro: lesson.intro, example: lesson.example, exercise: lesson.exercise, }; const targetLocales = ["es", "fr", "de", "ja"]; const translations = await Promise.all( targetLocales.map(async (targetLocale) => { const response = await fetch("https://api.lingo.dev/process/localize", { method: "POST", headers: { "X-API-Key": process.env.LINGODOTDEV_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ sourceLocale: "en", targetLocale, data: content, }), }); const { data } = await response.json(); return { locale: targetLocale, data }; }) ); await db.lessonTranslations.insertMany( translations.map((t) => ({ lessonId: lesson.id, locale: t.locale, ...t.data, })) ); res.json(lesson); }); ``` Reading localized content then becomes a simple database lookup - no API call needed at read time: ```javascript app.get("/api/lessons/:id", async (req, res) => { const locale = req.query.locale || "en"; if (locale === "en") { const lesson = await db.lessons.findById(req.params.id); return res.json(lesson); } const translation = await db.lessonTranslations.findOne({ lessonId: req.params.id, locale, }); res.json(translation); }); ``` ## Next Steps {% card-grid %} {% link-card title="API Reference" href="/docs/api" icon="code" description="Full request/response schema for localize and recognize endpoints" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Control which technical terms get translated and which stay as-is" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Set formality and tone per target locale" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="Automate localization for static content at build time" /%} {% /card-grid %} - [Engine Setup with the Localization MCP](https://lingo.dev/en/guides/mcp-engine-setup): Configure a localization engine using AI coding assistants through the Lingo.dev MCP server - brand voices, glossaries, instructions, and model routing from a single conversation. The Lingo.dev [MCP server](/docs/mcp) gives AI coding assistants direct access to your localization engine configuration. This guide walks through setting up a localization engine from scratch - from installation to a fully configured engine with per-locale brand voices, glossary rules, linguistic instructions, and model routing. ## What you'll configure | Layer | What it does | Example | | --- | --- | --- | | **Brand voices** | Per-locale tone and formality | Casual "du" for German developers, polite-formal for Japanese | | **Glossary** | Custom translations + non-translatables | "Deploy" → "Bereitstellen" in German, "OAuth" stays as-is everywhere | | **Instructions** | Locale-specific linguistic rules | Non-breaking spaces before French punctuation, full-width characters in Japanese | | **Model routing** | Per-locale model selection with fallbacks | Claude Sonnet for European pairs, GPT-4o fallback for Japanese | The result is a stateful translation API. Call it from code via the [localization API](/docs/api), from the command line via the [CLI](/docs/cli/setup), or automatically on every pull request via [CI/CD](/guides/ci-cd-workflows). Every request applies all layers automatically. ## The problem Each localization engine needs brand voices per locale, glossary rules, linguistic instructions, and model routing. Configuring all of this through a dashboard is time-consuming and repetitive - especially the first time, when you're learning what each layer does and how they interact. The Lingo.dev MCP server lets your AI coding assistant handle the initial setup in a single conversation. You point it at your product's content, and it creates the engine, writes brand voice profiles, identifies terms for the glossary, adds locale-specific instructions, and configures model routing - all in one pass. You review the output and tailor from there. ## Step 1: Install the MCP Generate an API key from the [API Keys](/docs/platform/api-keys) section of the Lingo.dev dashboard. Then add the MCP server to your coding agent's configuration. {% tabs %} {% tab label="Claude Code" %} Add to your `.claude/settings.json` or project-level `.mcp.json`: ```json { "lingo": { "type": "http", "url": "https://mcp.lingo.dev/account", "headers": { "x-api-key": "your_api_key" } } } ``` {% /tab %} {% tab label="Cursor" %} Add to `.cursor/mcp.json` in your project or `~/.cursor/mcp.json` globally: ```json { "mcpServers": { "lingo": { "url": "https://mcp.lingo.dev/account", "headers": { "x-api-key": "your_api_key" } } } } ``` {% /tab %} {% tab label="Codex" %} Add to `~/.codex/config.toml` or project-level `.codex/config.toml`: ```toml [mcp_servers.lingo] url = "https://mcp.lingo.dev/account" http_headers = { "x-api-key" = "your_api_key" } ``` {% /tab %} {% /tabs %} {% callout type="info" title="Organization scope" %} The API key determines which organization the MCP server manages. All operations run within that organization automatically - your assistant never needs to specify an organization ID. {% /callout %} Restart your agent and verify the connection by asking it to list your existing localization engines. If the MCP is active, it returns results (or an empty list for new organizations). ## Step 2: Configure the engine Copy the prompt below and paste it into your AI coding assistant. Replace the URL at the end with your product's website, documentation, or README - the agent needs representative content to infer your voice, terminology, and audience. ``` Create a localization engine called 'My Product' for localizing into German, French, Japanese, and Spanish. Study the content at the URL below to understand our tone, terminology, and audience. Then configure everything in one pass: brand voices for each locale (and English), glossary entries for terms that need consistent translations or should stay untranslated, and locale-specific linguistic instructions. https://docs.yourproduct.com ``` {% callout type="warning" title="Don't forget the URL" %} The prompt ends with a placeholder URL. Replace it with a link to content that captures your product's actual voice - documentation, README, onboarding flow, or marketing site. Without it, the agent generates generic configuration. {% /callout %} The agent reads your content, creates the engine, and configures all layers in a single pass. The next steps are about reviewing and tailoring what it produced. ## Step 3: Tailor brand voices Review the [brand voices](/docs/platform/brand-voices) the agent created for each locale. A brand voice defines how your product speaks in a specific language - tone, formality, and style. The agent infers these from your content, but the cultural nuances are worth checking. What to look for: | Locale | Common adjustment | | --- | --- | | German | "du" (informal) vs. "Sie" (formal) - depends on your audience | | French | "tu" (informal) vs. "vous" (formal) - consumer vs. enterprise | | Japanese | Politeness level - polite-formal (です/ます) is safe for most products | | English | Source language voice is often missing - add one for consistency | A well-configured German brand voice looks like this: ``` Use informal "du" address. Keep a direct, technical tone. Prefer short sentences. Use active voice. When a German equivalent exists for a technical term, use it (e.g., "Bereitstellung" for deployment), but keep widely-adopted English terms as-is (e.g., API, CLI, Token). ``` If the register is wrong, tell your assistant directly: ``` The German brand voice is too informal for our enterprise docs. Switch it to formal "Sie" register. ``` ## Step 4: Tailor glossary Review the [glossary](/docs/platform/glossaries) entries the agent created. The glossary gives the engine exact control over specific terms - either enforcing a translation or preventing translation entirely. The agent identifies terms from your content, but it may miss product-specific ones or pick wrong translations. A typical glossary after the initial pass: | Source text | Target text | Source locale | Target locale | Type | | --- | --- | --- | --- | --- | | Deploy | Bereitstellen | en | de | custom translation | | workspace | espace de travail | en | fr | custom translation | | Lingo.dev | Lingo.dev | * | * | non-translatable | | OAuth | OAuth | * | * | non-translatable | What to check: - **Missing terms** - product feature names, internal jargon the agent didn't encounter - **Wrong translations** - the agent may pick a synonym that doesn't match your established usage - **Missing non-translatables** - brand names, protocol names, or acronyms that should stay as-is Glossary entries are matched by semantic similarity - an entry for "Deploy" also matches "Deploying", "deployment", and "deploy your application" without needing separate entries. Use `*` wildcards for terms that apply across all locales. ``` Add a glossary entry: 'checkout' should stay as 'Checkout' in German - it's our product feature name, not the shopping action. ``` ## Step 5: Tailor instructions Review the [instructions](/docs/platform/instructions) the agent created. Instructions are discrete, testable rules for specific locales. Unlike brand voices (which set overall tone), instructions encode rules that generic models miss - punctuation, abbreviations, character width, number formatting. A typical set of instructions after the initial pass: | Locale | Name | Rule | | --- | --- | --- | | fr | French punctuation spacing | Always use a non-breaking space before `:`, `;`, `!`, and `?` | | de | German address abbreviations | Abbreviate "Straße" to "Str." and "Nummer" to "Nr." | | ja | Japanese character width | Use full-width parentheses () instead of half-width () | Each instruction addresses one concern, making them individually testable - if German abbreviations break, update that one instruction without touching anything else. What to check: - **Missing rules** - number formatting, date formats, currency conventions for your target locales - **Source language** - English instructions for Oxford commas, title case, or number formatting are often missing ``` In French, there should always be a non-breaking space before colons and semicolons. Add that as an instruction for fr. ``` ## Step 6: Configure model routing (optional) New engines come pre-configured with model defaults optimized for quality across common and low-resource languages. Most teams don't need to change them. If you have specific requirements - a model that performs well for your domain, a budget constraint, or a compliance need - override the defaults: ``` Set Claude Sonnet as the primary model for European language pairs, with GPT-4o as fallback for Japanese. ``` Each model config supports ranked fallbacks. If the primary model fails (outage, rate limit, deprecation), the engine automatically tries the next one. ## Next Steps {% card-grid %} {% link-card title="Localization MCP" href="/docs/mcp" icon="plug" description="Full MCP server documentation and setup reference" /%} {% link-card title="Localization Engines" href="/docs/platform/engines" icon="gear" description="How the five configurable layers interact" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Per-locale tone, formality, and style rules" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Exact term control with semantic matching" /%} {% /card-grid %} - [Ruby on Rails Localization with the i18n API](https://lingo.dev/en/guides/ruby-on-rails-localization): How to localize a Ruby on Rails app using config/locales YAML files with the Lingo.dev CLI and GitHub Actions – from project setup to automated CI/CD translations. The Lingo.dev [CLI](/docs/cli) translates Rails `config/locales` YAML files through a configured [localization engine](/docs/platform/engines). Rails ships with the [i18n API](https://guides.rubyonrails.org/i18n.html) baked in – your app's translatable text lives in per-locale YAML files. Lingo.dev fits the existing pipeline without adding a runtime dependency. This guide walks through localizing a Rails app end-to-end: configuring the CLI, organizing per-locale YAML files, switching locales at request time, and automating translations with GitHub Actions. {% callout type="info" title="Demo repository" %} Clone or fork [lingodotdev/ruby-on-rails-localization-example](https://github.com/lingodotdev/ruby-on-rails-localization-example) to follow along. The repository contains a working Rails app with `config/locales` YAML files, a Lingo.dev CLI configuration, and a GitHub Actions workflow. {% /callout %} ## How Rails Localization Works Rails reads translations from YAML files under `config/locales/`. Each file is keyed by a locale code at the root and contains nested keys that mirror the lookup paths your code uses with `I18n.t`. | Layer | What lives there | Example file | | --- | --- | --- | | UI strings | Buttons, labels, flash messages | `config/locales/en.yml` | | Mailer copy | Subjects and bodies for `ActionMailer` | `config/locales/mailers.en.yml` | | Model errors | Validation messages and attribute names | `config/locales/activerecord.en.yml` | The first key of every Rails YAML file is the locale code itself – `en:`, `es:`, `fr:`. The CLI's `yaml-root-key` bucket understands this shape: it strips the locale prefix before sending content to your localization engine, then writes a parallel file with the target locale code as the new root key. Nested keys, `%{name}` interpolation tokens, and CLDR plural categories (`zero`/`one`/`two`/`few`/`many`/`other`) are preserved. ## Prerequisites {% steps %} {% step title="Create a localization engine" %} Every CLI run sends content through a [localization engine](/docs/platform/engines) – the configuration that determines which LLM model, [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) apply. Create one in the [Lingo.dev dashboard](https://lingo.dev) and generate an [API key](/docs/platform/api-keys). {% /step %} {% step title="Verify Ruby and Rails" %} This guide targets Rails 7.2 or higher, which requires Ruby 3.1 or higher. Check your versions: ```bash ruby -v rails -v ``` {% /step %} {% step title="Verify Node.js" %} The CLI requires Node.js 18 or higher: ```bash node -v ``` {% /step %} {% step title="Set up Rails i18n" %} This guide assumes your app already stores translations in `config/locales/*.yml`. If you have hardcoded strings in views or controllers, extract them into `t()` calls first. For example, replace: ```erb

Welcome

``` with: ```erb

<%= t(".welcome") %>

``` then add the matching key to `config/locales/en.yml`. See Rails [internationalization guide](https://guides.rubyonrails.org/i18n.html) for the full migration steps. {% /step %} {% /steps %} ## Organize Translation Files Rails auto-loads every `*.yml` file under `config/locales/`. Keep the source locale next to its translated siblings so the directory acts as the single source of truth: ``` config/locales/ en.yml # Source locale es.yml # Generated by Lingo.dev fr.yml de.yml ``` A typical `en.yml` mixes plain strings, nested namespaces, `%{name}` interpolation, and pluralization: ```yaml en: hello: "Hello" home: welcome: "Welcome, %{name}!" cta: "Get started" notifications: unread: zero: "No unread notifications" one: "1 unread notification" other: "%{count} unread notifications" errors: messages: blank: "can't be blank" ``` ## Configure the CLI Create an `i18n.json` file in your project root. Declare a `yaml-root-key` bucket pointing at the locale files: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "yaml-root-key": { "include": ["config/locales/[locale].yml"] } } } ``` The `[locale]` placeholder resolves to each configured locale code. With `source: "en"`, the CLI reads `config/locales/en.yml` and writes translated files to `config/locales/es.yml`, `config/locales/fr.yml`, and `config/locales/de.yml`. Rails happily loads `mailers.en.yml`, `pages.en.yml`, and so on alongside `en.yml`. Add additional patterns to the `include` array of your `yaml-root-key` bucket: ```json { "buckets": { "yaml-root-key": { "include": [ "config/locales/[locale].yml", "config/locales/mailers.[locale].yml", "config/locales/pages.[locale].yml" ] } } } ``` ## Configure Rails for Multiple Locales Tell Rails which locales are available and which one to use as the default. In `config/application.rb`: ```ruby module YourApp class Application < Rails::Application config.i18n.available_locales = [:en, :es, :fr, :de] config.i18n.default_locale = :en config.i18n.fallbacks = [:en] end end ``` Pick the request locale in `ApplicationController` from a URL parameter or the `Accept-Language` header: ```ruby class ApplicationController < ActionController::Base around_action :switch_locale private def switch_locale(&action) locale = params[:locale] || http_accept_locale || I18n.default_locale I18n.with_locale(locale, &action) end def http_accept_locale header = request.headers["Accept-Language"].to_s header.scan(/[a-z]{2}/).find { |l| I18n.available_locales.map(&:to_s).include?(l) } end def default_url_options { locale: I18n.locale } end end ``` ## Render Translations in Views Use `t` and `l` helpers in ERB templates. A leading dot in the key resolves against the current view path, keeping translation keys colocated with the templates that use them: ```erb

<%= t(".welcome", name: @user_name) %>

<%= t("notifications.unread", count: @unread_count) %>

<%= link_to t(".cta"), signup_path, class: "btn-primary" %> ``` Add a locale switcher to your layout: ```erb ``` ## Translate Locally Set your API key and run the CLI: ```bash export LINGO_API_KEY="your-api-key" npx lingo.dev@latest run ``` The CLI reads every file matching your bucket patterns, identifies untranslated entries using the [lockfile](/docs/cli/lockfile), translates the delta through your localization engine, and writes results into each target locale file. The locale root key, nested namespaces, `%{name}`-style interpolation tokens, and plural categories are preserved – only translatable text changes. To target a specific locale during development: ```bash npx lingo.dev@latest run --target-locale es ``` Restart the Rails server after the first translation run so the new YAML files load: ```bash bin/rails server ``` Visit `/es` to see the Spanish output. ## Plurals Rails uses [CLDR plural categories](https://cldr.unicode.org/index/cldr-spec/plural-rules) – `zero`, `one`, `two`, `few`, `many`, `other`. Pass a `count:` argument to `I18n.t` and Rails picks the matching key: ```ruby t("notifications.unread", count: 0) # => "No unread notifications" t("notifications.unread", count: 1) # => "1 unread notification" t("notifications.unread", count: 12) # => "12 unread notifications" ``` The CLI translates each plural variant in place. If your target locale needs more categories than English's `one`/`other`, define them in your source `en.yml`. ## Automate with GitHub Actions Add a workflow file at `.github/workflows/translate.yml` to translate on every push: {% tabs %} {% tab label="Commit to main" %} Translations commit directly to main – zero friction, ideal for small teams: ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` {% /tab %} {% tab label="PR for review" %} Translations open a pull request for human review before merging: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ github.token }} ``` {% /tab %} {% /tabs %} Store your API key as `LINGODOTDEV_API_KEY` in **Settings > Secrets and variables > Actions** in your GitHub repository. ## Verify Before Deploy Use the `--frozen` flag as a deployment gate to ensure no untranslated content ships to production. The CLI exits with a non-zero status if any entries need translation: ```bash npx lingo.dev@latest run --frozen ``` Add this as a separate CI step before your asset precompile or container build: ```yaml - name: Verify translations run: npx lingo.dev@latest run --frozen - name: Precompile assets run: bundle exec rails assets:precompile ``` ## Next Steps {% card-grid %} {% link-card title="Static Content Localization" href="/guides/static-content" icon="file-code" description="Markdown, MDX, JSON, YAML, and more bucket types" /%} {% link-card title="Web App Localization" href="/guides/web-app" icon="globe" description="UI string patterns across common web frameworks" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="GitHub Actions, GitLab CI, Bitbucket Pipelines patterns" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Lock brand names and technical terms from translation" /%} {% /card-grid %} - [Next.js App Router Localization with Markdoc](https://lingo.dev/en/guides/markdoc-nextjs-localization): How to localize a Next.js App Router site that renders Markdoc content with the Lingo.dev CLI and GitHub Actions – from project setup to automated CI/CD translations. The Lingo.dev [CLI](/docs/cli) translates [Markdoc](https://markdoc.dev) files and JSON UI-string catalogs through a configured [localization engine](/docs/platform/engines). Markdoc is a Markdown-based authoring format with typed, React-backed custom tags – a good fit for Next.js App Router sites that mix long-form content with interactive components. This guide walks through localizing a Next.js App Router site end-to-end: configuring the CLI, organizing per-locale content, rendering Markdoc in dynamic routes, and automating translations with GitHub Actions. {% callout type="info" title="Demo repository" %} Clone or fork [lingodotdev/markdoc-nextjs-localization-example](https://github.com/lingodotdev/markdoc-nextjs-localization-example) to follow along. The repository contains a working Next.js App Router app with Markdoc content, a Lingo.dev CLI configuration, and a GitHub Actions workflow. {% /callout %} ## How Next.js + Markdoc Localization Works Most Next.js App Router sites split localized content into two layers: | Layer | What lives there | Example file | | --- | --- | --- | | Long-form content | Marketing pages, docs, blog posts | `src/content/[locale]/pages/home.md` | | UI strings | Navbar labels, CTAs, button states | `src/content/[locale]/ui.json` | Routes live under `src/app/[lang]/` and read the matching locale's files at request time. A middleware picks a default locale from the browser's `Accept-Language` header and redirects bare paths like `/` to `/en` (or the best match). The CLI's `markdoc` bucket parses Markdoc files with frontmatter and custom tags intact, and the `json` bucket handles the UI-string catalog. Both translate the delta through your localization engine and write per-locale files alongside the source. ## Prerequisites {% steps %} {% step title="Create a localization engine" %} Every CLI run sends content through a [localization engine](/docs/platform/engines) – the configuration that determines which LLM model, [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) apply. Create one in the [Lingo.dev dashboard](https://lingo.dev) and generate an [API key](/docs/platform/api-keys). {% /step %} {% step title="Verify Node.js" %} The CLI requires Node.js 18 or higher: ```bash node -v ``` {% /step %} {% step title="Set up your Next.js project" %} Your project needs the App Router (`src/app/`) and a per-locale content directory. The demo repo uses `src/content/[locale]/` with two sub-folders (`pages/` and `blog/`) plus a `ui.json` file. See [Next.js internationalization](https://nextjs.org/docs/app/building-your-application/routing/internationalization) for the routing basics. {% /step %} {% /steps %} ## Organize Content Split content by role. Long-form pages and posts are authored in Markdoc; short UI strings live in JSON so components can load them directly. ``` src/content/ en/ # Source locale pages/home.md # Long-form Markdoc blog/hello.md ui.json # UI strings (navbar, CTAs, button states) es/ # Target locales – generated by Lingo.dev fr/ de/ ``` Markdoc files support frontmatter for per-page metadata (title, description, date, author) and custom tags that render as React components. A minimal page looks like: ```markdown --- title: Author once in Markdoc, ship in every language. description: An example Next.js App Router app that localizes Markdoc with Lingo. --- {% callout type="note" %} This page is authored in Markdoc and translated by Lingo.dev. {% /callout %} ## Built from three pieces Markdoc custom tags render as React components – even interactive ones. ``` ## Configure the CLI Create an `i18n.json` file in your project root. Declare two buckets – one for Markdoc content, one for the UI-string catalog: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de"] }, "buckets": { "markdoc": { "include": [ "src/content/[locale]/pages/*.md", "src/content/[locale]/blog/*.md" ] }, "json": { "include": ["src/content/[locale]/ui.json"] } } } ``` The `[locale]` placeholder resolves to each configured locale code. With `source: "en"`, the CLI reads from `src/content/en/` and writes translated files into `src/content/es/`, `src/content/fr/`, and `src/content/de/`. {% callout type="info" title="Single-file catalogs" %} If your UI strings live in a single multi-locale JSON file instead of one file per locale, use the `json-per-locale` bucket type. See [Static Content Localization](/guides/static-content) for the full list of bucket types. {% /callout %} ## Render Markdoc in the App Router A typical dynamic route loads a document and renders the transformed tree. The demo repo exposes a small helper: ```ts // src/lib/markdoc.ts export async function loadDoc( locale: Locale, collection: "pages" | "blog", slug: string, ) { const raw = await fs.readFile( path.join(process.cwd(), "src/content", locale, collection, `${slug}.md`), "utf8", ); const ast = Markdoc.parse(raw); const frontmatter = ast.attributes.frontmatter ? parseFrontmatter(ast.attributes.frontmatter) : {}; const content = Markdoc.transform(ast, { ...schema, variables: { frontmatter } }); return { frontmatter, content }; } ``` The App Router page is a thin wrapper that pairs the doc with locale-specific UI strings: ```tsx // src/app/[lang]/page.tsx export default async function Home({ params }: PageProps<"/[lang]">) { const { lang } = await params; const doc = await loadDoc(lang, "pages", "home"); const { home } = await getMessages(lang); return (

{doc.frontmatter.title}

{renderMarkdoc(doc.content)}
); } ``` Custom Markdoc tags (`callout`, `bento`, `blog-hero`, etc.) are declared in `markdoc.schema.ts` and wired to React components under `src/components/markdoc/`. See the [Markdoc schema docs](https://markdoc.dev/docs/tags) for the full API. ## Detect Locale in Middleware Next.js middleware inspects the request before a route renders. Use it to redirect bare paths to the best-matching locale based on the `Accept-Language` header: ```ts // src/middleware.ts export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = locales.some( (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`), ); if (hasLocale) return; const locale = pickLocale(request); // parses Accept-Language const url = request.nextUrl.clone(); url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`; return NextResponse.redirect(url); } export const config = { matcher: ["/((?!_next|api|.*\\..*).*)"], }; ``` Visitors land on `/en`, `/es`, `/fr`, or `/de` without ever typing the prefix. ## Translate Locally Set your API key and run the CLI: ```bash export LINGO_API_KEY="your-api-key" npx lingo.dev@latest run ``` The CLI reads every file matching your bucket patterns, identifies untranslated entries using the [lockfile](/docs/cli/lockfile), translates the delta through your localization engine, and writes results into each target locale's directory. Frontmatter keys, Markdoc custom tags, and JSON shapes are preserved – only translatable text changes. To target a specific locale during development: ```bash npx lingo.dev@latest run --target-locale es ``` ## Automate with GitHub Actions Add a workflow file at `.github/workflows/translate.yml` to translate on every push: {% tabs %} {% tab label="Commit to main" %} Translations commit directly to main – zero friction, ideal for small teams: ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` {% /tab %} {% tab label="PR for review" %} Translations open a pull request for human review before merging: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ github.token }} ``` {% /tab %} {% /tabs %} Store your API key as `LINGODOTDEV_API_KEY` in **Settings > Secrets and variables > Actions** in your GitHub repository. ## Verify Before Deploy Use the `--frozen` flag as a deployment gate to ensure no untranslated content ships to production. The CLI exits with a non-zero status if any entries need translation: ```bash npx lingo.dev@latest run --frozen ``` Add this as a separate CI step before your Next.js build: ```yaml - name: Verify translations run: npx lingo.dev@latest run --frozen - name: Build run: pnpm build ``` ## Next Steps {% card-grid %} {% link-card title="Static Content Localization" href="/guides/static-content" icon="file-code" description="Markdown, MDX, JSON, YAML, and more bucket types" /%} {% link-card title="Web App Localization" href="/guides/web-app" icon="globe" description="UI string patterns across common web frameworks" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="GitHub Actions, GitLab CI, Bitbucket Pipelines patterns" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Lock brand names and technical terms from translation" /%} {% /card-grid %} - [Android App Localization with strings.xml](https://lingo.dev/en/guides/android-app-localization): How to localize an Android app using strings.xml resource files with the Lingo.dev CLI and GitHub Actions - from project setup to automated CI/CD translations. The Lingo.dev [CLI](/docs/cli) translates Android [string resources](https://developer.android.com/guide/topics/resources/string-resource) (`strings.xml`) through a configured [localization engine](/docs/platform/engines). The CLI's `android` bucket type understands ``, ``, ``, and `` elements natively, preserving XML structure and generating correct plural categories for each target locale. This guide walks through localizing an Android app end-to-end: configuring the CLI, translating locally, and automating with GitHub Actions so translations ship on every push. {% callout type="info" title="Demo repository" %} Clone or fork [lingodotdev/android-app-localization-example](https://github.com/lingodotdev/android-app-localization-example) to follow along. The repository contains a working Android project with string resources, a Lingo.dev CLI configuration, and a GitHub Actions workflow. {% /callout %} ## How Android Localization Works Android uses a [resource directory convention](https://developer.android.com/guide/topics/resources/providing-resources) where each locale gets its own `values-[locale]/` directory. The system loads the correct `strings.xml` at runtime based on the device's language setting. ``` app/src/main/res/ values/ # Default (source) strings strings.xml values-es/ # Spanish strings.xml values-fr/ # French strings.xml values-ja/ # Japanese strings.xml ``` A typical `strings.xml` contains three element types: ```xml My App Welcome back! Mercury Venus Earth %d item %d items ``` The CLI parses all three element types, translates their content through the localization engine, and writes per-locale files into the correct `values-[locale]/` directories. ## Prerequisites {% steps %} {% step title="Create a localization engine" %} Every CLI run sends content through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) apply. Create one in the [Lingo.dev dashboard](https://lingo.dev) and generate an [API key](/docs/platform/api-keys). {% /step %} {% step title="Verify Node.js" %} The CLI requires Node.js 18 or higher: ```bash node -v ``` {% /step %} {% step title="Set up your Android project" %} Your project needs a default `strings.xml` in `app/src/main/res/values/`. Android Studio creates this file when you start a new project. See Android's [localization guide](https://developer.android.com/guide/topics/resources/localization) for setting up resource directories. {% /step %} {% /steps %} ## Configure the CLI Create an `i18n.json` file in your project root. The `android` bucket tells the CLI to parse Android XML resources and create separate files per locale: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "android": { "include": ["app/src/main/res/values-[locale]/strings.xml"] } } } ``` The `[locale]` placeholder resolves to each configured locale code at runtime. The CLI substitutes your source locale into the pattern - so with `source: "en"`, it looks for `values-en/strings.xml`. Target locales produce `values-es/strings.xml`, `values-fr/strings.xml`, and so on. ## Bridge the Default Locale Directory Android stores default strings in `values/` (no locale suffix), but the CLI resolves `[locale]` literally and looks for `values-en/strings.xml`. Create a symlink to bridge the two conventions: ```bash cd app/src/main/res ln -s values values-en ``` This makes the source strings visible at both `values/strings.xml` (where Android expects them) and `values-en/strings.xml` (where the CLI looks). Commit the symlink to your repository - git tracks symlinks natively on macOS and Linux. {% callout type="warning" title="Windows" %} Git on Windows may check out symlinks as plain text files depending on your configuration. If you're on Windows, run `git config core.symlinks true` before cloning, or copy the `values/` directory to `values-en/` instead. {% /callout %} {% callout type="info" title="Multiple resource files" %} If your project splits strings across multiple files (for example, `strings.xml` and `arrays.xml`), list them all. The symlink covers the entire directory, so all files inside `values/` are accessible through `values-en/`: ```json { "buckets": { "android": { "include": [ "app/src/main/res/values-[locale]/strings.xml", "app/src/main/res/values-[locale]/arrays.xml" ] } } } ``` {% /callout %} ## Translate Locally Set your API key and run the CLI: ```bash export LINGO_API_KEY="your-api-key" npx lingo.dev@latest run ``` The CLI reads your source `strings.xml`, identifies untranslated entries using the [lockfile](/docs/cli/lockfile), translates the delta through your localization engine, and writes results into the target `values-[locale]/` directories. Open any target file to see the translated strings. To target a specific locale during development: ```bash npx lingo.dev@latest run --target-locale es ``` ## Plurals Android uses `` elements with [CLDR quantity strings](https://developer.android.com/guide/topics/resources/string-resource#Plurals) (`zero`, `one`, `two`, `few`, `many`, `other`) to handle plural forms. Different languages require different plural categories - English needs two (`one` and `other`), Russian needs four, and Arabic needs six. The CLI preserves the `` structure during translation and generates the correct quantity entries for each target locale. A source entry with two categories: ```xml %d new message %d new messages ``` Produces the correct categories for each target language. The localization engine knows which [CLDR plural rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) apply to each locale and generates only the categories that language requires. ## Key Locking Some string values should stay identical across all languages - brand names, API endpoints, or format patterns. Use [key locking](/docs/cli/key-locking) to copy these values without translation: ```json { "buckets": { "android": { "include": ["app/src/main/res/values-[locale]/strings.xml"], "lockedKeys": ["app_name", "api_base_url"] } } } ``` Locked keys are copied from source to all target files without entering the translation pipeline. ## Automate with GitHub Actions Add a workflow file at `.github/workflows/translate.yml` to translate on every push: {% tabs %} {% tab label="Commit to main" %} Translations commit directly to main - zero friction, ideal for small teams: ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` {% /tab %} {% tab label="PR for review" %} Translations open a pull request for human review before merging: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ github.token }} ``` {% /tab %} {% /tabs %} Store your API key as `LINGODOTDEV_API_KEY` in **Settings > Secrets and variables > Actions** in your GitHub repository. ## Verify Before Deploy Use the `--frozen` flag as a deployment gate to ensure no untranslated strings ship to production. The CLI exits with a non-zero status if any entries need translation: ```bash npx lingo.dev@latest run --frozen ``` Add this as a separate CI step before your build: ```yaml - name: Verify translations run: npx lingo.dev@latest run --frozen ``` ## Next Steps {% card-grid %} {% link-card title="Mobile App Localization" href="/guides/mobile-app" icon="rocket" description="Overview of all mobile platforms - iOS, Android, Flutter, React Native" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="GitHub Actions, GitLab CI, Bitbucket Pipelines patterns" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Lock brand names and technical terms from translation" /%} {% link-card title="Key Locking" href="/docs/cli/key-locking" icon="shield" description="Copy specific values without translating them" /%} {% /card-grid %} - [iOS App Localization with Xcode String Catalogs](https://lingo.dev/en/guides/ios-app-localization): How to localize an iOS app using Xcode String Catalogs (.xcstrings) with the Lingo.dev CLI and GitHub Actions - from project setup to automated CI/CD translations. The Lingo.dev [CLI](/docs/cli) translates Xcode [String Catalogs](https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog) (`.xcstrings`) through a configured [localization engine](/docs/platform/engines). String Catalogs are Apple's modern localization format, introduced in Xcode 15, that stores all languages in a single JSON file. The CLI mutates this file in place - no per-locale directories needed. This guide walks through localizing an iOS app end-to-end: configuring the CLI, translating locally, and automating with GitHub Actions so translations ship on every push. {% callout type="info" title="Demo repository" %} Clone or fork [lingodotdev/ios-app-localization-example](https://github.com/lingodotdev/ios-app-localization-example) to follow along. The repository contains a working Xcode project with String Catalogs, a Lingo.dev CLI configuration, and a GitHub Actions workflow. {% /callout %} ## How String Catalogs Work Before Xcode 15, iOS localization required managing separate `.strings` and `.stringsdict` files across `[locale].lproj/` directories. String Catalogs replace this with a single `Localizable.xcstrings` file that Xcode maintains automatically. When you mark a string as localizable in SwiftUI or UIKit, Xcode detects it during build and adds an entry to the String Catalog. Each entry tracks the source string, its translations for every configured locale, and an optional comment field that provides context to translators. | Aspect | Legacy `.strings` | String Catalogs `.xcstrings` | | --- | --- | --- | | File count | One per locale per table | One file, all locales | | Format | Key-value text | Structured JSON | | Plural support | Separate `.stringsdict` file | Built-in plural rules | | Xcode integration | Manual export/import | Automatic detection | | Translator notes | Not supported | Comment field per entry | The CLI's `xcode-xcstrings` bucket type parses this JSON structure, translates each entry through the localization engine, and writes translations back into the same file - preserving comments, plural rules, and metadata. ## Prerequisites {% steps %} {% step title="Create a localization engine" %} Every CLI run sends content through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, [glossary](/docs/platform/glossaries), [brand voice](/docs/platform/brand-voices), and [instructions](/docs/platform/instructions) apply. Create one in the [Lingo.dev dashboard](https://lingo.dev) and generate an [API key](/docs/platform/api-keys). {% /step %} {% step title="Verify Node.js" %} The CLI requires Node.js 18 or higher: ```bash node -v ``` {% /step %} {% step title="Enable localization in Xcode" %} In your Xcode project, go to **Project Settings > Info > Localizations** and add your target languages. Xcode creates the String Catalog entries for each locale you add. See Apple's [localization documentation](https://developer.apple.com/documentation/xcode/localization) for details. {% /step %} {% /steps %} ## Configure the CLI Create an `i18n.json` file in your project root. The `xcode-xcstrings` bucket tells the CLI to parse the String Catalog format and mutate the file in place: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "xcode-xcstrings": { "include": ["MyApp/Localizable.xcstrings"] } } } ``` Because String Catalogs store all locales in a single file, no `[locale]` placeholder is needed in the include pattern. The CLI reads the source language entries, translates them, and writes all target languages back into the same `.xcstrings` file. {% callout type="info" title="Multiple String Catalogs" %} If your project uses multiple String Catalog files (for example, one per framework target), list them all in the include array: ```json { "buckets": { "xcode-xcstrings": { "include": [ "MyApp/Localizable.xcstrings", "MyAppWidgets/Localizable.xcstrings" ] } } } ``` {% /callout %} ## Translate Locally Set your API key and run the CLI: ```bash export LINGO_API_KEY="your-api-key" npx lingo.dev@latest run ``` The CLI reads your String Catalog, identifies untranslated entries using the [lockfile](/docs/cli/lockfile), translates the delta through your localization engine, and writes results back into the `.xcstrings` file. Open the file in Xcode to see translations populated for each configured locale. To target a specific locale during development: ```bash npx lingo.dev@latest run --target-locale es ``` ## Translator Notes String Catalogs support a comment field per entry that the CLI includes in translation requests. These comments provide context to the localization engine - disambiguating terms, specifying tone, or describing where a string appears in the UI. In Xcode, select a string in the String Catalog editor and add a comment in the inspector panel. The comment is stored in the `.xcstrings` JSON: ```json { "sourceLanguage": "en", "strings": { "Set": { "comment": "Refers to a collection of items, not the verb", "localizations": { } } } } ``` The CLI sends this comment alongside the string, steering the model toward the correct interpretation. "Set" without context could become a verb in many languages - the comment eliminates that ambiguity. See [Translator Notes](/docs/cli/translator-notes) for more patterns. ## Plurals String Catalogs handle plural forms natively using [CLDR plural rules](https://cldr.unicode.org/index/cldr-spec/plural-rules). When you define a plural variation in Xcode, the String Catalog stores rules for each plural category (`zero`, `one`, `two`, `few`, `many`, `other`) that the target language requires. The CLI preserves this structure during translation and generates the correct plural categories for each target locale. English uses two categories (`one` and `other`), but Arabic needs six, Polish needs four, and Japanese needs one. The localization engine handles these differences automatically. ## Automate with GitHub Actions Add a workflow file at `.github/workflows/translate.yml` to translate on every push: {% tabs %} {% tab label="Commit to main" %} Translations commit directly to main - zero friction, ideal for small teams: ```yaml name: Translate on: push: branches: [main] permissions: contents: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} ``` {% /tab %} {% tab label="PR for review" %} Translations open a pull request for human review before merging: ```yaml name: Translate on: push: branches: [main] permissions: contents: write pull-requests: write jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lingo.dev uses: lingodotdev/lingo.dev@main with: api-key: ${{ secrets.LINGODOTDEV_API_KEY }} pull-request: true env: GH_TOKEN: ${{ github.token }} ``` {% /tab %} {% /tabs %} Store your API key as `LINGODOTDEV_API_KEY` in **Settings > Secrets and variables > Actions** in your GitHub repository. ## Verify Before Deploy Use the `--frozen` flag as a deployment gate to ensure no untranslated strings ship to production. The CLI exits with a non-zero status if any entries need translation: ```bash npx lingo.dev@latest run --frozen ``` Add this as a separate CI step before your build: ```yaml - name: Verify translations run: npx lingo.dev@latest run --frozen ``` ## Next Steps {% card-grid %} {% link-card title="Mobile App Localization" href="/guides/mobile-app" icon="rocket" description="Overview of all mobile platforms - iOS, Android, Flutter, React Native" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="GitHub Actions, GitLab CI, Bitbucket Pipelines patterns" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Lock brand names and technical terms from translation" /%} {% link-card title="Translator Notes" href="/docs/cli/translator-notes" icon="chat" description="Provide context to improve translation accuracy" /%} {% /card-grid %} - [Jira Triage](https://lingo.dev/en/guides/triage): Automatically triage Jira tickets for localization relevance using AI - add a label, get surgical suggestions for glossary items, instructions, and brand voice updates, approve with one click. Add a "lingo" label to any Jira ticket. An AI agent analyzes the issue, classifies it against ISO 5060, checks your engine config, and suggests specific glossary items, instructions, or brand voice changes. Approve with one click. Works with Jira Cloud and Jira Service Management. ## The Problem Product teams with active localization programs receive a constant stream of feedback. Translation bugs, missing locale support, formatting issues, cultural mismatches. The triage step - deciding what's a real localization issue, what to do about it, and which engine to update - consumes more time than the fix itself. A team processing 100+ localization-related tickets per week can easily spend 15-20 hours just categorizing and routing them. Most tickets need a glossary entry, an instruction update, or a brand voice adjustment. These take seconds in the dashboard but minutes of human judgment to identify. ## How It Works Connect your Jira project to your Lingo.dev organization. When someone adds a "lingo" label to any ticket, five things happen automatically: {% steps %} {% step title="Webhook triggers" %} Jira notifies Lingo.dev. A triage job appears in your Jobs dashboard. {% /step %} {% step title="AI researches the issue" %} An agent reads the ticket - summary, description, comments. It loads the [ISO 5060](https://iso5060.com/) translation quality framework, checks locale-specific norms, and reviews your engine's existing glossary, instructions, and brand voice. It won't suggest items that already exist. {% /step %} {% step title="Structured decision" %} The agent classifies the error (category + severity) and produces atomic suggestions. One glossary item per suggestion. One instruction per suggestion. If the ticket isn't localization-related, it's auto-closed with a Jira comment. {% /step %} {% step title="Human review" %} All suggestions appear simultaneously. Each has a type-specific button - "Add glossary item", "Add instruction", "Create task". Approve or dismiss each independently. All approvals are collected first, then all approved changes execute together. {% /step %} {% step title="Automatic execution" %} Approved suggestions execute immediately. A focused agent resolves the correct engine, validates the operation, and applies the change. Glossary suggestion becomes a real glossary entry. Instruction suggestion becomes a real locale instruction. {% /step %} {% /steps %} ### Jira comments close the loop When triage completes, a comment is posted back to the Jira ticket with the reasoning, a link to the job details, and a "Provided by Lingo.dev Localization Intelligence" signature. The person who added the label sees the result directly in Jira without opening the dashboard. {% callout type="info" title="Comment authorship" %} Comments are posted as the user who connected the Jira integration (OAuth limitation). If you want comments attributed to a neutral account, connect Jira using a shared service account. {% /callout %} ## Typical Scenarios ### Missing translations **Ticket:** "Spanish translations missing on checkout page - 'Place Order' button shows English for es-MX users" **Triage result:** ISO 5060 accuracy issue, major severity. The agent finds your production engine and suggests: - Add glossary item: "Place Order" -> "Realizar pedido" (en -> es-MX) - Add glossary item: "Shipping Address" -> "Direccion de envio" (en -> es-MX) One click each. Glossary entries created in your engine. ### Locale formatting issues **Ticket:** "Korean date format shows MM/DD instead of YYYY.MM.DD in the dashboard" **Triage result:** ISO 5060 locale conventions, major severity. Suggests: - Add instruction for ko-KR: "Display dates in YYYY.MM.DD format, times in 24-hour format" ### Non-localization tickets **Ticket:** "API rate limiting returns 429 errors during peak hours" **Triage result:** Not localization-related. A comment is posted to the Jira ticket automatically explaining why. No human review needed. ### Text expansion / layout issues **Ticket:** "German compound words overflow sidebar navigation buttons" **Triage result:** ISO 5060 design and markup. Suggests a Jira task for the CSS fix (code changes are coming in a future version) plus an instruction about character budget awareness for German. ### Multi-locale tickets **Ticket:** "Dashboard untranslated for pt-BR, es-MX, and ja-JP users" **Triage result:** The agent produces separate glossary items for each locale mentioned. Each is its own suggestion, reviewed independently. ## What the AI Can and Can't Do | Can do | Can't do (yet) | | --- | --- | | Suggest specific glossary items with translations | Fix code or modify translation files directly | | Create locale-specific instructions | Create pull requests with code changes | | Update brand voice entries | Transition Jira issue status | | Create Jira tasks for developer work | Auto-merge changes without approval | | Classify issues using ISO 5060 | | | Read existing engine config to avoid duplicates | | {% callout type="info" title="Code-level fixes" %} For issues that require code changes (RTL CSS, new locale files, encoding), the agent creates a Jira task with a detailed description. Direct code fixes via pull requests are coming in a future version. {% /callout %} ## Lifecycle Management The workflow tracks every state change: | Event | What happens | | --- | --- | | Label added | Job created, triage runs, suggestions appear | | Label removed | Active job cancelled, event logged | | Label re-added | Same job reopened with full history, new triage runs | | Issue closed | Active job cancelled, event logged | | Issue deleted | Active job cancelled, event logged | One job per Jira issue. Full audit trail. If you remove and re-add the label, the same job reopens with all previous events preserved and a fresh triage runs on top. ## Suggestion Types Each suggestion maps to a single operation: | Suggestion | What it does | Button | | --- | --- | --- | | Add glossary item | Creates a term mapping | "Add glossary item" | | Update glossary item | Changes an existing translation | "Update glossary item" | | Delete glossary item | Removes a term mapping | "Delete glossary item" | | Add instruction | Creates a locale instruction | "Add instruction" | | Update instruction | Changes an existing instruction | "Update instruction" | | Add brand voice | Creates brand voice for a locale | "Add brand voice" | | Update brand voice | Changes existing brand voice | "Update brand voice" | | Create Jira task | Creates a ticket for developer work | "Create task" | | Close issue | Posts a comment explaining why | "Close issue" | {% callout type="info" title="Engine changes are preferred" %} The agent prioritizes engine configuration (glossary, instructions, brand voice) over Jira tasks. Tasks are suggested only for developer code changes - RTL CSS fixes, new locale files, encoding issues. {% /callout %} ## Best Practices **Label generously.** Add "lingo" to anything that might be localization-related. The AI filters out irrelevant tickets automatically and posts a comment explaining why. False positives cost nothing. **Add context before labeling.** The more detail in the ticket description and comments, the better the triage. Mention the affected locale, the expected vs actual text, and where in the UI the issue appears. **Review each suggestion individually.** The AI suggests specific translations based on context, but you know your product's terminology best. Approve the ones that are right, dismiss the ones that aren't. **Check your engine config first.** The agent reads your existing glossary and instructions before suggesting changes. If your engine already has good coverage for a locale, the suggestions will be more targeted. **Create Jira tasks selectively.** When the agent suggests a Jira task, you pick the target project from a dropdown before approving. Choose the project where the relevant team works. ## Setup {% steps %} {% step title="Connect Jira" %} Go to **Settings** and click **Connect** under Jira (Atlassian App). Authorize on Atlassian. {% callout type="warning" %} Each Jira site can only connect to one Lingo.dev organization. If you see "Jira site already connected", disconnect it from the other org first. {% /callout %} {% /step %} {% step title="Add the label" %} Add the label **lingo** to any ticket. Case-insensitive - "Lingo", "LINGO", and "lingo" all work. Triage starts within seconds. {% /step %} {% step title="Review in the dashboard" %} Open **Jobs** in your sidebar. Triage jobs show real-time status. Click into a job for the full timeline and suggestions. {% /step %} {% /steps %} {% callout type="info" title="Webhook maintenance" %} Jira webhooks auto-refresh weekly. If webhooks stop firing, reconnect Jira in Settings to re-register them. {% /callout %} ## ISO 5060 Classification Every relevant ticket is classified using [ISO 5060:2024](https://iso5060.com/): | Category | Covers | | --- | --- | | Terminology | Inconsistent or incorrect terms | | Accuracy | Mistranslation, omission, untranslated content | | Linguistic conventions | Grammar, spelling, punctuation, encoding | | Style | Register, phrasing, inconsistency | | Locale conventions | Date, number, currency formatting | | Audience appropriateness | Cultural references, inclusivity | | Design and markup | Layout, truncation, text expansion, RTL | Three severity levels: **critical** (unusable), **major** (affects comprehension), **minor** (cosmetic). ## Coming Soon We're working on the next version of Jira triage: - **Auto-apply engine changes** - configurable per organization. When enabled, glossary items, instructions, and brand voice suggestions execute automatically without waiting for human approval. Review becomes optional, not blocking. - **Pull request creation** - for issues that require code changes, the agent will create PRs directly in connected GitHub repositories instead of creating Jira tasks. - **Bulk triage** - label multiple tickets at once and review all suggestions in a single batch view. ## Next Steps {% card-grid %} {% link-card title="Localization Engines" href="/docs/platform/engines" description="Configure the engines that triage suggestions modify" icon="gear" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" description="Understand glossary items that triage creates" icon="book" /%} {% link-card title="Instructions" href="/docs/platform/instructions" description="Learn about per-locale instructions" icon="file-code" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" description="Configure brand voice profiles per locale" icon="chat" /%} {% /card-grid %} - [Emails Localization](https://lingo.dev/en/guides/emails): How to localize transactional and marketing emails using Lingo.dev - translate email templates at build time with the CLI or at runtime with the localization API. The Lingo.dev [CLI](/docs/cli) and [localization API](/docs/api) support two patterns for email localization: translate template files at build time to ship per-locale templates, or translate content at runtime before sending. Both run through a configured [localization engine](/docs/platform/engines) with glossary rules, brand voice, and model selection applied automatically. ## Choose Your Approach | Approach | Best for | How it works | | --- | --- | --- | | **Build-time** (CLI) | Template files - react-email, MJML, HTML | Translate files in your repository, deploy per-locale templates | | **Runtime** (API) | Dynamic content, ESP-rendered templates | Call the [localization API](/docs/api) before sending, pass translated content to your email provider | {% callout type="info" title="Which approach?" %} If your email templates live in your repository as files (HTML, MJML, or React components), use the build-time approach. If your email content is generated dynamically or stored in your email service provider, use the runtime approach. {% /callout %} ## Prerequisites Every translation runs through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard and generate an [API key](/docs/platform/api-keys). ## Build-Time Localization The CLI translates email template files directly. Configure a bucket that matches your template format, run the CLI, and get per-locale template files alongside your source templates. {% tabs %} {% tab label="react-email" %} [react-email](https://react.email/) templates are React components that render to HTML. Extract translatable strings into JSON resource files using an i18n library like [react-i18next](https://react.i18next.com/), then translate the JSON files with the CLI. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["emails/locales/[locale].json"] } } } ``` At render time, pass the locale to your email component and load the corresponding JSON file. The react-email `render()` function produces locale-specific HTML ready to send. {% /tab %} {% tab label="MJML" %} [MJML](https://mjml.io/) templates produce responsive HTML from a markup language. Two approaches work for localizing MJML-based emails. **Option A: Translate compiled HTML** Compile your MJML source to HTML, then translate the HTML output with the `html` bucket: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "html": { "include": ["emails/compiled/[locale]/welcome.html"] } } } ``` **Option B: Translate extracted strings** Extract translatable strings into JSON files and use a templating layer (Handlebars, Nunjucks) to inject them into your MJML templates at build time: ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["emails/locales/[locale].json"] } } } ``` {% /tab %} {% tab label="HTML" %} Plain HTML email templates translate directly with the `html` bucket. The CLI extracts text content and translatable attributes, translates them, and writes per-locale HTML files. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "html": { "include": ["emails/[locale]/welcome.html"] } } } ``` {% /tab %} {% /tabs %} Run translations with: ```bash npx lingo.dev@latest run ``` ## Runtime Localization When email content is dynamic - personalized notifications, user-generated content summaries, or marketing copy stored in a CMS - translate it at runtime before sending. This builds on the pattern described in the [Translation API guide](/guides/translation-api). ```javascript async function sendLocalizedEmail(userId, templateId, content) { const user = await db.users.findById(userId); const response = await fetch("https://api.lingo.dev/process/localize", { method: "POST", headers: { "X-API-Key": process.env.LINGODOTDEV_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ engineId: "eng_abc123", sourceLocale: "en", targetLocale: user.locale, data: { subject: content.subject, preheader: content.preheader, body: content.body, }, }), }); const { data } = await response.json(); await emailProvider.send({ to: user.email, subject: data.subject, html: renderTemplate(templateId, data), }); } ``` ## Best Practices | Area | Recommendation | | --- | --- | | Subject lines | Keep under 50 characters. Use a [glossary](/docs/platform/glossaries) to lock brand names from translation. | | Preview text | Translate separately from the body - email clients display it independently. | | Brand voice | Configure [per-locale tone](/docs/platform/brand-voices) in the localization engine. Marketing emails in Japanese need a different register than German. | | RTL languages | Test rendered output in email clients for Arabic, Hebrew, and Persian. HTML `dir="rtl"` handling varies across clients. | | Key locking | Use [locked keys](/docs/cli/key-locking) for URLs, product names, and legal identifiers that should not be translated. | ## Next Steps {% card-grid %} {% link-card title="Translation API" href="/guides/translation-api" icon="code" description="Full guide for runtime localization via the API" /%} {% link-card title="Brand Voices" href="/docs/platform/brand-voices" icon="chat" description="Set tone and formality per target locale" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Control which terms get translated and which stay as-is" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="Automate email template translation on every push" /%} {% /card-grid %} - [Localization Guides](https://lingo.dev/en/guides/): Step-by-step guides for localizing APIs, web apps, mobile apps, emails, static content, CMS platforms, and CI/CD workflows using a Lingo.dev localization engine. Each guide walks through a specific localization scenario end-to-end - from configuring a localization engine on Lingo.dev to shipping translated output in production. Every guide assumes you have an organization and at least one localization engine configured. ## How Guides Work Every product has different localization surfaces. A web app needs translated UI strings. An API needs localized responses. A documentation site needs translated Markdown files. Each surface connects to the same localization engine, but the integration pattern differs. These guides cover seven integration patterns: | Surface | What gets localized | Integration method | | --- | --- | --- | | [Translation API](/guides/translation-api) | Key-value data, dynamic content | HTTP API calls | | [Web apps](/guides/web-app) | UI strings, component text | CLI + framework integration | | [Mobile apps](/guides/mobile-app) | iOS `.strings`, Android `.xml` | CLI + mobile build pipeline | | [iOS apps](/guides/ios-app-localization) | Xcode String Catalogs `.xcstrings` | CLI + GitHub Actions | | [Android apps](/guides/android-app-localization) | Android `strings.xml` | CLI + GitHub Actions | | [Next.js with Markdoc](/guides/markdoc-nextjs-localization) | Markdoc pages and JSON UI strings | CLI + GitHub Actions | | [Rails with i18n](/guides/ruby-on-rails-localization) | Rails `config/locales` YAML | CLI + GitHub Actions | | [Emails](/guides/emails) | Transactional and marketing templates | API or CLI depending on template format | | [Static content](/guides/static-content) | Markdown, JSON, YAML files | CLI with file-based translation | | [CI/CD workflows](/guides/ci-cd-workflows) | All of the above, automated | GitHub Actions, GitLab CI, Bitbucket Pipelines | {% callout type="info" title="Prerequisites" %} All guides require an [API key](/docs/platform/api-keys) and a configured [localization engine](/docs/platform/engines). If you haven't set these up yet, start with the [documentation](/docs/platform). {% /callout %} ## Choose Your Guide {% card-grid %} {% link-card title="Translation API" href="/guides/translation-api" description="Send key-value data to the localization API and get translations back programmatically" icon="code" /%} {% link-card title="Web App" href="/guides/web-app" description="Localize React, Next.js, and other web frameworks using the CLI" icon="globe" /%} {% link-card title="Mobile App" href="/guides/mobile-app" description="Localize iOS and Android applications with per-locale model selection" icon="rocket" /%} {% link-card title="iOS with String Catalogs" href="/guides/ios-app-localization" description="End-to-end Xcode .xcstrings localization with CLI and GitHub Actions" icon="rocket" /%} {% link-card title="Android with strings.xml" href="/guides/android-app-localization" description="End-to-end Android XML localization with CLI and GitHub Actions" icon="rocket" /%} {% link-card title="Rails with i18n" href="/guides/ruby-on-rails-localization" description="End-to-end Rails config/locales YAML localization with CLI and GitHub Actions" icon="code" /%} {% link-card title="Emails" href="/guides/emails" description="Translate transactional and marketing email templates with brand voice applied" icon="chat" /%} {% link-card title="Static Content" href="/guides/static-content" description="Translate Markdown, JSON, and other static files in your repository" icon="file-code" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" description="Automate localization on every push with GitHub Actions, GitLab CI, or Bitbucket" icon="git-branch" /%} {% link-card title="Engine Setup with MCP" href="/guides/mcp-engine-setup" description="Configure a localization engine using AI coding assistants through the Lingo.dev MCP server" icon="gear" /%} {% link-card title="Jira Triage" href="/guides/triage" description="Automatically triage Jira tickets for localization relevance with AI-powered suggestions" icon="lightning" /%} {% /card-grid %} - [Mobile App Localization](https://lingo.dev/en/guides/mobile-app): How to localize iOS, Android, Flutter, and React Native applications using Lingo.dev - translate native resource files through a configured localization engine. The Lingo.dev [CLI](/docs/cli) translates native mobile resource files - Xcode `.strings`, Android XML, Flutter ARB, and React Native JSON - through a configured [localization engine](/docs/platform/engines). Each platform has a dedicated bucket type that understands the file format, preserves structure, and handles plurals natively. ## Platform Overview | Platform | Native format | CLI bucket | Typical file path | | --- | --- | --- | --- | | iOS (Xcode) | `.strings` | `xcode-strings` | `[locale].lproj/Localizable.strings` | | iOS (Xcode) | `.stringsdict` | `xcode-stringsdict` | `[locale].lproj/Localizable.stringsdict` | | iOS (Xcode) | `.xcstrings` | `xcode-xcstrings` | `Localizable.xcstrings` | | Android | `strings.xml` | `android` | `app/src/main/res/values-[locale]/strings.xml` | | Flutter | `.arb` | `flutter` | `lib/l10n/app_[locale].arb` | | React Native | `.json` | `json` | `src/locales/[locale].json` | ## Prerequisites Every CLI run sends content through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard and generate an [API key](/docs/platform/api-keys). ## Configure Your Platform {% tabs %} {% tab label="iOS" %} Xcode supports three localization formats. Use the one that matches your project setup. **String Catalogs (`.xcstrings`)** - the modern Xcode format introduced in Xcode 15. A single JSON file contains all locales, and Xcode updates it automatically when you add new strings. The CLI mutates this file in place - no `[locale]` placeholder needed. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "xcode-xcstrings": { "include": ["MyApp/Localizable.xcstrings"] } } } ``` **Legacy `.strings` files** - one file per locale in `[locale].lproj/` directories. If your project also uses `.stringsdict` for plurals, add both buckets. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "xcode-strings": { "include": ["MyApp/[locale].lproj/Localizable.strings"] }, "xcode-stringsdict": { "include": ["MyApp/[locale].lproj/Localizable.stringsdict"] } } } ``` See Apple's [localization documentation](https://developer.apple.com/documentation/xcode/localization) for setting up Xcode's i18n infrastructure. {% /tab %} {% tab label="Android" %} Android uses XML resource files in `values-[locale]/` directories. The CLI's `android` bucket understands ``, ``, ``, and `` elements natively. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "android": { "include": ["app/src/main/res/values-[locale]/strings.xml"] } } } ``` See Android's [localization guide](https://developer.android.com/guide/topics/resources/localization) for setting up resource directories and string references. {% /tab %} {% tab label="Flutter" %} Flutter uses Application Resource Bundle (`.arb`) files for localization. The CLI's `flutter` bucket preserves ARB metadata including `@`-prefixed descriptions and placeholders. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "flutter": { "include": ["lib/l10n/app_[locale].arb"] } } } ``` See Flutter's [internationalization guide](https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization) for configuring `flutter_localizations` and generating the `AppLocalizations` class. {% /tab %} {% tab label="React Native" %} React Native apps typically use [react-i18next](https://react.i18next.com/) with JSON translation files - the same pattern as React web apps. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["src/locales/[locale].json"] } } } ``` {% /tab %} {% /tabs %} ## Running Translations Translate all resource files in one command: ```bash npx lingo.dev@latest run ``` The CLI reads your source locale files, computes what changed since the last run using the [lockfile](/docs/cli/lockfile), translates only the delta, and writes results to target locale files. Target a specific platform when your project contains multiple resource types: ```bash npx lingo.dev@latest run --bucket android npx lingo.dev@latest run --bucket xcode-xcstrings ``` ## Plurals and Platform Conventions Each mobile platform handles plural forms differently - iOS uses `.stringsdict` or String Catalog rules, Android uses `` XML elements, and Flutter uses ICU MessageFormat in ARB files. The CLI preserves each platform's native plural structure during translation and generates the correct plural categories for each target locale. {% callout type="info" title="Translator notes" %} Mobile strings are often short and context-dependent. Use [translator notes](/docs/cli/translator-notes) in Xcode `.xcstrings` files to give the localization engine context about where a string appears - "button label in checkout flow" translates differently than "navigation menu item." {% /callout %} ## Platform Deep Dives {% card-grid %} {% link-card title="iOS with String Catalogs" href="/guides/ios-app-localization" icon="rocket" description="End-to-end guide for Xcode .xcstrings with CLI and GitHub Actions" /%} {% link-card title="Android with strings.xml" href="/guides/android-app-localization" icon="rocket" description="End-to-end guide for Android XML resources with CLI and GitHub Actions" /%} {% /card-grid %} ## Next Steps {% card-grid %} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" icon="file-code" description="Full reference for all mobile file formats and bucket types" /%} {% link-card title="Glossaries" href="/docs/platform/glossaries" icon="book" description="Lock brand names and technical terms from translation" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="Automate mobile translations on every push" /%} {% link-card title="Key Locking" href="/docs/cli/key-locking" icon="shield" description="Copy specific values without translating them" /%} {% /card-grid %} - [Static Content Localization](https://lingo.dev/en/guides/static-content): How to localize Markdown, MDX, JSON, YAML, subtitles, and other static files using Lingo.dev - translate documentation, blog posts, and structured content through a configured localization engine. The Lingo.dev [CLI](/docs/cli) translates static files in your repository - Markdown, MDX, Markdoc, JSON, YAML, subtitles, and more - through a configured [localization engine](/docs/platform/engines). Point it at your content, run once, and get translated files alongside your source. ## Supported Content Types | Content type | Format | CLI bucket | Example path | | --- | --- | --- | --- | | Documentation | Markdown | `markdown` | `docs/[locale]/getting-started.md` | | Documentation | MDX | `mdx` | `docs/[locale]/getting-started.mdx` | | Documentation | Markdoc | `markdoc` | `docs/[locale]/getting-started.mdoc` | | Structured data | JSON | `json` | `data/[locale].json` | | Structured data | YAML | `yaml` | `data/[locale].yaml` | | Blog posts | Markdown / MDX | `markdown` / `mdx` | `blog/[locale]/post-slug.md` | | Subtitles | SRT | `srt` | `subs/[locale]/intro.srt` | | Subtitles | VTT | `vtt` | `subs/[locale]/intro.vtt` | | Spreadsheets | CSV | `csv-per-locale` | `data/[locale].csv` | | Config strings | Properties | `properties` | `lang/[locale].properties` | | Config strings | Gettext PO | `po` | `locale/[locale]/messages.po` | | Plain text | TXT | `txt` | `content/[locale]/readme.txt` | ## Prerequisites Every CLI run sends content through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard and generate an [API key](/docs/platform/api-keys). ## Documentation Sites Most documentation frameworks organize translated content in per-locale directories. The CLI's `markdown`, `mdx`, and `markdoc` buckets translate these files while preserving frontmatter, code blocks, and component syntax. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "markdown": { "include": ["docs/[locale]/getting-started.md"] }, "mdx": { "include": ["docs/[locale]/setup.mdx"] } } } ``` Adjust the `include` pattern to match your framework's directory convention: | Framework | Locale directory convention | Reference | | --- | --- | --- | | Docusaurus | `i18n/[locale]/docusaurus-plugin-content-docs/current/` | [Docusaurus i18n guide](https://docusaurus.io/docs/i18n/introduction) | | Nextra | Per-locale pages or JSON dictionaries | [Nextra documentation](https://nextra.site/) | | Hugo | `content/[locale]/` | [Hugo multilingual guide](https://gohugo.io/content-management/multilingual/) | | Astro | `src/content/[locale]/` or JSON dictionaries | [Astro i18n guide](https://docs.astro.build/en/guides/internationalization/) | | VitePress | `[locale]/` directory prefix | [VitePress i18n](https://vitepress.dev/guide/i18n) | | MkDocs | Per-locale `docs/` with i18n plugin | [MkDocs i18n plugin](https://github.com/ultrabug/mkdocs-static-i18n) | {% callout type="info" title="MDX components" %} The `mdx` bucket preserves JSX component syntax during translation. Custom components like ``, ``, and `` pass through unchanged - only the text content inside them is translated. {% /callout %} ## Structured Data JSON and YAML files translate with the `json` and `yaml` buckets. Use [locked keys](/docs/cli/key-locking) to prevent non-translatable values (IDs, URLs, configuration flags) from being modified. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["content/[locale].json"], "lockedKeys": ["id", "slug", "url"] }, "yaml": { "include": ["data/[locale].yaml"] } } } ``` For YAML files that use the locale code as the root key (common in Rails and Hugo), use the `yaml-root-key` bucket instead - it reads from the source locale key and writes to target locale keys within the same file. ## Subtitles SRT and VTT subtitle files translate with the `srt` and `vtt` buckets. The CLI preserves all timing data, cue indices, and formatting tags - only the text content is translated. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "srt": { "include": ["subs/[locale]/intro.srt"] } } } ``` ## Working with Large Content Static content repositories can contain thousands of files. The CLI handles this efficiently through three mechanisms: | Mechanism | How it helps | | --- | --- | | [Lockfile](/docs/cli/lockfile) | Tracks SHA-256 fingerprints of source content. Only new or modified files trigger translation on subsequent runs. | | [Parallel processing](/docs/cli/parallel-processing) | Distributes translation across concurrent workers. Configure with `run --concurrency 20`. | | [Targeted runs](/docs/cli/large-projects) | Process a specific bucket or locale: `run --bucket markdown` or `run --target-locale es`. | ## Next Steps {% card-grid %} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" icon="file-code" description="Full reference for all 25+ file formats the CLI can translate" /%} {% link-card title="Key Locking" href="/docs/cli/key-locking" icon="shield" description="Prevent specific values from being translated" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="Automate static content translation on every push" /%} {% link-card title="Lockfile" href="/docs/cli/lockfile" icon="gear" description="How incremental translation tracking works" /%} {% /card-grid %} - [Web App Localization](https://lingo.dev/en/guides/web-app): How to localize React, Next.js, Vue, Angular, Svelte, PHP, Python, and Rails web applications using Lingo.dev - translate resource files through a configured localization engine. The Lingo.dev [CLI](/docs/cli) translates your web app's resource files - JSON, YAML, XLIFF, PO, or PHP - through a configured [localization engine](/docs/platform/engines). Set up i18n in your framework, point the CLI at your translation files, and run. ## How It Works Every web framework has an i18n library that loads translations from resource files - JSON for React, XLIFF for Angular, PO for Django, and so on. The CLI translates those files directly, so the framework picks up translations without any code changes. {% steps %} {% step title="Set up i18n in your framework" %} Use your framework's official i18n library to add locale-aware routing, a translation function, and source-language resource files. Each framework section below links to the official setup guide. {% /step %} {% step title="Configure the CLI" %} Create an `i18n.json` file that tells the CLI where your translation files live and which locales to target. The bucket type matches your framework's resource format. {% /step %} {% step title="Run translations" %} Run `npx lingo.dev@latest run` and the CLI translates your resource files through the localization engine - glossary rules, brand voice, and model selection apply automatically. {% /step %} {% /steps %} ## Prerequisites Every CLI run sends content through a [localization engine](/docs/platform/engines) - the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard and generate an [API key](/docs/platform/api-keys). {% callout type="info" title="AI-assisted setup" %} The [i18n MCP](/docs/react/mcp) can scaffold your framework's entire i18n infrastructure automatically. Connect it to [Claude Code](/docs/react/mcp/claude-code), [Cursor](/docs/react/mcp/cursor), or [GitHub Copilot](/docs/react/mcp/github-copilot) and prompt "Set up i18n" - the agent follows a 13-step checklist to configure routing, translation files, and a language switcher. {% /callout %} ## JavaScript Frameworks {% tabs %} {% tab label="React" %} [react-i18next](https://react.i18next.com/) loads translations from JSON files and provides a `useTranslation` hook that maps keys to translated strings at runtime. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["public/locales/[locale]/translation.json"] } } } ``` {% /tab %} {% tab label="Next.js" %} [next-intl](https://next-intl.dev/) for the App Router or [next-i18next](https://github.com/i18next/next-i18next) for the Pages Router. Both load translations from JSON files organized by locale. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["messages/[locale].json"] } } } ``` {% /tab %} {% tab label="Vue" %} [vue-i18n](https://vue-i18n.intlify.dev/) provides a `$t()` function and `useI18n` composable for accessing translations in templates and setup functions. Translations load from JSON files. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["src/locales/[locale].json"] } } } ``` {% /tab %} {% tab label="Nuxt" %} [@nuxtjs/i18n](https://i18n.nuxtjs.org/) wraps vue-i18n and adds locale-aware routing, SEO meta tags, and lazy loading out of the box. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["locales/[locale].json"] } } } ``` {% /tab %} {% tab label="Svelte" %} [svelte-i18n](https://github.com/kaisermann/svelte-i18n) provides a store-based `$_()` translation function for SvelteKit apps. Translations load from JSON dictionaries per locale. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["src/lib/locales/[locale].json"] } } } ``` {% /tab %} {% tab label="Angular" %} [@angular/localize](https://angular.dev/guide/i18n) is Angular's built-in i18n system. It extracts marked strings into XLIFF files and builds per-locale bundles at compile time. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "xliff": { "include": ["src/locale/messages.[locale].xlf"] } } } ``` {% /tab %} {% tab label="Astro" %} Astro supports [i18n routing natively](https://docs.astro.build/en/guides/internationalization/). Pair it with a JSON translation loader for UI strings, or use content collections with per-locale Markdown and MDX files for content-heavy pages. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "json": { "include": ["src/i18n/[locale].json"] } } } ``` {% /tab %} {% /tabs %} ## Server-Side Frameworks {% tabs %} {% tab label="PHP / Laravel" %} Laravel ships with [built-in localization](https://laravel.com/docs/localization) that loads translations from PHP files organized by locale directory. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "php": { "include": ["lang/[locale]/messages.php"] } } } ``` {% /tab %} {% tab label="Python / Django" %} Django's [translation framework](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) uses GNU gettext. Developers mark strings with `gettext()`, then `makemessages` extracts them into PO files. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "po": { "include": ["locale/[locale]/LC_MESSAGES/django.po"] } } } ``` {% /tab %} {% tab label="Ruby on Rails" %} Rails ships with [built-in i18n](https://guides.rubyonrails.org/i18n.html) that loads translations from YAML files organized by locale key. ```json { "$schema": "https://lingo.dev/schema/i18n.json", "version": "1.15", "locale": { "source": "en", "targets": ["es", "fr", "de", "ja"] }, "buckets": { "ruby-on-rails": { "include": ["config/locales/[locale].yml"] } } } ``` {% /tab %} {% /tabs %} ## Running Translations With `i18n.json` configured, translate all resource files in one command: ```bash npx lingo.dev@latest run ``` The CLI reads your source locale files, computes what changed since the last run using the [lockfile](/docs/cli/lockfile), translates only the delta, and writes results to target locale files. Existing translations are preserved - the CLI only fills in missing or updated strings. Target a specific locale or bucket when working on a subset: ```bash npx lingo.dev@latest run --target-locale es npx lingo.dev@latest run --bucket json ``` ## Next Steps {% card-grid %} {% link-card title="CLI Configuration" href="/docs/cli/configuration" icon="gear" description="Full i18n.json reference - buckets, locale codes, and advanced options" /%} {% link-card title="Supported Formats" href="/docs/cli/supported-formats" icon="file-code" description="All 25+ file formats the CLI can translate" /%} {% link-card title="CI/CD Workflows" href="/guides/ci-cd-workflows" icon="git-branch" description="Automate translations on every push" /%} {% link-card title="i18n MCP" href="/docs/react/mcp" icon="lightning" description="AI-assisted i18n setup for your framework" /%} {% /card-grid %} ## Research - [Retrieval Augmented Localization Cuts LLM Terminology Errors 17-45%](https://lingo.dev/en/research/retrieval-augmented-localization): Raw LLMs translating isolated paragraphs produce 17-45% more terminology errors without a domain glossary. Holistic quality metrics miss this entirely. Production localization translates isolated paragraphs and strings. A CI/CD pipeline diffs against the previous version and retranslates what changed — a UI string, a tooltip, a modified paragraph. Each request arrives at the LLM in isolation — without the surrounding page, without the document's full context, without any signal that this text is EU legal prose versus marketing copy. Without domain context injected at inference time, every isolated request is a fresh opportunity for terminology drift. **Retrieval Augmented Localization (RAL)** closes this gap by enriching each translation request with glossary terms, brand voice rules, and locale-specific instructions at inference time — the same retrieve-inject pattern behind [Retrieval Augmented Generation (RAG)](https://arxiv.org/abs/2005.11401). In a controlled evaluation across five LLM providers and five European languages, RAL reduced terminology errors by 16.6-44.6%. **Key findings:** - RAL reduced terminology errors by 16.6-44.6% across all five LLM providers tested - Holistic quality scores ([GEMBA-DA](https://arxiv.org/abs/2302.14520)) could not detect these differences. Deltas of 0.0007-0.0178, while [MQM](https://themqm.org/) counted thousands fewer errors - Models with lower baseline terminology scores gained the most: Mistral (-44.6%) and Deepseek (-42.1%) vs. Anthropic (-24.4%) and Google (-16.6%) - Portuguese showed the largest per-locale improvement; French the smallest — the further domain terminology diverges from training data, the more RAL helps ## The isolation problem The unit of production localization is small: a paragraph, a string, a diff. Rarely more than 200 words. Often fewer than 50. A JSON locale file contains individual keys, each holding a phrase or sentence. A CMS page is composed of blocks, each translated independently. When the model encounters "provider" in an isolated English paragraph, it has to decide: is this Portuguese "fornecedor" (the common word) or "prestador" (the official EU legal term)? Without domain context, it picks the common one. Multiply this across every domain-specific term in every locale, and terminology drift becomes the default. We set out to measure exactly how large this gap is — and whether injecting glossary context at inference time closes it. ## The first attempt showed nothing Our initial experiment used 37 glossary terms per locale pair and scored translations at article level - each article (200-700 words) evaluated as a single unit. The results: [GEMBA-DA](https://arxiv.org/abs/2302.14520) — the [WMT23](https://www2.statmt.org/wmt23/) winning holistic quality prompt — reported 0.952 for raw and 0.952 for configured. [MQM](https://themqm.org/) error annotation produced scores of 0.985-0.999 for every translation. No signal. No difference. By every metric, raw and glossary-augmented output were identical. We almost published a null result. Then we looked at why. Two problems. First, 37 glossary terms was too few - many test paragraphs contained zero glossary hits, so the configured engine had no advantage. Second, article-level scoring mathematically compresses quality differences into noise. MQM scores are computed as `1 - penalty / wordCount`. A single major terminology error in a 500-word article: `1 - 5/500 = 0.99`. The same error in a 50-word paragraph: `1 - 5/50 = 0.90`. The error is identical. The score is not. At article level, every real quality difference vanishes above 0.98. This is not just a measurement problem for our study. It applies to every translation benchmark that evaluates at page or article level. The errors are there. The metric cannot see them. ## We changed the lens For the second iteration, we made four changes. First, we expanded the glossary from 37 to 72 terms per locale pair — extracted from a training set of articles, separate from the test set used for evaluation. Second, we scored at paragraph level (50-200 words), matching the actual unit of production translation. Third, we added human reference translations to the MQM scoring prompt so judges could compare terminology directly. Fourth, we reduced judges from six to four. Deepseek and QWEN flagged only 1-3 errors per paragraph versus 5-15 for stricter judges — too lenient to add signal. The signal appeared immediately. ## Study design **Dataset.** We wanted the most terminology-dense text type available to stress-test glossary injection under demanding conditions. The EU AI Act (Regulation 2024/1689) fit: formal regulatory text where every paragraph carries terms with specific, officially defined translations. EUR-Lex publishes official human translations in all five target languages, enabling paragraph-by-paragraph scoring against ground truth. 15 articles, English into German, French, Spanish, Portuguese, and Italian. **Engines.** Each provider was tested in two localization-engine configurations: a **raw** engine (the LLM on its own — no glossary, no retrieval, translating from training knowledge alone) and a **RAL-augmented** engine (the same model, with a domain glossary, brand voice profile, and locale-specific instructions applied at inference time). Ten engines in total, sharing the same configuration across all RAL-augmented engines. | Provider | Model | Raw engine | RAL engine | |---|---|---|---| | Anthropic | claude-opus-4.6 | model only | glossary + brand voice + instructions | | OpenAI | gpt-5.4 | model only | glossary + brand voice + instructions | | Google | gemini-3.1-pro-preview | model only | glossary + brand voice + instructions | | Mistral | mistral-large-2512 | model only | glossary + brand voice + instructions | | Deepseek | deepseek-v3.2 | model only | glossary + brand voice + instructions | QWEN was initially included but dropped from the final set — translations were slow and unreliable, the same issue that disqualified it as a judge. **RAL configuration.** Each augmented engine contained 72 glossary terms per locale pair (70 custom translations plus 2 non-translatables), a brand voice profile (formal EU regulatory register), and 13 locale-specific instructions. Glossary terms were extracted from a training set of articles separate from the test set used for evaluation. Example entries: EN "provider" → PT "prestador" (not "fornecedor"); EN "high-risk AI system" → PT "sistema de IA de risco elevado" (not "sistema de IA de alto risco"). At inference time, only terms matching the current paragraph are retrieved and passed to the model — glossary size does not bloat the context window. Engines were configured on [Lingo.dev](https://lingo.dev) as stateful localization engines — persistent context applied to every request. **Scoring.** Each translated paragraph was scored by four LLM judges, averaged to smooth individual judge bias. Each judge scores all providers' outputs, not just its own: | Judge | Model | |---|---| | Anthropic | claude-sonnet-4.6 | | OpenAI | gpt-4.1 | | Google | gemini-2.5-flash | | Mistral | mistral-large-2512 | **GEMBA-MQM.** [MQM](https://themqm.org/) (Multidimensional Quality Metrics) is a standard framework for translation quality evaluation — normally performed by trained human annotators. [GEMBA-MQM](https://arxiv.org/abs/2310.13988), the WMT23 winning evaluation method, replaces human annotators with an LLM while following the same MQM protocol: the judge reads the translation and flags every error, assigning each a category and a severity. Error categories: accuracy, fluency, style, terminology. Severity weights follow the official MQM standard: minor = 1, major = 5, critical = 25. MQM score per paragraph: `max(0, 1 - weighted penalty / word count)`. A 50-word paragraph with one major terminology error scores `1 - 5/50 = 0.90`. A perfect paragraph scores 1.0. Error counts in the results tables are summed across all four judges and all paragraphs for a given provider and locale. One change from the standard GEMBA-MQM prompt: we added the human reference translation. GEMBA-MQM is reference-free by design — the judge evaluates quality without seeing the "correct" answer. We added references because EUR-Lex publishes official translations of the EU AI Act in all five target languages, giving judges ground truth to compare terminology against. **GEMBA-DA.** A holistic 0-1 quality score using the [GEMBA-DA](https://arxiv.org/abs/2302.14520) prompt (also WMT23 winning). Unlike MQM, it produces a single score with no error annotations. We include it as a sanity check — as the results show, it cannot detect terminology-level differences. Deepseek was excluded from the judge panel due to overly lenient scoring (1-3 errors per paragraph vs 5-15 for stricter judges). Averaging across four judges smooths individual bias, and the relative raw-vs-RAL improvement is consistent within every judge. **Sample size.** 535 paired paragraph observations per provider (107 paragraphs × 5 locales). Over 42,000 individual quality judgments total (535 paragraphs × 5 providers × 2 configurations × 8 scores each). ## Terminology errors drop 16.6-44.6% | Provider | Raw errors | RAL errors | Reduction | |---|---|---|---| | Mistral | 3,336 | 1,847 | **-44.6%** | | Deepseek | 3,672 | 2,127 | **-42.1%** | | OpenAI | 2,276 | 1,508 | **-33.7%** | | Anthropic | 1,559 | 1,179 | **-24.4%** | | Google | 1,901 | 1,586 | **-16.6%** | *Terminology error counts from MQM across 15 articles, 5 locales, and 4 judges.* Improvement tracked inversely with baseline score. Mistral and Deepseek — with the highest raw error counts — saw 42.1-44.6% reductions. Anthropic and Google — which already reflected more EU legal terminology in training — saw smaller gains. The pattern: RAL compensates for what the model doesn't already know. Meanwhile, GEMBA-DA - the holistic score - reported a delta of 0.0007-0.0178 between raw and RAL across all providers. The same translations that MQM flagged for 16.6-44.6% more terminology errors received nearly identical holistic scores. This is the measurement gap: holistic evaluation at any granularity cannot detect terminology-level quality differences. Total errors (all MQM categories) showed a smaller but consistent reduction across all five providers: | Provider | Raw total | RAL total | Change | |---|---|---|---| | Deepseek | 10,423 | 9,014 | -13.5% | | Mistral | 8,846 | 7,812 | -11.7% | | OpenAI | 7,563 | 7,155 | -5.4% | | Google | 7,793 | 7,545 | -3.2% | | Anthropic | 6,232 | 6,039 | -3.1% | The gap between terminology reduction (16.6-44.6%) and total reduction (3.1-13.5%) is largely explained by style. LLM judges tend to flag text as "awkward" when it diverges from their training-data preferences, even when the divergence moves toward the official reference — a known limitation called self-preference bias. Terminology and accuracy are anchored against the reference; style has no anchor beyond the judge's own sense of what sounds natural. ## Statistical significance Terminology error reduction was tested per provider using a paired Wilcoxon signed-rank test (one-sided, Holm-Bonferroni corrected across five providers). Per-paragraph terminology error counts were summed across four judges, then paired by paragraph (same source, same judges, raw vs RAL). | Provider | Paired paragraphs | Mean reduction/paragraph | 95% CI | Cohen's d | p (adjusted) | |---|---:|---:|---|---:|---| | Mistral | 532 | 2.80 | [2.42, 3.21] | 0.60 | < 0.001 | | Deepseek | 526 | 2.94 | [2.45, 3.44] | 0.50 | < 0.001 | | OpenAI | 535 | 1.44 | [1.12, 1.77] | 0.37 | < 0.001 | | Anthropic | 533 | 0.71 | [0.50, 0.93] | 0.28 | < 0.001 | | Google | 533 | 0.59 | [0.34, 0.85] | 0.20 | < 0.001 | All five providers show statistically significant terminology error reductions (p < 0.001 after Holm-Bonferroni correction for multiple comparisons), with 95% confidence intervals excluding zero. Effect sizes range from medium-large (Mistral, d = 0.60) to small (Google, d = 0.20) — consistent with the pattern that models with lower baseline terminology coverage benefit more from RAL. ## Where RAL matters most Portuguese showed the largest terminology improvements across all providers. Portuguese legal terminology diverges significantly from everyday Portuguese, and EU legal terms in Portuguese are underrepresented in LLM training data. French showed the smallest - French legal terms are well-represented in training corpora. {% callout type="info" title="Case study: OpenAI Portuguese" %} OpenAI's raw output translated the EU AI Act into Portuguese using "alto risco" 71 times (the colloquial "high risk"), "fornecedores" 39 times, and "fornecedor" 36 times. The official EUR-Lex translations use "risco elevado" and "prestadores." With RAL, OpenAI Portuguese terminology errors dropped from 648 to 266 — a **59% reduction**. {% /callout %} The pattern generalizes: locales whose domain terminology is further from the LLM's training distribution benefit more from RAL. ## The mechanism The effective mechanism is straightforward. At inference time, the engine decomposes input text into n-gram phrases and embeds them. It then runs cosine similarity search against the glossary's vector index to find matching terms. Matched terms are injected into the LLM's context window alongside the source text. The model doesn't guess "fornecedor" or "prestador" — it sees the correct mapping in context and uses it. Structurally identical to RAG: embed, retrieve, inject, generate. ## Provider ranking by raw quality Without RAL - raw model output only: | Rank | Provider | MQM avg | |---|---|---| | 1 | Anthropic | 0.955 | | 2 | OpenAI | 0.942 | | 3 | Google | 0.938 | | 4 | Mistral | 0.915 | | 5 | Deepseek | 0.883 | The 0.072 gap between Anthropic and Deepseek represents roughly 3-4 additional errors per 100-word paragraph. RAL narrowed this gap: Mistral with RAL (0.940 avg) approached Google's raw quality (0.938). A model at a fraction of the per-token cost, augmented with a 72-term glossary, matched the terminology accuracy of a more expensive model without one. ## What this means in production The quality gap between raw LLM output and production-ready localization is a context problem — and it compounds. After ten releases without RAL, three different wrong translations of "provider" coexist across the product. RAL breaks this pattern. The glossary is persistent — it applies to every request, regardless of what changed. The 72-term glossary that reduced errors by 16.6-44.6% in our study is not a one-time improvement. It is a consistency layer across every translation request over the lifetime of the product. Two findings for teams shipping LLM translations: first, holistic quality scores cannot detect terminology-level problems. GEMBA-DA — the WMT23 winning method — scored raw and RAL-augmented translations within 0.0007-0.0178 of each other. MQM counted 16.6-44.6% fewer terminology errors. If you evaluate at page level with a single score, you are not seeing the full picture. Second, the fix is simpler than the problem suggests. A domain glossary injected at inference time reduced terminology errors across every provider we tested. The model that translates best (Anthropic, MQM 0.955) still improved. The model with the highest baseline error rate (Deepseek, MQM 0.883) improved most. RAL is to localization what RAG is to generation: the engineering layer between the model and production. ## Next steps {% card-grid %} {% link-card title="Introducing Lingo.dev v1.0" href="/blog/introducing-lingodotdev-v1" description="The localization engineering platform built around RAL" icon="rocket" /%} {% link-card title="Localization engines" href="/docs/platform/engines" description="Configure models, glossaries, and brand voice per locale" icon="gear" /%} {% /card-grid %}