← ARB Desk / API
Tokens

ARB Desk API

Two metered lanes, one JSON object back. annotate writes the @key metadata a translator needs (descriptions, declared placeholders, examples) without touching a message; translate translates template messages into a target locale, either a new file or only the keys an existing translation is missing.

The free checker (the port of gen-l10n's ICU message parser and placeholder rules, the plural preview per locale, the template-to-translation comparison and the setup snippets) runs only in the browser page. It makes no API call and costs nothing, so it is not part of this API. Over the API you send the checker's facts yourself, as the page does, in the facts string, and you get the model's reply as it is. The web page additionally merges the reply into your file and re-checks it with the same gen-l10n rules; an API caller does that step themselves (see what the page re-checks).

Everything below is taken from the app's own files: sdk.js (endpoints, headers, envelope), arbkit.js (buildInput(), plan(), LIMITS), SKILL.md (the model's instructions and the reply envelope), recon.js (how the page reads and re-checks a reply) and app.js (the run lifecycle).

The task field comes first

Every run body is a JSON object whose task field picks the lane. There are exactly two (ArbKit.LANES = ["annotate", "translate"]). SKILL.md tells the model to follow only that task's section and, if task is missing or unknown, to choose the closest task and write it in the reply's own task field. Always send it, and read the reply's task back.

taskWhat it doesSendReply keys
"annotate"Returns, for every sent message, the message value unchanged plus an @key object: a description for translators and one declared entry (type from facts, an example, and a format where gen-l10n requires one) per placeholder. Rewrites a translator cannot work with go in suggestions as advice.task, template_locale, arb, facts; optional contexttask, headline, notes, suggestions, arb
"translate"Translates the sent template messages into target_locale, keeping every placeholder and the ICU structure, with one plural case per CLDR category of the target language. mode new starts a file; fill adds only the missing keys, in the style of the existing ones.task, source_locale, target_locale, use_escaping, arb, facts; optional guidancetask, headline, notes, suggestions (always []), arb

Input fields

Exactly what ArbKit.buildInput(lane, ...) produces. Every value is a string: arb and facts are JSON objects encoded as strings (JSON.stringify(...)), not nested objects, and use_escaping is the string "on" or "off". Optional fields are sent only when not empty. The limits are the page's own (ArbKit.LIMITS); sdk.js enforces none of them, so treat them as what to stay inside, not as a documented server limit.

task "annotate"

FieldTypeRequiredMeaning
taskstringyes"annotate"
template_localestringyesThe template's locale as the checker reads it (from @@locale or the file name), for example "en".
arbstring (JSON)yesA JSON-encoded object with the sent messages in file order: "key": "message", each followed by its existing "@key" object when the template has one. Up to 40 keys and 9,000 characters per run (message length plus the length of its @key JSON); whole messages only, and the rest wait for the next run.
factsstring (JSON)yesA JSON-encoded object with the checker's facts; see the facts string.
contextstringnoWhat the app is, in your words. At most 3,000 characters: the page cuts a longer text at the last sentence or line end and appends [... cut by ARB Desk].
retry_notestringnoNot typed by the user: the page adds it on its single automatic retry when a reply was not the one JSON object the instructions require. That retry is a new run with its own Idempotency-Key (attempt a2).

task "translate"

FieldTypeRequiredMeaning
taskstringyes"translate"
source_localestringyesThe template's locale, for example "en".
target_localestringyesThe locale to translate into, in the page's normal form (de, pt_BR, zh_Hant). It must differ from the template's locale.
use_escapingstringyes"on" or "off": whether gen-l10n's use-escaping is on for your project. When on, a single ' starts a quoted span, so the model writes '' for an apostrophe.
arbstring (JSON)yesA JSON-encoded object with the sent template messages in file order, each followed by its "@key" object when that is an object (the descriptions give the model the meaning). Up to 60 keys and 6,000 characters per run, counted as for annotate.
factsstring (JSON)yesA JSON-encoded object with the checker's facts; see the facts string.
guidancestringnoGlossary, formality (du/Sie, tu/vous) and tone. At most 2,000 characters, cut like context.
retry_notestringnoAs for annotate.

Which keys the page sends (ArbKit.plan()): only messages that parse under gen-l10n and whose key names are valid; a message with a syntax error or an invalid name is never sent. For annotate, of those, the keys that still need metadata: a finding such as missing_meta, missing_description, undeclared_placeholder or datetime_no_format, or a placeholder without an example. For translate, the keys the existing translation does not have yet (all of them in new mode). Keys are taken in file order until the key or character budget is spent. /estimate checks none of this for you.

