Lantern es la fuente de verdad de tus traducciones. Tu aplicación mantiene archivos de
idioma locales (locales/en.json, locales/fr.json, …) sincronizados descargándolos de
la API de lectura — normalmente como un paso de tu compilación o despliegue.
- URL base:
https://lantern.abyss-inn.ch - Autenticación: un token de API de la pestaña API del proyecto (uno por proyecto). Los tokens son de solo lectura de forma predeterminada; conceder permisos de escritura permite que un agente de IA o tu CI devuelvan claves y traducciones (véase Escritura inversa)
- Endpoint:
GET /api/v1/projects/<slug>/translations
El endpoint#
GET /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…
| Parámetro | Valores | Significado |
|---|---|---|
format |
json (predeterminado), csv |
Formato de salida |
locale |
un código de idioma (p. ej. en) |
Un solo idioma. Omítelo para todos |
nested |
1 |
JSON anidado ({"home":{"title":…}}) en lugar de plano ({"home.title":…}) |
Respuestas
?locale=en&nested=1→ un solo idioma:{ "home": { "title": "Welcome" } }- (sin
locale)?nested=1→ todos los idiomas, indexados por idioma (lo mejor para sincronizar archivos):{ "en": { "home": { "title": "Welcome" } }, "fr": { "home": { "title": "Bienvenue" } } } format=csv→ una columnakeymás una columna por idioma.
Errores: 401 (token ausente/no válido), 403 (el token no corresponde al slug), 400
(idioma desconocido / formato no válido), 404 (proyecto no encontrado).
Para listar los idiomas del proyecto con sus nombres visibles (la llamada anterior solo expone los códigos), usa:
GET /api/v1/projects/<slug>/languages
Authorization: Bearer lk_…
→ 200 [ { "locale": "en", "name": "English", "isDefault": true }, … ]
Seguridad del token#
- El token es de solo lectura y está limitado a un proyecto. Guárdalo como variable de
entorno / secreto de CI (por ejemplo
LANTERN_TOKEN) — nunca lo subas al repositorio. - Para rotarlo: revócalo en la pestaña API y crea uno nuevo.
Estrategias de sincronización#
- Descarga en tiempo de compilación (recomendado). Ejecuta un paso de descarga antes de
compilar para que los archivos publicados estén siempre al día. Puedes añadir el
locales/generado a.gitignoreo versionarlo — tú decides. - Descarga en ejecución + caché. Los servidores de larga duración pueden descargar al arrancar y refrescar cada cierto intervalo. Cachea en memoria y recurre a la última copia válida si hay un error.
- Programada. Para aplicaciones en ejecución que no se redespliegan, programa la descarga con cron y recarga.
Código#
Cada ejemplo de abajo descarga todos los idiomas en una sola petición (?nested=1) y
escribe locales/<locale>.json. Para descargar un único idioma, añade ?locale=en y escribe
un solo archivo. Define LANTERN_TOKEN en tu entorno y sustituye my-project por tu 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)#
Se incluye una CLI lista para usar — para proyectos JS/TS es lo más sencillo:
LANTERN_TOKEN=lk_… node cli/lantern-pull.mjs \
--url https://lantern.abyss-inn.ch --project my-project --out locales
Equivalente en línea:
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 (biblioteca estándar, sin dependencias)#
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";
}
Escritura inversa — enviar claves y traducciones#
La sincronización de lectura es unidireccional (Lantern → tus archivos). Un token con permisos de escritura también puede enviar en el otro sentido — ideal para un agente de IA que redacta traducciones, o para un paso de CI que registra las claves nuevas que encuentra en el código. Concede los ámbitos al crear el token en la pestaña API:
| Ámbito | Permite |
|---|---|
key:create |
Crear claves de traducción |
key:edit |
Renombrar una clave / cambiar su espacio de nombres o su descripción |
translation:edit |
Establecer/sobrescribir valores de traducción |
language:manage |
Crear automáticamente un idioma de destino que falte al enviar |
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)
Identifica la clave por su ruta con puntos key actual o por keyId; los demás campos
(name, namespace, description) son los nuevos valores, y los que omitas conservan su
valor actual. La actualización se hace por id, así que las traducciones, los estados y los
comentarios de la clave sobreviven a un cambio de nombre — no hay que borrar y recrear.
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 requiere además key:create; enviar a un idioma que aún no existe
requiere language:manage (de lo contrario la petición se rechaza con
400 Unknown locale). Las escrituras hechas con un token aparecen en el registro de actividad
del proyecto, atribuidas al nombre del token.
CLI de envío#
Se incluye cli/lantern-push.mjs (sin dependencias, simétrica a la CLI de descarga):
# 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
Errores: 401 (token no válido), 403 (falta el ámbito, o el token no corresponde al
slug), 400 (cuerpo no válido / idioma desconocido), 409 (la clave ya existe).
Usar Lantern desde un agente de IA (MCP)#
Lantern aloja un servidor MCP para que un agente de IA (Claude Code / Claude Desktop, etc.) pueda leer y escribir las traducciones de un proyecto de forma conversacional — sin código pegamento a medida y sin nada que instalar. Los ámbitos del token deciden lo que puede hacer el agente (un token de solo lectura puede leer, pero las herramientas de escritura devuelven 403).
Crea un token lk_ en la pestaña API del proyecto y apunta tu cliente MCP al endpoint:
{
"mcpServers": {
"lantern": {
"url": "https://lantern.abyss-inn.ch/api/v1/mcp",
"headers": { "Authorization": "Bearer lk_…" }
}
}
}
El token ya identifica el proyecto, así que no hay nada más que configurar.
Herramientas expuestas al agente:
| Herramienta | Qué hace | Ámbito necesario |
|---|---|---|
list_projects |
Los proyectos a los que puede acceder este token | — |
list_languages |
Los idiomas de un proyecto (código + nombre visible + predeterminado) | — |
get_translations |
Leer traducciones (locale?, nested?, format?) |
— |
create_key |
Crear una clave | key:create |
set_translations |
Insertar/actualizar valores de un idioma (createMissingKeys?) |
translation:edit |
createMissingKeys requiere además key:create; crear automáticamente un idioma nuevo
requiere language:manage.
Un token lk_ está limitado a un proyecto, así que las herramientas actúan sobre él
automáticamente. Una conexión OAuth a nivel de usuario (más abajo) puede llegar a todos
tus proyectos, por lo que sus herramientas reciben un slug project — llama primero a
list_projects y luego pasa project a las demás. Los permisos se vuelven a comprobar por
proyecto en cada llamada, así que el agente nunca podrá hacer en un proyecto más de lo que
puedes hacer tú.
Conectar con OAuth (iniciar sesión en vez de pegar un token)#
Si tu cliente MCP admite OAuth (los conectores de Claude, muchos agentes de IDE), puedes
conectarte iniciando sesión en lugar de crear y pegar un token lk_. Apunta el cliente a
la misma URL sin cabecera Authorization:
{
"mcpServers": {
"lantern": { "url": "https://lantern.abyss-inn.ch/api/v1/mcp" }
}
}
El cliente descubre automáticamente el servidor de autorización de Lantern, abre un navegador
para iniciar sesión y muestra una pantalla de consentimiento que confirma que el agente
actuará como tú, en todos tus proyectos. Una sola autorización cubre todos los proyectos
que puedes ver (el agente elige uno por llamada mediante el argumento project); solo puedes
conceder permisos que tú mismo tengas, y cada uno se vuelve a comprobar por proyecto en el
momento de la llamada. El acceso está ligado a tu cuenta — revócalo cuando quieras iniciando
sesión en otro sitio o contactando con quien administra el espacio de trabajo. Lantern
implementa el estándar OAuth 2.1 (PKCE, registro dinámico de clientes, tokens de refresco
rotatorios), así que funciona cualquier cliente MCP que cumpla la especificación.
El método del token lk_ anterior sigue funcionando igual: es la opción adecuada para CLI,
scripts y CI, donde no hay un navegador que complete un inicio de sesión.
En CI (GitHub Actions)#
Añade LANTERN_TOKEN como secreto del repositorio y descarga antes de compilar:
- 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
(O llama al script de descarga de tu lenguaje, de los de arriba.) La compilación empaquetará entonces los archivos actualizados.
Conectar los archivos a una biblioteca i18n#
El JSON anidado coincide con lo que esperan la mayoría de las bibliotecas i18n, por ejemplo:
- JS/TS:
i18next(resources),react-intl,vue-i18n,next-intl - Python: carga el JSON y busca por clave con puntos (o salida plana: quita
nested=1) - C#: enlázalo a fuentes JSON de
IStringLocalizer, o léelo directamente - Rust:
fluent/rust-i18n(probablemente prefieras la salida plana — omitenested=1)
Si tu biblioteca quiere claves planas ("home.title"), quita nested=1 de la URL.
Unity (Unity Localization)#
Para Unity, no conectes el JSON en bruto: usa el paquete de
Localization oficial de
Unity como runtime (Locales, String Tables, LocalizedString, compatibilidad con TMP) y deja
que Lantern lo alimente.
El paquete lantern-unity es un puente solo
para el editor: un botón Window ▸ Lantern ▸ Pull Translations descarga un proyecto
(mediante la salida format=csv de la API de lectura) directamente a una String Table
Collection con un clic — sin archivos manuales. Instálalo desde el Package Manager con Add
package from git URL…:
https://github.com/Rabzizz/lantern-unity.git#v0.1.0
com.unity.localization es una dependencia, así que se instala automáticamente. Usa un token
lk_ de solo lectura; se guarda por máquina en EditorPrefs y nunca se incluye en tu
build. Por ahora es de solo lectura (Lantern → Unity).
¿Sin paquete, solo CSV? Unity Localization puede importar el CSV de Lantern directamente:
guarda …/translations?format=csv en un archivo, añade la extensión CSV a tu String Table
Collection, apúntala al archivo y pulsa Import. El paquete simplemente automatiza ese
recorrido.