Integrating Lantern (keeping your translation files in sync)

On this page

Lantern is the source of truth for your translations. Your app keeps local locale files (locales/en.json, locales/fr.json, …) in sync by pulling them from the read API — typically as a step in your build or deploy.

  • Base URL: https://lantern.abyss-inn.ch
  • Auth: an API token from the project's API tab (per project). Tokens are read-only by default; granting write permissions lets an AI agent or CI push keys and translations back (see Writing back)
  • Endpoint: GET /api/v1/projects/<slug>/translations

The endpoint#

GET /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…
Query param Values Meaning
format json (default), csv Output format
locale a locale code (e.g. en) One language. Omit for all languages
nested 1 Nested JSON ({"home":{"title":…}}) instead of flat ({"home.title":…})

Responses

  • ?locale=en&nested=1 → one language:
    { "home": { "title": "Welcome" } }
    
  • (no locale) ?nested=1 → all languages, keyed by locale (best for syncing files):
    { "en": { "home": { "title": "Welcome" } }, "fr": { "home": { "title": "Bienvenue" } } }
    
  • format=csv → a key column plus one column per locale.

Errors: 401 (missing/invalid token), 403 (token doesn't match the slug), 400 (unknown locale / bad format), 404 (project not found).

To list the project's languages with display names (the call above only exposes locale codes), use:

GET /api/v1/projects/<slug>/languages
Authorization: Bearer lk_…
→ 200 [ { "locale": "en", "name": "English", "isDefault": true }, … ]

Token security#

  • The token is read-only and scoped to one project. Store it as an environment variable / CI secret (e.g. LANTERN_TOKEN) — never commit it.
  • To rotate: revoke it on the API tab and create a new one.

Sync strategies#

  1. Build-time pull (recommended). Run a pull step before your build/bundle so the shipped files are always current. You can .gitignore the generated locales/ or commit them — your call.
  2. Runtime fetch + cache. Long-running servers can fetch on boot and refresh on an interval. Cache in memory; fall back to the last good copy on error.
  3. Scheduled. For runtime apps that don't redeploy, cron the pull and reload.

Code#

Every example below fetches all locales in one request (?nested=1) and writes locales/<locale>.json. To pull a single language instead, add ?locale=en and write one file. Set LANTERN_TOKEN in your environment and replace my-project with your slug.

Shell (curl + jq) — universal#

#!/usr/bin/env bash
set -euo pipefail
: "${LANTERN_TOKEN:?set LANTERN_TOKEN}"
BASE=https://lantern.abyss-inn.ch ; SLUG=my-project ; OUT=locales
mkdir -p "$OUT"
curl -fsS -H "Authorization: Bearer $LANTERN_TOKEN" \
  "$BASE/api/v1/projects/$SLUG/translations?nested=1" \
| jq -c 'to_entries[]' | while read -r e; do
    loc=$(jq -r '.key' <<<"$e")
    jq '.value' <<<"$e" > "$OUT/$loc.json"
    echo "wrote $OUT/$loc.json"
  done

JavaScript (Node)#

The repo ships a ready CLI — for JS/TS projects this is the simplest:

LANTERN_TOKEN=lk_… node cli/lantern-pull.mjs \
  --url https://lantern.abyss-inn.ch --project my-project --out locales

Inline equivalent:

import { mkdir, writeFile } from "node:fs/promises";

const BASE = "https://lantern.abyss-inn.ch", SLUG = "my-project";
const res = await fetch(`${BASE}/api/v1/projects/${SLUG}/translations?nested=1`, {
  headers: { Authorization: `Bearer ${process.env.LANTERN_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const all = await res.json();

await mkdir("locales", { recursive: true });
for (const [loc, messages] of Object.entries(all)) {
  await writeFile(`locales/${loc}.json`, JSON.stringify(messages, null, 2) + "\n");
}

TypeScript#

import { mkdir, writeFile } from "node:fs/promises";

type Messages = Record<string, unknown>;
type AllLocales = Record<string, Messages>;

const BASE = "https://lantern.abyss-inn.ch", SLUG = "my-project";
const res = await fetch(`${BASE}/api/v1/projects/${SLUG}/translations?nested=1`, {
  headers: { Authorization: `Bearer ${process.env.LANTERN_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const all = (await res.json()) as AllLocales;

await mkdir("locales", { recursive: true });
for (const [loc, messages] of Object.entries(all)) {
  await writeFile(`locales/${loc}.json`, JSON.stringify(messages, null, 2) + "\n");
}

Python (standard library, no dependencies)#

import json, os, pathlib, urllib.request

BASE, SLUG = "https://lantern.abyss-inn.ch", "my-project"
req = urllib.request.Request(
    f"{BASE}/api/v1/projects/{SLUG}/translations?nested=1",
    headers={"Authorization": f"Bearer {os.environ['LANTERN_TOKEN']}"},
)
all_locales = json.load(urllib.request.urlopen(req))

out = pathlib.Path("locales"); out.mkdir(exist_ok=True)
for loc, messages in all_locales.items():
    (out / f"{loc}.json").write_text(
        json.dumps(messages, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
    )
    print("wrote", loc)

C##

using System.Net.Http.Headers;
using System.Text.Json;

var baseUrl = "https://lantern.abyss-inn.ch";
var slug = "my-project";
var token = Environment.GetEnvironmentVariable("LANTERN_TOKEN")!;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var json = await http.GetStringAsync(
    $"{baseUrl}/api/v1/projects/{slug}/translations?nested=1");

using var doc = JsonDocument.Parse(json);
Directory.CreateDirectory("locales");
var opts = new JsonSerializerOptions { WriteIndented = true };
foreach (var locale in doc.RootElement.EnumerateObject())
{
    var text = JsonSerializer.Serialize(locale.Value, opts);
    await File.WriteAllTextAsync($"locales/{locale.Name}.json", text);
    Console.WriteLine($"wrote {locale.Name}");
}

Rust#

# Cargo.toml
[dependencies]
ureq = { version = "2", features = ["json"] }
serde_json = "1"
use std::{env, fs};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let base = "https://lantern.abyss-inn.ch";
    let slug = "my-project";
    let token = env::var("LANTERN_TOKEN")?;
    let url = format!("{base}/api/v1/projects/{slug}/translations?nested=1");

    let all: serde_json::Value = ureq::get(&url)
        .set("Authorization", &format!("Bearer {token}"))
        .call()?
        .into_json()?;

    fs::create_dir_all("locales")?;
    for (loc, messages) in all.as_object().unwrap() {
        fs::write(
            format!("locales/{loc}.json"),
            serde_json::to_string_pretty(messages)?,
        )?;
        println!("wrote {loc}");
    }
    Ok(())
}

Go#

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	base, slug := "https://lantern.abyss-inn.ch", "my-project"
	req, _ := http.NewRequest("GET", base+"/api/v1/projects/"+slug+"/translations?nested=1", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("LANTERN_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var all map[string]json.RawMessage
	if err := json.NewDecoder(res.Body).Decode(&all); err != nil {
		panic(err)
	}

	os.MkdirAll("locales", 0o755)
	for loc, msgs := range all {
		var pretty any
		json.Unmarshal(msgs, &pretty)
		out, _ := json.MarshalIndent(pretty, "", "  ")
		os.WriteFile("locales/"+loc+".json", out, 0o644)
		fmt.Println("wrote", loc)
	}
}

PHP#

<?php
$base = "https://lantern.abyss-inn.ch";
$slug = "my-project";
$token = getenv("LANTERN_TOKEN");

$ctx = stream_context_create(["http" => ["header" => "Authorization: Bearer $token"]]);
$json = file_get_contents("$base/api/v1/projects/$slug/translations?nested=1", false, $ctx);
$all = json_decode($json, true);

@mkdir("locales");
foreach ($all as $loc => $messages) {
    file_put_contents(
        "locales/$loc.json",
        json_encode($messages, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    );
    echo "wrote $loc\n";
}

Writing back — push keys & translations#

Read sync is one-way (Lantern → your files). A token with write permissions can also push the other way — ideal for an AI agent that drafts translations, or a CI step that registers new keys it finds in source. Grant scopes when you create the token on the API tab:

Scope Allows
key:create Create translation keys
key:edit Rename a key / change its namespace or description
translation:edit Set/overwrite translation values
language:manage Auto-create a missing target language on push

Endpoints#

POST /api/v1/projects/<slug>/keys
Authorization: Bearer lk_…            (needs key:create)
{ "key": "home.title", "description": "Landing headline" }
→ 201 { "id": "…", "key": "home.title", "created": true }   (409 if it exists)
PATCH /api/v1/projects/<slug>/keys
Authorization: Bearer lk_…            (needs key:edit)
{ "key": "home.title", "namespace": "home", "name": "headline" }
→ 200 { "id": "…", "key": "home.headline", "updated": true }   (409 if it collides)

Identify the key by its current dotted key path or by keyId; the other fields (name, namespace, description) are the new values, and any you omit keep their current value. The update is by id, so the key's translations, statuses and comments survive a rename — no delete-and-recreate.

PUT /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…            (needs translation:edit)
{ "locale": "fr", "createMissingKeys": true,
  "entries": [ { "key": "home.title", "value": "Bienvenue" } ] }
→ 200 { "updated": 1, "created": 0, "skipped": 0 }

createMissingKeys also needs key:create; pushing to a not-yet-existing locale needs language:manage (otherwise the request is rejected with 400 Unknown locale). Token writes show up in the project's activity feed attributed to the token's name.

Push CLI#

The repo ships cli/lantern-push.mjs (dependency-free, mirrors the pull CLI):

# Register keys (a JSON array of dotted paths, or [{ "key", "namespace", "description" }])
LANTERN_TOKEN=lk_… node cli/lantern-push.mjs \
  --url https://lantern.abyss-inn.ch --project my-project --keys keys.json

# Push one locale from a flat or nested JSON file (creating any missing keys)
LANTERN_TOKEN=lk_… node cli/lantern-push.mjs \
  --url https://lantern.abyss-inn.ch --project my-project \
  --locale fr --in fr.json --create-keys

Errors: 401 (bad token), 403 (missing scope, or token doesn't match the slug), 400 (bad body / unknown locale), 409 (key already exists).


Use Lantern from an AI agent (MCP)#

Lantern hosts an MCP server so an AI agent (Claude Code / Claude Desktop, etc.) can read and write a project's translations conversationally — no bespoke glue, nothing to install. The token's scopes decide what the agent can do (a read-only token can read but the write tools return 403).

Create an lk_ token on the project's API tab, then point your MCP client at the endpoint:

{
  "mcpServers": {
    "lantern": {
      "url": "https://lantern.abyss-inn.ch/api/v1/mcp",
      "headers": { "Authorization": "Bearer lk_…" }
    }
  }
}

The token already identifies the project, so there's nothing else to configure.

Tools exposed to the agent:

Tool Does Scope needed
list_projects The projects this token can access
list_languages A project's languages (locale + display name + default)
get_translations Read translations (locale?, nested?, format?)
create_key Create a key key:create
set_translations Upsert values for a locale (createMissingKeys?) translation:edit

createMissingKeys also needs key:create; auto-creating a new locale needs language:manage.

An lk_ token is scoped to one project, so the tools act on it automatically. A user-wide OAuth connection (below) can reach all your projects, so its tools take a project slug — call list_projects first, then pass project to the others. Permissions are re-checked per project on every call, so the agent can only ever do on a project what you can.

Connect with OAuth (sign in instead of pasting a token)#

If your MCP client supports OAuth (Claude's connectors, many IDE agents), you can connect by signing in instead of creating and pasting an lk_ token. Point the client at the same URL with no Authorization header:

{
  "mcpServers": {
    "lantern": { "url": "https://lantern.abyss-inn.ch/api/v1/mcp" }
  }
}

The client discovers Lantern's authorization server automatically, opens a browser to sign in, and shows a consent screen confirming that the agent will act as you, across all your projects. One authorization covers every project you can see (the agent picks one per call via the project argument); you can only grant permissions you yourself hold, and each is re-checked per project at call time. Access is tied to your account — revoke it any time by signing in elsewhere or contacting the workspace owner. Lantern implements standard OAuth 2.1 (PKCE, dynamic client registration, rotating refresh tokens), so any spec-compliant MCP client works.

The lk_ token method above keeps working unchanged — it's the right choice for CLIs, scripts, and CI, where there's no browser to complete a sign-in.

In CI (GitHub Actions)#

Add LANTERN_TOKEN as a repository secret, then pull before building:

- name: Pull translations from Lantern
  env:
    LANTERN_TOKEN: ${{ secrets.LANTERN_TOKEN }}
  run: |
    node cli/lantern-pull.mjs \
      --url https://lantern.abyss-inn.ch --project my-project --out locales

(Or call your language's pull script above.) The build then bundles the fresh files.

Plugging the files into an i18n library#

The nested JSON matches what most i18n libraries expect, e.g.:

  • JS/TS: i18next (resources), react-intl, vue-i18n, next-intl
  • Python: load the JSON and look up by dotted key (or flat output: drop nested=1)
  • C#: bind to IStringLocalizer JSON sources, or read directly
  • Rust: fluent/rust-i18n (you may prefer flat output — omit nested=1)

If your library wants flat keys ("home.title"), drop nested=1 from the URL.

Unity (Unity Localization)#

For Unity, don't wire up the raw JSON — use Unity's official Localization package as the runtime (Locales, String Tables, LocalizedString, TMP support) and let Lantern feed it.

The lantern-unity package is an editor-only bridge: a Window ▸ Lantern ▸ Pull Translations button pulls a project (via the read API's format=csv output) straight into a String Table Collection in one click — no manual files. Install it from Package Manager with Add package from git URL…:

https://github.com/Rabzizz/lantern-unity.git#v0.1.0

com.unity.localization is a dependency, so it installs automatically. Use a read-only lk_ token; it's stored per-machine in EditorPrefs and never ships in your build. It's read-only (Lantern → Unity) for now.

No package, just CSV? Unity Localization can import Lantern's CSV directly: save …/translations?format=csv to a file, add the CSV extension to your String Table Collection, point it at the file, and Import. The package just automates that round-trip.