The facts string

facts is what the browser checker knows, JSON-encoded: build the object, then serialise it (json.dumps(facts), JSON.stringify(facts), ...). SKILL.md tells the model to respect it: in annotate the declared type of each placeholder must be exactly the type given here.

task "annotate"

KeyValue
checkerA string naming the checker and its rule sets.
template_localeThe template's locale.
keysOne entry per sent key, in order: key; placeholders, an array of {name, usage, type} where usage is plain, plural, select or date and type is the type gen-l10n infers, plus, only when they apply, declared_type, format, example (already declared) and declared: false (not declared yet); plural_cases (the cases each plural writes, such as "=1 other") and select_cases when the message has them; findings, the checker's finding codes for that key (such as missing_meta, plural_hack), when there are any.
keys_needing_workHow many keys need metadata in the whole template.
keys_sentHow many of them this run sends.
keys_totalHow many messages the template has.

task "translate"

KeyValue
checkerA string naming the checker and its rule sets.
source_locale, target_localeAs in the input.
target_languageThe target language's English name, for example "German".
plural_categoriesThe target locale's CLDR plural categories, for example ["one", "few", "many", "other"].
plural_examplesPer category, up to 6 whole numbers from 0 to 1,000 that fall in it; for a category no whole number reaches, those of 1.5, 0.5, 2.5 and 1000000 that do.
exact_cases_that_are_not_exactPer zero, one, two: the other numbers that category covers (up to 6), because gen-l10n treats =0, =1, =2 as those categories. {} when there are none.
keysOne entry per sent key: key; placeholders as {name, usage, type}; template_plural_cases and select_cases when the message has them.
keys_to_translateHow many template keys still need a translation.
keys_sentHow many this run sends.
keys_totalHow many messages the template has.
mode"new" (no existing translation) or "fill".
keys_already_translatedFill mode only: how many template keys the existing translation already has.
style_examplesFill mode only: up to 12 {key, source, translation} pairs from the existing translation, so the model keeps its terminology and tone.

The reply

The finished job's reply text (output.output, see step 5) is one JSON object, the same envelope for both lanes. SKILL.md asks for that object and nothing else (no prose, no code fences), with every key present every time and arb last:

{
  "task": "annotate" | "translate",
  "headline": "One or two plain sentences: what was done and what needs your attention.",
  "notes": [ { "key": "<a sent key, or \"\" for the whole file>", "kind": "check" | "info", "text": "..." } ],
  "suggestions": [ { "key": "<a sent key>", "problem": "...", "proposed": "<a complete ICU message>" } ],
  "arb": { ... }
}
KeyMeaning
taskThe lane the model followed.
headlineOne or two sentences, in English.
notesAt most 15, at most one per key. kind check: a person must confirm something (ambiguous meaning, missing context, a length risk in a button); info: a choice the model made that you should know about. key is "" for the whole file.
suggestionsannotate only (translate returns []): replacement ICU messages for texts a translator cannot translate well as written, such as an (s) plural or a single ambiguous word. Advice only; nothing applies them.
arbannotate: for every sent key, in the order sent, "key" with the message exactly as sent and "@key" with description, placeholders (when the message uses or declares any; each {type, example} plus any existing format, optionalParameters or isCustomDateFormat) and any other existing fields kept. translate: "@@locale" set to target_locale and one "key": "translation" per sent key; no @key objects.

How the page reads the reply (recon.js): it takes the first balanced JSON object in the text, after stripping a leading ```json and a trailing ``` fence, and parses it. arb may also arrive as a JSON string, which the page parses; anything that is not an object becomes {}. A note without text is dropped, an unknown kind is read as info, and the lane is the one that was sent, whatever the reply's task says. If the job reports truncated: true, the page first closes the cut-off JSON so the keys that did arrive can be used. If the reply does not parse at all, the page retries once with retry_note (a new run).

What the page re-checks (do the same)

The page never takes the reply on trust. recon.js merges it into your file and re-runs the gen-l10n rules on the result. An API caller should check at least the same things:

annotate

translate

Worked examples

These are exact run bodies as the page builds them (the page's example files). arb and facts are strings, so their quotes are escaped.

annotate: a bare template (Larderbee)

Eleven messages, almost none with metadata, and a short app description in context.

{
  "task": "annotate",
  "template_locale": "en",
  "arb": "{\"appTitle\":\"Larderbee\",\"addItem\":\"Add item\",\"itemsLeft\":\"{count} item(s) left\",\"expiresOn\":\"Expires {date}\",\"sharedBy\":\"Shared by {name}\",\"listName\":\"{owner}'s list\",\"clear\":\"Clear\",\"outOfStock\":\"{product} is out of stock\",\"@outOfStock\":{\"description\":\"Shown when an item runs out\"},\"reminderTime\":\"Remind me at {time}\",\"@reminderTime\":{\"placeholders\":{\"time\":{\"type\":\"DateTime\"}}},\"totalPrice\":\"Total: {amount}\",\"itemsSelected\":\"{count, plural, =1{1 selected} other{{count} selected}}\"}",
  "facts": "{\"checker\":\"ARB Desk (gen-l10n message parser and placeholder rules ported from flutter_tools; CLDR plural categories from the browser)\",\"template_locale\":\"en\",\"keys\":[{\"key\":\"appTitle\",\"placeholders\":[],\"findings\":[\"missing_meta\"]},{\"key\":\"addItem\",\"placeholders\":[],\"findings\":[\"missing_meta\"]},{\"key\":\"itemsLeft\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"plural_hack\",\"undeclared_placeholder\",\"missing_meta\"]},{\"key\":\"expiresOn\",\"placeholders\":[{\"name\":\"date\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"undeclared_placeholder\",\"missing_meta\"]},{\"key\":\"sharedBy\",\"placeholders\":[{\"name\":\"name\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"undeclared_placeholder\",\"missing_meta\"]},{\"key\":\"listName\",\"placeholders\":[{\"name\":\"owner\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"undeclared_placeholder\",\"missing_meta\"]},{\"key\":\"clear\",\"placeholders\":[],\"findings\":[\"missing_meta\"]},{\"key\":\"outOfStock\",\"placeholders\":[{\"name\":\"product\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"undeclared_placeholder\"]},{\"key\":\"reminderTime\",\"placeholders\":[{\"name\":\"time\",\"usage\":\"plain\",\"type\":\"DateTime\",\"declared_type\":\"DateTime\"}],\"findings\":[\"datetime_no_format\",\"missing_description\"]},{\"key\":\"totalPrice\",\"placeholders\":[{\"name\":\"amount\",\"usage\":\"plain\",\"type\":\"Object\",\"declared\":false}],\"findings\":[\"undeclared_placeholder\",\"missing_meta\"]},{\"key\":\"itemsSelected\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"num\",\"declared\":false}],\"plural_cases\":[\"=1 other\"],\"findings\":[\"undeclared_placeholder\",\"missing_meta\"]}],\"keys_needing_work\":11,\"keys_sent\":11,\"keys_total\":11}",
  "context": "Larderbee is a shared grocery and pantry list app for households. Items have expiry dates and reminders; lists can be shared with family members."
}

Decoded, the facts entry for reminderTime says the placeholder is already declared DateTime but has no format (datetime_no_format), and no description:

{
  "key": "reminderTime",
  "placeholders": [
    {
      "name": "time",
      "usage": "plain",
      "type": "DateTime",
      "declared_type": "DateTime"
    }
  ],
  "findings": [
    "datetime_no_format",
    "missing_description"
  ]
}

An abbreviated reply. The addItem, @addItem, reminderTime and @reminderTime entries and the itemsLeft suggestion are quoted from a real run. The real reply also had a headline, several notes, a second suggestion and the other nine keys with their @key objects; they are left out here, and the headline and notes values below are placeholders so the excerpt stays valid JSON. Note the added format "jm", which gen-l10n requires for a DateTime shown as {time}, and that proposed uses one/other with {count} in both.

{
  "task": "annotate",
  "headline": "(left out here)",
  "notes": [],
  "suggestions": [
    {
      "key": "itemsLeft",
      "problem": "\"item(s)\" is an English-only plural hack; many languages need different wording or extra plural categories that a hard-coded \"(s)\" can't express.",
      "proposed": "{count, plural, one{{count} item left} other{{count} items left}}"
    }
  ],
  "arb": {
    "addItem": "Add item",
    "@addItem": {
      "description": "Button or menu label that adds a new item to the pantry list."
    },
    "reminderTime": "Remind me at {time}",
    "@reminderTime": {
      "description": "Label for the time a reminder is set to go off for an item; {time} is that time of day.",
      "placeholders": {
        "time": {
          "type": "DateTime",
          "format": "jm",
          "example": "9:00 AM"
        }
      }
    }
  }
}

translate, new: English to Russian (Ridgemoss)

Twelve messages with descriptions, no existing Russian file (mode new) and guidance on formality. facts gives the Russian categories one few many other and says that one also covers 21, 31, 41 and so on, so SKILL.md requires the =1 cases to become one with {count} in the text.

{
  "task": "translate",
  "source_locale": "en",
  "target_locale": "ru",
  "use_escaping": "off",
  "arb": "{\"appTitle\":\"Ridgemoss\",\"@appTitle\":{\"description\":\"The app's name, shown in the title bar. A brand name.\"},\"welcomeBack\":\"Welcome back, {name}!\",\"@welcomeBack\":{\"description\":\"Greeting at the top of the home screen.\",\"placeholders\":{\"name\":{\"type\":\"String\",\"example\":\"Maya\"}}},\"trailCount\":\"{count, plural, =0{No trails nearby} =1{1 trail nearby} other{{count} trails nearby}}\",\"@trailCount\":{\"description\":\"Heading of the list of hiking trails near the user.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"12\"}}},\"distanceKm\":\"{distance} km\",\"@distanceKm\":{\"description\":\"Length of a trail in kilometres, shown on each trail card.\",\"placeholders\":{\"distance\":{\"type\":\"double\",\"format\":\"decimalPattern\",\"example\":\"8.5\"}}},\"lastHike\":\"Last hike: {date}\",\"@lastHike\":{\"description\":\"Date of the user's most recent hike, on the profile screen.\",\"placeholders\":{\"date\":{\"type\":\"DateTime\",\"format\":\"yMMMd\",\"example\":\"2026-09-14\"}}},\"elevationGain\":\"Elevation gain: {meters} m\",\"@elevationGain\":{\"description\":\"Total climb of a trail in metres, on the trail detail screen.\",\"placeholders\":{\"meters\":{\"type\":\"int\",\"example\":\"640\"}}},\"friendJoined\":\"{gender, select, female{{name} joined her first hike} male{{name} joined his first hike} other{{name} joined their first hike}}\",\"@friendJoined\":{\"description\":\"Activity feed item when a friend completes their first hike.\",\"placeholders\":{\"gender\":{\"type\":\"String\",\"example\":\"female\"},\"name\":{\"type\":\"String\",\"example\":\"Ana\"}}},\"photosAdded\":\"{count, plural, =1{You added a photo} other{You added {count} photos}}\",\"@photosAdded\":{\"description\":\"Confirmation after the user uploads trail photos.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"3\"}}},\"saveTrail\":\"Save trail\",\"@saveTrail\":{\"description\":\"Button that bookmarks a trail for offline use. Keep it short.\"},\"offlineBanner\":\"You're offline. Maps you saved still work.\",\"@offlineBanner\":{\"description\":\"Banner shown when the phone has no connection.\"},\"reviewPrompt\":\"How was {trailName}?\",\"@reviewPrompt\":{\"description\":\"Question asking the user to rate a trail they just finished.\",\"placeholders\":{\"trailName\":{\"type\":\"String\",\"example\":\"Eagle Ridge Loop\"}}},\"deleteConfirm\":\"Delete {count, plural, =1{this hike} other{these {count} hikes}}?\",\"@deleteConfirm\":{\"description\":\"Title of the dialog that confirms deleting recorded hikes.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"2\"}}}}",
  "facts": "{\"checker\":\"ARB Desk (gen-l10n rules ported from flutter_tools; CLDR plural rules from the browser's Intl.PluralRules)\",\"source_locale\":\"en\",\"target_locale\":\"ru\",\"target_language\":\"Russian\",\"plural_categories\":[\"one\",\"few\",\"many\",\"other\"],\"plural_examples\":{\"one\":[1,21,31,41,51,61],\"few\":[2,3,4,22,23,24],\"many\":[0,5,6,7,8,9],\"other\":[1.5,0.5,2.5]},\"exact_cases_that_are_not_exact\":{\"one\":[21,31,41,51,61,71]},\"keys\":[{\"key\":\"appTitle\",\"placeholders\":[]},{\"key\":\"welcomeBack\",\"placeholders\":[{\"name\":\"name\",\"usage\":\"plain\",\"type\":\"String\"}]},{\"key\":\"trailCount\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=0 =1 other\"]},{\"key\":\"distanceKm\",\"placeholders\":[{\"name\":\"distance\",\"usage\":\"plain\",\"type\":\"double\"}]},{\"key\":\"lastHike\",\"placeholders\":[{\"name\":\"date\",\"usage\":\"plain\",\"type\":\"DateTime\"}]},{\"key\":\"elevationGain\",\"placeholders\":[{\"name\":\"meters\",\"usage\":\"plain\",\"type\":\"int\"}]},{\"key\":\"friendJoined\",\"placeholders\":[{\"name\":\"gender\",\"usage\":\"select\",\"type\":\"String\"},{\"name\":\"name\",\"usage\":\"plain\",\"type\":\"String\"}],\"select_cases\":[\"female male other\"]},{\"key\":\"photosAdded\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]},{\"key\":\"saveTrail\",\"placeholders\":[]},{\"key\":\"offlineBanner\",\"placeholders\":[]},{\"key\":\"reviewPrompt\",\"placeholders\":[{\"name\":\"trailName\",\"usage\":\"plain\",\"type\":\"String\"}]},{\"key\":\"deleteConfirm\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]}],\"keys_to_translate\":12,\"keys_sent\":12,\"keys_total\":12,\"mode\":\"new\"}",
  "guidance": "Informal but polite: address the user with вы in lower case. Keep the app name Ridgemoss in Latin letters."
}

translate, fill: the four keys a German file is missing

The same template with an existing German file that has eight of the twelve keys: mode fill, those eight as style_examples, and only the four missing keys in arb. This is also the body the step-by-step code sends.

{
  "task": "translate",
  "source_locale": "en",
  "target_locale": "de",
  "use_escaping": "off",
  "arb": "{\"photosAdded\":\"{count, plural, =1{You added a photo} other{You added {count} photos}}\",\"@photosAdded\":{\"description\":\"Confirmation after the user uploads trail photos.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"3\"}}},\"offlineBanner\":\"You're offline. Maps you saved still work.\",\"@offlineBanner\":{\"description\":\"Banner shown when the phone has no connection.\"},\"reviewPrompt\":\"How was {trailName}?\",\"@reviewPrompt\":{\"description\":\"Question asking the user to rate a trail they just finished.\",\"placeholders\":{\"trailName\":{\"type\":\"String\",\"example\":\"Eagle Ridge Loop\"}}},\"deleteConfirm\":\"Delete {count, plural, =1{this hike} other{these {count} hikes}}?\",\"@deleteConfirm\":{\"description\":\"Title of the dialog that confirms deleting recorded hikes.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"2\"}}}}",
  "facts": "{\"checker\":\"ARB Desk (gen-l10n rules ported from flutter_tools; CLDR plural rules from the browser's Intl.PluralRules)\",\"source_locale\":\"en\",\"target_locale\":\"de\",\"target_language\":\"German\",\"plural_categories\":[\"one\",\"other\"],\"plural_examples\":{\"one\":[1],\"other\":[0,2,3,4,5,6]},\"exact_cases_that_are_not_exact\":{},\"keys\":[{\"key\":\"photosAdded\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]},{\"key\":\"offlineBanner\",\"placeholders\":[]},{\"key\":\"reviewPrompt\",\"placeholders\":[{\"name\":\"trailName\",\"usage\":\"plain\",\"type\":\"String\"}]},{\"key\":\"deleteConfirm\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]}],\"keys_to_translate\":4,\"keys_sent\":4,\"keys_total\":12,\"mode\":\"fill\",\"keys_already_translated\":8,\"style_examples\":[{\"key\":\"appTitle\",\"source\":\"Ridgemoss\",\"translation\":\"Ridgemoss\"},{\"key\":\"welcomeBack\",\"source\":\"Welcome back, {name}!\",\"translation\":\"Willkommen zurück, {name}!\"},{\"key\":\"trailCount\",\"source\":\"{count, plural, =0{No trails nearby} =1{1 trail nearby} other{{count} trails nearby}}\",\"translation\":\"{count, plural, =0{Keine Wege in der Nähe} one{{count} Weg in der Nähe} other{{count} Wege in der Nähe}}\"},{\"key\":\"distanceKm\",\"source\":\"{distance} km\",\"translation\":\"{distance} km\"},{\"key\":\"lastHike\",\"source\":\"Last hike: {date}\",\"translation\":\"Letzte Wanderung: {date}\"},{\"key\":\"elevationGain\",\"source\":\"Elevation gain: {meters} m\",\"translation\":\"Höhenmeter: {meters} m\"},{\"key\":\"friendJoined\",\"source\":\"{gender, select, female{{name} joined her first hike} male{{name} joined his first hike} other{{name} joined their first hike}}\",\"translation\":\"{gender, select, female{{name} hat ihre erste Wanderung gemacht} male{{name} hat seine erste Wanderung gemacht} other{{name} hat die erste Wanderung gemacht}}\"},{\"key\":\"saveTrail\",\"source\":\"Save trail\",\"translation\":\"Weg speichern\"}]}",
  "guidance": "Address the user with du. A trail is a Weg."
}

The reply, in full:

{
  "task": "translate",
  "headline": "Translated the 4 sent messages into natural German (fill mode), using du and Weg per the guidance, and rewrote both plurals from the template's =1/other pattern into German's one/other categories.",
  "notes": [],
  "suggestions": [],
  "arb": {
    "@@locale": "de",
    "photosAdded": "{count, plural, one{Du hast ein Foto hinzugefügt} other{Du hast {count} Fotos hinzugefügt}}",
    "offlineBanner": "Du bist offline. Gespeicherte Karten funktionieren weiterhin.",
    "reviewPrompt": "Wie war {trailName}?",
    "deleteConfirm": "{count, plural, one{Diese Wanderung} other{Diese {count} Wanderungen}} löschen?"
  }
}

Both plurals became German's one/other (no =1), and @@locale matches target_locale. To produce app_de.arb, merge these four keys into the existing file without replacing any key it already has, then check the result.

Base URL and envelope

Base URL: https://api.skillsafe.ai/v1/app-api. sdk.js builds every call as "https://api.skillsafe.ai" + "/v1/app-api/..." and sends:

There is no app or slug header: the token belongs to the app (the slug goes in only once, in the /guest body or the sign-in). Every JSON response is an envelope: {"data": ...} on success; on a non-2xx status {"error": {"code", "message", "details"}}, which sdk.js (apiError) turns into an error carrying the HTTP status, code and details, with the HTTP status text as the message when the body has none.

CallBodydataMetered?
POST /guest{"slug": "arb-desk"}{token, guest_id}no
GET /me—{subject_type, subject_id, credits, profile?}no
POST /estimatethe run bodyhold_credits, min_credits, model, model_alias, markup_bps (the page also reads sponsor_enabled)no
POST /runthe run body{job_id, ...}yes
GET /jobs/{job_id}—the job: status, output.output (the reply text), charged_credits, truncated, errorno
POST /run-streamthe run bodyServer-Sent Events (step 6), or a plain envelope on an idempotent replayyes

Errors

Branch on the HTTP status; show error.message; log error.code and error.details. This app documents no list of error.code values, so do not depend on specific codes. The statuses below are described by their usual HTTP meaning.

StatusMeaningWhat to do
400Bad request: the server could not accept the request as sent.Fix the request; retrying the same body will not help.
401The token is missing, expired or not valid.Get a fresh token (step 2) and retry. The page asks the user to sign in again.
402Not enough credits for the run.Top up, or compare /me credits with the estimate's min_credits before running, as the page does.
403Not allowed with this token, for example a guest token on a metered run.Use a personal token from /tokens.html.
404Not found, for example an unknown job_id.Check the path and the id.
409Conflict with the current state of the resource.Read the message; do not retry blindly.
429Rate limited.Wait, then retry with the same Idempotency-Key.
5xxA server-side failure.Retry later with the same Idempotency-Key; poll GET /jobs/{job_id} if you already have one.
SSE event: errorThe stream reports a failure; data {code, message, job_id} (the HTTP status was 200).Show message; read the job with GET /jobs/{job_id}.
job status: "failed"The run ended without a reply; details in error.A new attempt needs a new Idempotency-Key.

Idempotency-Key

Send an Idempotency-Key header on POST /run and POST /run-stream. The same key means the same run: repeating a request after a network error returns that run instead of starting and billing a second one. The page builds the key from the app, the lane, a hash of the exact input and the attempt number, arb-desk:<lane>:<hash>:a<attempt> (for example arb-desk:translate:<hash>:a1): its comment says a network blip never double-bills, the same file in two lanes is two runs, and the reformat retry is a distinct run (:a2). Do the same: one key per attempt, reused only to repeat that attempt. Any stable hash of the body works; the snippets use SHA-256. On an idempotent replay /run-stream answers with a plain {"data": ...} envelope instead of text/event-stream; the snippets in step 6 handle both.

Estimate and credits

Model

Runs use the model alias gpt-terra, which currently resolves to gpt-5.6-terra. An alias can move to another model, so do not hard-code the resolved name; read model and model_alias from /estimate.

Step by step

Each step builds on the previous ones: the helper from step 1 and the INPUT body from step 4. Replace YOUR_TOKEN with your token. In Go, Java and C# each step is a function or method to add next to the helper; the tabs switch every code block on the page at once.

1. A tiny client helper

One function that makes the same request sdk.js makes: JSON body, bearer token, optional Idempotency-Key, and the {data} / {error} envelope unwrapped. Java uses Jackson for JSON; the other languages use only their standard library (curl uses jq).

# bash + curl + jq
API="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # a personal token copied from https://arb-desk.skillsafe.ai/tokens.html

# ss METHOD PATH [extra curl args...]
# Sends the two headers every call carries and prints the raw envelope:
# {"data": ...} on success, {"error": {"code", "message", "details"}} on failure.
ss() {
  curl -sS -X "$1" "$API$2" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" \
    "${@:3}"
}

2. Get a token

Guest: POST /guest with {"slug": "arb-desk"} and no Authorization header, as sdk.js guest() does; the token is data.token. A guest token works for /me and /estimate. Personal: a metered run needs one. Sign in on the app, then copy the token from the tokens page and use it in place of YOUR_TOKEN. Keep it secret; removing it from the browser there does not revoke a copy you have pasted elsewhere.

# A guest token: no Authorization header, the app's slug in the body.
curl -sS -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"arb-desk"}' | jq -r '.data.token // .error'
# Enough for /me and /estimate, not for a metered run.
# For /run and /run-stream, set TOKEN to the personal token from /tokens.html.

3. Who am I: GET /me

Returns {subject_type, subject_id, credits, profile?}. The page compares credits with the estimate before it lets a run start.

ss GET /me | jq '.data // .error'

4. Price a run: POST /estimate

The body is the translate, fill example, embedded as it is. Because /estimate validates nothing, each snippet first checks the body's shape itself: a known task, and arb and facts as strings that decode to JSON objects.

# The "translate, fill" example body from this page, exactly as the web page builds it.
IFS= read -r -d '' BODY <<'JSON' || true
{
  "task": "translate",
  "source_locale": "en",
  "target_locale": "de",
  "use_escaping": "off",
  "arb": "{\"photosAdded\":\"{count, plural, =1{You added a photo} other{You added {count} photos}}\",\"@photosAdded\":{\"description\":\"Confirmation after the user uploads trail photos.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"3\"}}},\"offlineBanner\":\"You're offline. Maps you saved still work.\",\"@offlineBanner\":{\"description\":\"Banner shown when the phone has no connection.\"},\"reviewPrompt\":\"How was {trailName}?\",\"@reviewPrompt\":{\"description\":\"Question asking the user to rate a trail they just finished.\",\"placeholders\":{\"trailName\":{\"type\":\"String\",\"example\":\"Eagle Ridge Loop\"}}},\"deleteConfirm\":\"Delete {count, plural, =1{this hike} other{these {count} hikes}}?\",\"@deleteConfirm\":{\"description\":\"Title of the dialog that confirms deleting recorded hikes.\",\"placeholders\":{\"count\":{\"type\":\"int\",\"example\":\"2\"}}}}",
  "facts": "{\"checker\":\"ARB Desk (gen-l10n rules ported from flutter_tools; CLDR plural rules from the browser's Intl.PluralRules)\",\"source_locale\":\"en\",\"target_locale\":\"de\",\"target_language\":\"German\",\"plural_categories\":[\"one\",\"other\"],\"plural_examples\":{\"one\":[1],\"other\":[0,2,3,4,5,6]},\"exact_cases_that_are_not_exact\":{},\"keys\":[{\"key\":\"photosAdded\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]},{\"key\":\"offlineBanner\",\"placeholders\":[]},{\"key\":\"reviewPrompt\",\"placeholders\":[{\"name\":\"trailName\",\"usage\":\"plain\",\"type\":\"String\"}]},{\"key\":\"deleteConfirm\",\"placeholders\":[{\"name\":\"count\",\"usage\":\"plural\",\"type\":\"int\"}],\"template_plural_cases\":[\"=1 other\"]}],\"keys_to_translate\":4,\"keys_sent\":4,\"keys_total\":12,\"mode\":\"fill\",\"keys_already_translated\":8,\"style_examples\":[{\"key\":\"appTitle\",\"source\":\"Ridgemoss\",\"translation\":\"Ridgemoss\"},{\"key\":\"welcomeBack\",\"source\":\"Welcome back, {name}!\",\"translation\":\"Willkommen zurück, {name}!\"},{\"key\":\"trailCount\",\"source\":\"{count, plural, =0{No trails nearby} =1{1 trail nearby} other{{count} trails nearby}}\",\"translation\":\"{count, plural, =0{Keine Wege in der Nähe} one{{count} Weg in der Nähe} other{{count} Wege in der Nähe}}\"},{\"key\":\"distanceKm\",\"source\":\"{distance} km\",\"translation\":\"{distance} km\"},{\"key\":\"lastHike\",\"source\":\"Last hike: {date}\",\"translation\":\"Letzte Wanderung: {date}\"},{\"key\":\"elevationGain\",\"source\":\"Elevation gain: {meters} m\",\"translation\":\"Höhenmeter: {meters} m\"},{\"key\":\"friendJoined\",\"source\":\"{gender, select, female{{name} joined her first hike} male{{name} joined his first hike} other{{name} joined their first hike}}\",\"translation\":\"{gender, select, female{{name} hat ihre erste Wanderung gemacht} male{{name} hat seine erste Wanderung gemacht} other{{name} hat die erste Wanderung gemacht}}\"},{\"key\":\"saveTrail\",\"source\":\"Save trail\",\"translation\":\"Weg speichern\"}]}",
  "guidance": "Address the user with du. A trail is a Weg."
}
JSON

# /estimate validates nothing, so check the shape yourself first (prints true or false).
printf '%s' "$BODY" | jq -e '
  (.task == "annotate" or .task == "translate")
  and (.arb | type == "string") and (.arb | fromjson | type == "object")
  and (.facts | type == "string") and (.facts | fromjson | type == "object")'

ss POST /estimate --data-binary "$BODY" \
  | jq '.data // .error | if has("hold_credits") then {hold_credits, min_credits, model, model_alias, markup_bps} else . end'

5. Run it: POST /run, then poll GET /jobs/{job_id}

POST /run with an Idempotency-Key returns data.job_id. Poll GET /jobs/{job_id} until status is succeeded or failed (sdk.js waitForJob polls every second and gives up after 180 seconds; the job itself may still finish). The reply text is at output.output: take the first JSON object out of it, as recon.js does, parse arb if it is a string, then run your own re-checks before you use it. charged_credits is what the run cost.

# One key per attempt: app, lane, a hash of the exact body, attempt number.
KEY="arb-desk:translate:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB_ID=$(ss POST /run -H "Idempotency-Key: $KEY" --data-binary "$BODY" | jq -r '.data.job_id')

for _ in $(seq 180); do   # poll once a second, for up to 3 minutes
  JOB=$(ss GET "/jobs/$JOB_ID")
  STATUS=$(printf '%s' "$JOB" | jq -r '.data.status')
  case "$STATUS" in succeeded|failed|null) break ;; esac   # null: the call itself failed
  sleep 1
done

printf '%s' "$JOB" | jq '.data | {status, charged_credits, error}'
# The reply text is .data.output.output. Take the JSON object out of it (first "{" to last "}";
# the page uses a balanced scan) and decode arb if it arrived as a string.
printf '%s' "$JOB" | jq '.data.output.output
  | capture("(?<j>\\{[\\s\\S]*\\})").j | fromjson
  | .arb |= (if type == "string" then fromjson else . end)'

6. Stream it: POST /run-stream

The response is text/event-stream: frames of an event: <name> line and a data: <json> line (several data: lines are joined), separated by a blank line. The events are:

eventdata
delta{text}: the next piece of the reply.
jobThe job, once it has started.
done{job_id, status, charged_credits, output}: the final result; the reply text is output.output. (sdk.js treats a pending event the same way.)
error{code, message, job_id}: the run failed.

If the response is not text/event-stream, it is a plain JSON envelope: an idempotent replay of a run with the same key ({"data": ...}, read like done) or an error. A browser may receive only tick or heartbeat events and one done, with no deltas (the page advances its progress steps on elapsed time for that reason), so always read the final text from done.output.output, never from the concatenated deltas.

# -N turns off buffering. Frames are "event: <name>" + "data: <json>", then a blank line.
# Drop everything after the first line to watch the raw frames instead.
ss POST /run-stream -N -H "Idempotency-Key: $KEY" --data-binary "$BODY" \
  | awk '/^event:/ { ev = $0; sub(/^event: */, "", ev) }
         /^data:/ && ev == "done" { sub(/^data: */, ""); print; exit }' \
  | jq -r '.output.output'
# An idempotent replay answers with a plain {"data": ...} envelope instead of frames:
# then read .data.output.output, as in step 5.