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 · Input fields · The facts string
- The reply · What to re-check · Worked examples
- Base URL and envelope · Errors · Idempotency-Key · Estimate and credits
- Step by step: helper, token, /me, /estimate, /run, /run-stream
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.
| task | What it does | Send | Reply 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 context | task, 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 guidance | task, 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"
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "annotate" |
template_locale | string | yes | The template's locale as the checker reads it (from @@locale or the file name), for example "en". |
arb | string (JSON) | yes | A 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. |
facts | string (JSON) | yes | A JSON-encoded object with the checker's facts; see the facts string. |
context | string | no | What 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_note | string | no | Not 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"
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "translate" |
source_locale | string | yes | The template's locale, for example "en". |
target_locale | string | yes | The locale to translate into, in the page's normal form (de, pt_BR, zh_Hant). It must differ from the template's locale. |
use_escaping | string | yes | "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. |
arb | string (JSON) | yes | A 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. |
facts | string (JSON) | yes | A JSON-encoded object with the checker's facts; see the facts string. |
guidance | string | no | Glossary, formality (du/Sie, tu/vous) and tone. At most 2,000 characters, cut like context. |
retry_note | string | no | As 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"
| Key | Value |
|---|---|
checker | A string naming the checker and its rule sets. |
template_locale | The template's locale. |
keys | One 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_work | How many keys need metadata in the whole template. |
keys_sent | How many of them this run sends. |
keys_total | How many messages the template has. |
task "translate"
| Key | Value |
|---|---|
checker | A string naming the checker and its rule sets. |
source_locale, target_locale | As in the input. |
target_language | The target language's English name, for example "German". |
plural_categories | The target locale's CLDR plural categories, for example ["one", "few", "many", "other"]. |
plural_examples | Per 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_exact | Per 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. |
keys | One entry per sent key: key; placeholders as {name, usage, type}; template_plural_cases and select_cases when the message has them. |
keys_to_translate | How many template keys still need a translation. |
keys_sent | How many this run sends. |
keys_total | How many messages the template has. |
mode | "new" (no existing translation) or "fill". |
keys_already_translated | Fill mode only: how many template keys the existing translation already has. |
style_examples | Fill 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": { ... }
}
| Key | Meaning |
|---|---|
task | The lane the model followed. |
headline | One or two sentences, in English. |
notes | At 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. |
suggestions | annotate 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. |
arb | annotate: 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
- How many sent keys came back with an
@keyobject; the rest keep their old metadata. - A changed message value is ignored: the page keeps your original, because this lane never edits a message.
- A placeholder
typethat differs from the type gen-l10n infers is flagged: it changes the generated method's parameter. - A placeholder you had declared and the reply dropped is flagged: it removes a parameter.
- The merged template is checked again; remaining metadata findings on a sent key (undeclared placeholder,
missing description, wrong plural or select type, a
DateTimewithout or with a badformat, a bad number format, invalid types) are flagged, and the counts before and after are compared. - Every suggestion must name a key in the file, parse under gen-l10n (with your use-escaping setting) and use
only placeholders the message already has. When a suggestion turns a placeholder into a plural or a select, the
page notes that its declared type must change too (
intornumfor a plural,Stringfor a select,DateTimefor a date). - Keys the reply returned but were not sent are ignored, as are notes naming them.
translate
- How many sent keys came back as strings; missing ones stay untranslated for the next run.
@@localeis always written as the target locale, whatever the reply says.- The merged translation is checked against the template with the gen-l10n rules (syntax, placeholders, CLDR plural categories, select cases, escaping); any finding above info level is flagged on its key, with an overall count of errors, warnings and keys still untranslated.
@keyobjects in the reply are left out; a key your existing translation already has is never replaced; keys that were not sent are ignored.
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:
Content-Type: application/jsonon every call, with a JSON body where there is one;Authorization: Bearer <token>whenever it holds a token (every call except the firstPOST /guest);Idempotency-Key: <key>onPOST /runandPOST /run-streamwhen a key is given.
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.
| Call | Body | data | Metered? |
|---|---|---|---|
POST /guest | {"slug": "arb-desk"} | {token, guest_id} | no |
GET /me | — | {subject_type, subject_id, credits, profile?} | no |
POST /estimate | the run body | hold_credits, min_credits, model, model_alias, markup_bps (the page also reads sponsor_enabled) | no |
POST /run | the run body | {job_id, ...} | yes |
GET /jobs/{job_id} | — | the job: status, output.output (the reply text), charged_credits, truncated, error | no |
POST /run-stream | the run body | Server-Sent Events (step 6), or a plain envelope on an idempotent replay | yes |
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.
| Status | Meaning | What to do |
|---|---|---|
| 400 | Bad request: the server could not accept the request as sent. | Fix the request; retrying the same body will not help. |
| 401 | The token is missing, expired or not valid. | Get a fresh token (step 2) and retry. The page asks the user to sign in again. |
| 402 | Not enough credits for the run. | Top up, or compare /me credits with the estimate's min_credits before running, as the page does. |
| 403 | Not allowed with this token, for example a guest token on a metered run. | Use a personal token from /tokens.html. |
| 404 | Not found, for example an unknown job_id. | Check the path and the id. |
| 409 | Conflict with the current state of the resource. | Read the message; do not retry blindly. |
| 429 | Rate limited. | Wait, then retry with the same Idempotency-Key. |
| 5xx | A server-side failure. | Retry later with the same Idempotency-Key; poll GET /jobs/{job_id} if you already have one. |
SSE event: error | The 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
POST /estimateis free (sdk.js: "no charge, no job"). It takes the same body as/run, and a guest token is enough. It returnshold_credits,min_credits,model,model_aliasandmarkup_bps.hold_creditsis reserved, not the price. The actual cost is the job'scharged_creditsafter the run settles; the page tells users they are charged only for what the run uses, usually far less. The page will not start a run belowmin_creditsand warns that a balance under the hold may cut the reply short (truncated: true). It shows credits at 10,000 to the US dollar./estimateperforms no body validation. A wrong body (a missingtask,factssent as an object, an empty object) still gets a valid-looking estimate. Validate the body yourself: thetask, the lane's required fields, and thatarbandfactsare strings holding JSON objects (the step-4 snippets do this)./runand/run-streamare metered and need a personal (signed-in) token. A guest token can call/meand/estimateonly.
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}"
}
# arbdesk.py - Python 3.8+, standard library only
import hashlib
import json
import time
import urllib.error
import urllib.parse
import urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__("%s %s: %s" % (status, code, message))
self.status, self.code, self.details = status, code, details
def api_error(e):
"""Turn an HTTPError into ApiError from the {"error": {code, message, details}} envelope."""
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
return ApiError(e.code, err.get("code"), err.get("message") or e.reason, err.get("details"))
def request(method, path, body=None, token=None, idempotency_key=None):
token = TOKEN if token is None else token
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
data = None if body is None else json.dumps(body).encode("utf-8")
return urllib.request.Request(API + path, data=data, method=method, headers=headers)
def call(method, path, body=None, token=None, idempotency_key=None):
"""One request; returns the envelope's `data`, raises ApiError on a non-2xx status."""
try:
with urllib.request.urlopen(request(method, path, body, token, idempotency_key)) as res:
return json.load(res).get("data")
except urllib.error.HTTPError as e:
raise api_error(e) from None
// arbdesk.mjs - Node 20+ or a browser (fetch, crypto.subtle)
export const API = "https://api.skillsafe.ai/v1/app-api";
export let TOKEN = "YOUR_TOKEN"; // a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
export function headers(token = TOKEN, idempotencyKey) {
const h = { "Content-Type": "application/json" };
if (token) h["Authorization"] = "Bearer " + token;
if (idempotencyKey) h["Idempotency-Key"] = idempotencyKey;
return h;
}
export function apiError(res, json) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
return err;
}
// One request; resolves with the envelope's data, throws on a non-2xx status.
export async function call(method, path, body, { token = TOKEN, idempotencyKey } = {}) {
const res = await fetch(API + path, {
method,
headers: headers(token, idempotencyKey),
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw apiError(res, json);
return json.data;
}
// arbdesk.go - Go 1.18+, standard library only. Steps 2-6 are functions to add to this file.
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const api = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
func newRequest(method, path string, body any, tok, idemKey string) (*http.Request, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, api+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
return req, nil
}
// decodeEnvelope reads {"data": ...} into out, or returns the {"error": ...} as *APIError.
func decodeEnvelope(res *http.Response, out any) error {
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return e
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
// call sends one request and decodes the envelope's data into out (may be nil).
func call(method, path string, body any, tok, idemKey string, out any) error {
req, err := newRequest(method, path, body, tok, idemKey)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
return decodeEnvelope(res, out)
}
// ArbDesk.java - Java 17+, java.net.http plus Jackson (com.fasterxml.jackson.core:jackson-databind).
// Steps 2-6 are static methods to add to this class.
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class ArbDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static String token = "YOUR_TOKEN"; // a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
static class ApiException extends RuntimeException {
final int status;
final String code;
final JsonNode details;
ApiException(int status, String code, String message, JsonNode details) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
this.details = details;
}
}
static HttpRequest request(String method, String path, Object body, String tok, String idempotencyKey)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json");
if (tok != null && !tok.isEmpty()) b.header("Authorization", "Bearer " + tok);
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body));
return b.method(method, pub).build();
}
/** {"data": ...} gives data; a non-2xx status throws the {"error": ...} as ApiException. */
static JsonNode unwrap(int status, String text) {
JsonNode env;
try {
env = JSON.readTree(text == null || text.isEmpty() ? "{}" : text);
} catch (Exception e) {
env = JSON.createObjectNode();
}
if (status / 100 != 2) {
JsonNode err = env.path("error");
throw new ApiException(status, err.path("code").asText(null),
err.path("message").asText("HTTP " + status), err.get("details"));
}
return env.path("data");
}
/** One request; returns the envelope's data. */
static JsonNode call(String method, String path, Object body, String tok, String idempotencyKey)
throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, tok, idempotencyKey),
HttpResponse.BodyHandlers.ofString());
return unwrap(res.statusCode(), res.body());
}
}
# arbdesk.rb - Ruby 2.7+, standard library only
require "digest"
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
$token = "YOUR_TOKEN" # a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :status, :code, :details
def initialize(status, code, message, details)
super("#{status} #{code}: #{message}")
@status = status
@code = code
@details = details
end
end
def build_request(method, path, body: nil, idempotency_key: nil, token: $token)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri) # Net::HTTP::Get, ::Post
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
req["Idempotency-Key"] = idempotency_key if idempotency_key
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
# {"data": ...} gives data; a non-2xx status raises the {"error": ...} as ApiError.
def unwrap(res, raw)
env = begin
JSON.parse(raw.to_s)
rescue JSON::ParserError
{}
end
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message, err["details"])
end
env["data"]
end
# One request; returns the envelope's "data".
def call(method, path, body: nil, idempotency_key: nil, token: $token)
uri, req = build_request(method, path, body: body, idempotency_key: idempotency_key, token: token)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
unwrap(res, res.body)
end
<?php
// arbdesk.php - PHP 7.4+ with ext-curl and ext-json
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
class ApiException extends RuntimeException {
public $status; public $apiCode; public $details;
public function __construct(int $status, ?string $code, string $message, $details = null) {
parent::__construct("$status $code: $message");
$this->status = $status; $this->apiCode = $code; $this->details = $details;
}
}
function headers(?string $token, ?string $idempotencyKey): array {
$h = ["Content-Type: application/json"];
if ($token !== null && $token !== "") $h[] = "Authorization: Bearer " . $token;
if ($idempotencyKey !== null) $h[] = "Idempotency-Key: " . $idempotencyKey;
return $h;
}
/** {"data": ...} gives data; a non-2xx status throws the {"error": ...} as ApiException. */
function unwrap(int $status, string $raw) {
$env = json_decode($raw, true);
if (!is_array($env)) $env = [];
if ($status < 200 || $status > 299) {
$err = $env["error"] ?? [];
throw new ApiException($status, $err["code"] ?? null, $err["message"] ?? "HTTP $status", $err["details"] ?? null);
}
return $env["data"] ?? null;
}
/** One request; returns the envelope's data. Pass $token = "" to send no Authorization header. */
function call(string $method, string $path, $body = null, ?string $idempotencyKey = null, ?string $token = null) {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers($token ?? $TOKEN, $idempotencyKey),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return unwrap($status, $raw);
}
// ArbDesk.cs - .NET 7+ (C# 11), HttpClient + System.Text.Json. Steps 2-6 are members to add to this class.
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public static partial class ArbDesk
{
public const string Api = "https://api.skillsafe.ai/v1/app-api";
public static readonly HttpClient Http = new HttpClient();
public static string Token = "YOUR_TOKEN"; // a personal token copied from https://arb-desk.skillsafe.ai/tokens.html
public class ApiException : Exception
{
public int Status { get; }
public string? Code { get; }
public JsonNode? Details { get; }
public ApiException(int status, string? code, string message, JsonNode? details)
: base($"{status} {code}: {message}")
{
Status = status; Code = code; Details = details;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null,
string? idempotencyKey = null, string? token = null)
{
token ??= Token;
var req = new HttpRequestMessage(new HttpMethod(method), Api + path);
if (!string.IsNullOrEmpty(token))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (idempotencyKey != null) req.Headers.Add("Idempotency-Key", idempotencyKey);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
return req;
}
/// <summary>{"data": ...} gives data; a non-2xx status throws the {"error": ...} as ApiException.</summary>
public static JsonNode? Unwrap(int status, string text)
{
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (status < 200 || status > 299)
{
var err = env?["error"];
throw new ApiException(status, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? "HTTP " + status, err?["details"]);
}
return env?["data"];
}
/// <summary>One request; returns the envelope's data.</summary>
public static async Task<JsonNode?> Call(string method, string path, object? body = null,
string? idempotencyKey = null, string? token = null)
{
using var req = Request(method, path, body, idempotencyKey, token);
using var res = await Http.SendAsync(req);
return Unwrap((int)res.StatusCode, await res.Content.ReadAsStringAsync());
}
}
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.
# A guest token: no Authorization header (token=""), the app's slug in the body.
guest = call("POST", "/guest", {"slug": "arb-desk"}, token="")
print(guest["token"]) # enough for /me and /estimate, not for a metered run
# For /run and /run-stream, set TOKEN to the personal token from /tokens.html.
// A guest token: no Authorization header (token ""), the app's slug in the body.
const guest = await call("POST", "/guest", { slug: "arb-desk" }, { token: "" });
console.log(guest.token); // enough for /me and /estimate, not for a metered run
// For /run and /run-stream, set TOKEN to the personal token from /tokens.html.
// A guest token: no Authorization header (tok ""), the app's slug in the body.
func guestToken() (string, error) {
var g struct {
Token string `json:"token"`
}
err := call("POST", "/guest", map[string]string{"slug": "arb-desk"}, "", "", &g)
return g.Token, err // enough for /me and /estimate, not for a metered run
}
// For /run and /run-stream, set token to the personal token from /tokens.html.
// A guest token: no Authorization header (tok ""), the app's slug in the body.
static String guestToken() throws Exception {
JsonNode guest = call("POST", "/guest", Map.of("slug", "arb-desk"), "", null);
return guest.path("token").asText(); // enough for /me and /estimate, not for a metered run
}
// For /run and /run-stream, set token to the personal token from /tokens.html.
# A guest token: no Authorization header (token: ""), the app's slug in the body.
guest = call("POST", "/guest", body: { slug: "arb-desk" }, token: "")
puts guest["token"] # enough for /me and /estimate, not for a metered run
# For /run and /run-stream, set $token to the personal token from /tokens.html.
// A guest token: no Authorization header ($token ""), the app's slug in the body.
$guest = call("POST", "/guest", ["slug" => "arb-desk"], null, "");
echo $guest["token"], "\n"; // enough for /me and /estimate, not for a metered run
// For /run and /run-stream, set $TOKEN to the personal token from /tokens.html.
// A guest token: no Authorization header (token ""), the app's slug in the body.
public static async Task<string> GuestToken()
{
var guest = await Call("POST", "/guest", new { slug = "arb-desk" }, token: "");
return guest!["token"]!.GetValue<string>(); // 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'
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
func whoAmI() error {
var me map[string]any
if err := call("GET", "/me", nil, token, "", &me); err != nil {
return err
}
fmt.Println(me["subject_type"], me["credits"])
return nil
}
static void whoAmI() throws Exception {
JsonNode me = call("GET", "/me", null, token, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits"));
}
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = call("GET", "/me");
echo $me["subject_type"], " ", json_encode($me["credits"] ?? null), "\n";
public static async Task WhoAmI()
{
var me = await Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["credits"]}");
}
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'
# The "translate, fill" example body from this page, exactly as the web page builds it.
INPUT = json.loads(r'''
{
"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."
}
''')
def check_input(body):
"""/estimate validates nothing, so check the shape yourself before pricing or running."""
if body.get("task") not in ("annotate", "translate"):
raise ValueError('task must be "annotate" or "translate"')
for field in ("arb", "facts"):
if not isinstance(body.get(field), str) or not isinstance(json.loads(body[field]), dict):
raise ValueError(field + " must be a JSON-encoded object (a string)")
return body
est = call("POST", "/estimate", check_input(INPUT))
print(est["hold_credits"], est.get("min_credits"), est.get("model"), est.get("model_alias"), est.get("markup_bps"))
// The "translate, fill" example body from this page, exactly as the web page builds it.
const INPUT = {
"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."
};
// /estimate validates nothing, so check the shape yourself before pricing or running.
function checkInput(body) {
if (!["annotate", "translate"].includes(body.task)) throw new Error('task must be "annotate" or "translate"');
for (const field of ["arb", "facts"]) {
const v = typeof body[field] === "string" ? JSON.parse(body[field]) : null;
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(field + " must be a JSON-encoded object (a string)");
}
return body;
}
const est = await call("POST", "/estimate", checkInput(INPUT));
console.log(est.hold_credits, est.min_credits, est.model, est.model_alias, est.markup_bps);
// The "translate, fill" example body from this page, exactly as the web page builds it.
const inputJSON = `
{
"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."
}
`
func loadInput() (map[string]any, error) {
var in map[string]any
if err := json.Unmarshal([]byte(inputJSON), &in); err != nil {
return nil, err
}
return in, checkInput(in)
}
// checkInput: /estimate validates nothing, so check the shape yourself before pricing or running.
func checkInput(in map[string]any) error {
if t, _ := in["task"].(string); t != "annotate" && t != "translate" {
return errors.New(`task must be "annotate" or "translate"`)
}
for _, f := range []string{"arb", "facts"} {
s, ok := in[f].(string)
var obj map[string]any
if !ok || json.Unmarshal([]byte(s), &obj) != nil || obj == nil {
return fmt.Errorf("%s must be a JSON-encoded object (a string)", f)
}
}
return nil
}
func estimate(in map[string]any) error {
var est map[string]any
if err := call("POST", "/estimate", in, token, "", &est); err != nil {
return err
}
fmt.Println(est["hold_credits"], est["min_credits"], est["model"], est["model_alias"], est["markup_bps"])
return nil
}
// The "translate, fill" example body from this page, exactly as the web page builds it.
// (In a text block a backslash is an escape, so every JSON backslash is doubled here.)
static final String INPUT_JSON = """
{
"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."
}
""";
static JsonNode input() throws Exception {
return checkInput(JSON.readTree(INPUT_JSON));
}
/** /estimate validates nothing, so check the shape yourself before pricing or running. */
static JsonNode checkInput(JsonNode body) throws Exception {
String task = body.path("task").asText();
if (!task.equals("annotate") && !task.equals("translate"))
throw new IllegalArgumentException("task must be \"annotate\" or \"translate\"");
for (String field : List.of("arb", "facts")) {
JsonNode v = body.path(field);
if (!v.isTextual() || !JSON.readTree(v.asText()).isObject())
throw new IllegalArgumentException(field + " must be a JSON-encoded object (a string)");
}
return body;
}
static void estimate() throws Exception {
JsonNode est = call("POST", "/estimate", input(), token, null);
for (String k : List.of("hold_credits", "min_credits", "model", "model_alias", "markup_bps"))
System.out.println(k + " = " + est.path(k));
}
# The "translate, fill" example body from this page, exactly as the web page builds it.
INPUT = JSON.parse(<<~'JSON')
{
"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 before pricing or running.
def check_input(body)
raise ArgumentError, 'task must be "annotate" or "translate"' unless %w[annotate translate].include?(body["task"])
%w[arb facts].each do |field|
ok = body[field].is_a?(String) && JSON.parse(body[field]).is_a?(Hash)
raise ArgumentError, "#{field} must be a JSON-encoded object (a string)" unless ok
end
body
end
est = call("POST", "/estimate", body: check_input(INPUT))
p est.slice("hold_credits", "min_credits", "model", "model_alias", "markup_bps")
// The "translate, fill" example body from this page, exactly as the web page builds it.
$INPUT = json_decode(<<<'JSON'
{
"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, true, 512, JSON_THROW_ON_ERROR);
/** /estimate validates nothing, so check the shape yourself before pricing or running. */
function check_input(array $body): array {
if (!in_array($body["task"] ?? null, ["annotate", "translate"], true))
throw new InvalidArgumentException('task must be "annotate" or "translate"');
foreach (["arb", "facts"] as $field) {
$v = is_string($body[$field] ?? null) ? json_decode($body[$field]) : null;
if (!is_object($v)) throw new InvalidArgumentException("$field must be a JSON-encoded object (a string)");
}
return $body;
}
$est = call("POST", "/estimate", check_input($INPUT));
foreach (["hold_credits", "min_credits", "model", "model_alias", "markup_bps"] as $k)
echo $k, " = ", json_encode($est[$k] ?? null), "\n";
// The "translate, fill" example body from this page, exactly as the web page builds it.
const string InputJson = """
{
"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."
}
""";
public static JsonNode Input() => CheckInput(JsonNode.Parse(InputJson)!);
// /estimate validates nothing, so check the shape yourself before pricing or running.
public static JsonNode CheckInput(JsonNode body)
{
var task = body["task"]?.GetValue<string>();
if (task != "annotate" && task != "translate")
throw new ArgumentException("task must be \"annotate\" or \"translate\"");
foreach (var field in new[] { "arb", "facts" })
{
var ok = body[field] is JsonValue v && v.TryGetValue<string>(out var s) && JsonNode.Parse(s) is JsonObject;
if (!ok) throw new ArgumentException($"{field} must be a JSON-encoded object (a string)");
}
return body;
}
public static async Task Estimate()
{
var est = await Call("POST", "/estimate", Input());
foreach (var k in new[] { "hold_credits", "min_credits", "model", "model_alias", "markup_bps" })
Console.WriteLine($"{k} = {est?[k]?.ToJsonString()}");
}
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)'
def idempotency_key(body, attempt=1):
"""One key per attempt: app, lane, a hash of the exact body, attempt number."""
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode("utf-8")).hexdigest()[:16]
return "arb-desk:%s:%s:a%d" % (body["task"], digest, attempt)
def reply_object(text):
"""The first balanced JSON object in the reply (anything around it, such as a code fence, is skipped)."""
start = text.find("{")
if start < 0:
raise ValueError("no JSON object in the reply")
obj, _ = json.JSONDecoder().raw_decode(text, start)
if isinstance(obj.get("arb"), str): # arb may arrive as a JSON string
obj["arb"] = json.loads(obj["arb"])
return obj
job_id = call("POST", "/run", INPUT, idempotency_key=idempotency_key(INPUT))["job_id"]
deadline = time.time() + 180
while True:
job = call("GET", "/jobs/" + urllib.parse.quote(job_id))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job %s is still running; poll it again later" % job_id)
time.sleep(1)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = reply_object(job["output"]["output"])
print("charged", job.get("charged_credits"), "credits")
print(reply["headline"])
print(json.dumps(reply["arb"], ensure_ascii=False, indent=2))
// One key per attempt: app, lane, a hash of the exact body, attempt number.
async function idempotencyKey(body, attempt = 1) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(body)));
const hex = Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
return `arb-desk:${body.task}:${hex}:a${attempt}`;
}
// The first balanced JSON object in the reply (a code fence around it is skipped), as recon.js reads it.
function replyObject(text) {
const start = text.indexOf("{");
if (start === -1) throw new Error("no JSON object in the reply");
let depth = 0, inStr = false, esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true;
else if (c === "{") depth++;
else if (c === "}" && --depth === 0) {
const obj = JSON.parse(text.slice(start, i + 1));
if (typeof obj.arb === "string") obj.arb = JSON.parse(obj.arb); // arb may arrive as a JSON string
return obj;
}
}
throw new Error("the JSON object in the reply is not closed");
}
const { job_id } = await call("POST", "/run", INPUT, { idempotencyKey: await idempotencyKey(INPUT) });
const deadline = Date.now() + 180000;
let job;
for (;;) {
job = await call("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error(`job ${job_id} is still running; poll it again later`);
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = replyObject(job.output.output);
console.log("charged", job.charged_credits, "credits");
console.log(reply.headline);
console.log(JSON.stringify(reply.arb, null, 2));
// idempotencyKey: one key per attempt: app, lane, a hash of the exact body, attempt number.
func idempotencyKey(in map[string]any, attempt int) string {
b, _ := json.Marshal(in) // encoding/json writes map keys sorted
sum := sha256.Sum256(b)
return fmt.Sprintf("arb-desk:%v:%x:a%d", in["task"], sum[:8], attempt)
}
// replyObject decodes the first JSON object in the reply (a code fence before it is skipped).
func replyObject(text string) (map[string]any, error) {
start := strings.Index(text, "{")
if start < 0 {
return nil, errors.New("no JSON object in the reply")
}
var obj map[string]any
if err := json.NewDecoder(strings.NewReader(text[start:])).Decode(&obj); err != nil {
return nil, err
}
if s, ok := obj["arb"].(string); ok { // arb may arrive as a JSON string
var arb map[string]any
if err := json.Unmarshal([]byte(s), &arb); err != nil {
return nil, err
}
obj["arb"] = arb
}
return obj, nil
}
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
ChargedCredits *float64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Error json.RawMessage `json:"error"`
}
func runAndWait(in map[string]any) error {
var started Job
if err := call("POST", "/run", in, token, idempotencyKey(in, 1), &started); err != nil {
return err
}
deadline := time.Now().Add(180 * time.Second)
var job Job
for {
job = Job{}
if err := call("GET", "/jobs/"+url.PathEscape(started.JobID), nil, token, "", &job); err != nil {
return err
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("job %s is still running; poll it again later", started.JobID)
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
return fmt.Errorf("job failed: %s", job.Error)
}
reply, err := replyObject(job.Output.Output)
if err != nil {
return err
}
if job.ChargedCredits != nil {
fmt.Println("charged", *job.ChargedCredits, "credits")
}
fmt.Println(reply["headline"])
arb, _ := json.MarshalIndent(reply["arb"], "", " ")
fmt.Println(string(arb))
return nil
}
/** One key per attempt: app, lane, a hash of the exact body, attempt number. */
static String idempotencyKey(JsonNode body, int attempt) throws Exception {
byte[] sum = MessageDigest.getInstance("SHA-256")
.digest(JSON.writeValueAsString(body).getBytes(StandardCharsets.UTF_8));
return "arb-desk:" + body.path("task").asText() + ":" + HexFormat.of().formatHex(sum, 0, 8) + ":a" + attempt;
}
/** The first JSON object in the reply (a code fence before it is skipped). */
static ObjectNode replyObject(String text) throws Exception {
int start = text.indexOf('{');
if (start < 0) throw new IllegalStateException("no JSON object in the reply");
try (JsonParser p = JSON.getFactory().createParser(text.substring(start))) {
JsonNode node = JSON.readTree(p); // reads one value, ignores what follows
if (node == null || !node.isObject()) throw new IllegalStateException("the reply is not a JSON object");
ObjectNode obj = (ObjectNode) node;
JsonNode arb = obj.get("arb");
if (arb != null && arb.isTextual()) obj.set("arb", JSON.readTree(arb.asText())); // arb may arrive as a JSON string
return obj;
}
}
static void runAndWait() throws Exception {
JsonNode body = input();
String jobId = call("POST", "/run", body, token, idempotencyKey(body, 1)).path("job_id").asText();
long deadline = System.currentTimeMillis() + 180_000;
JsonNode job;
while (true) {
job = call("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, token, null);
String status = job.path("status").asText();
if (status.equals("succeeded") || status.equals("failed")) break;
if (System.currentTimeMillis() > deadline)
throw new IllegalStateException("job " + jobId + " is still running; poll it again later");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new IllegalStateException("job failed: " + job.path("error"));
ObjectNode reply = replyObject(job.path("output").path("output").asText());
System.out.println("charged " + job.path("charged_credits") + " credits");
System.out.println(reply.path("headline").asText());
System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(reply.get("arb")));
}
# One key per attempt: app, lane, a hash of the exact body, attempt number.
def idempotency_key(body, attempt = 1)
"arb-desk:#{body["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(body))[0, 16]}:a#{attempt}"
end
# The first balanced JSON object in the reply (a code fence around it is skipped), as recon.js reads it.
def reply_object(text)
start = text.index("{") or raise "no JSON object in the reply"
depth = 0
in_str = false
esc = false
(start...text.length).each do |i|
c = text[i]
if in_str
if esc then esc = false
elsif c == "\\" then esc = true
elsif c == '"' then in_str = false
end
next
end
case c
when '"' then in_str = true
when "{" then depth += 1
when "}"
depth -= 1
next unless depth.zero?
obj = JSON.parse(text[start..i])
obj["arb"] = JSON.parse(obj["arb"]) if obj["arb"].is_a?(String) # arb may arrive as a JSON string
return obj
end
end
raise "the JSON object in the reply is not closed"
end
job_id = call("POST", "/run", body: INPUT, idempotency_key: idempotency_key(INPUT))["job_id"]
deadline = Time.now + 180
job = nil
loop do
job = call("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job #{job_id} is still running; poll it again later" if Time.now > deadline
sleep 1
end
raise "job failed: #{job["error"]}" if job["status"] == "failed"
reply = reply_object(job["output"]["output"])
puts "charged #{job["charged_credits"]} credits"
puts reply["headline"]
puts JSON.pretty_generate(reply["arb"])
/** One key per attempt: app, lane, a hash of the exact body, attempt number. */
function idempotency_key(array $body, int $attempt = 1): string {
return "arb-desk:" . $body["task"] . ":" . substr(hash("sha256", json_encode($body)), 0, 16) . ":a" . $attempt;
}
/** The first balanced JSON object in the reply (a code fence around it is skipped), as recon.js reads it. */
function reply_object(string $text): array {
$start = strpos($text, "{");
if ($start === false) throw new RuntimeException("no JSON object in the reply");
$depth = 0; $inStr = false; $esc = false;
for ($i = $start, $n = strlen($text); $i < $n; $i++) {
$c = $text[$i];
if ($inStr) {
if ($esc) $esc = false; elseif ($c === "\\") $esc = true; elseif ($c === '"') $inStr = false;
continue;
}
if ($c === '"') $inStr = true;
elseif ($c === "{") $depth++;
elseif ($c === "}" && --$depth === 0) {
$obj = json_decode(substr($text, $start, $i - $start + 1), true, 512, JSON_THROW_ON_ERROR);
if (is_string($obj["arb"] ?? null)) $obj["arb"] = json_decode($obj["arb"], true); // arb may arrive as a JSON string
return $obj;
}
}
throw new RuntimeException("the JSON object in the reply is not closed");
}
$jobId = call("POST", "/run", $INPUT, idempotency_key($INPUT))["job_id"];
$deadline = time() + 180;
while (true) {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job $jobId is still running; poll it again later");
sleep(1);
}
if ($job["status"] === "failed") throw new RuntimeException("job failed: " . json_encode($job["error"] ?? null));
$reply = reply_object($job["output"]["output"]);
echo "charged ", json_encode($job["charged_credits"] ?? null), " credits\n";
echo $reply["headline"], "\n";
echo json_encode($reply["arb"], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";
// One key per attempt: app, lane, a hash of the exact body, attempt number.
public static string IdempotencyKey(JsonNode body, int attempt = 1)
{
var hex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body.ToJsonString())));
return $"arb-desk:{body["task"]}:{hex[..16].ToLowerInvariant()}:a{attempt}";
}
// The first JSON object in the reply (a code fence before it is skipped).
public static JsonObject ReplyObject(string text)
{
int start = text.IndexOf('{');
if (start < 0) throw new FormatException("no JSON object in the reply");
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(text.Substring(start)));
var obj = JsonNode.Parse(ref reader)!.AsObject(); // reads one value, ignores what follows
if (obj["arb"] is JsonValue v && v.TryGetValue<string>(out var s))
obj["arb"] = JsonNode.Parse(s); // arb may arrive as a JSON string
return obj;
}
public static async Task RunAndWait()
{
var body = Input();
var jobId = (await Call("POST", "/run", body, IdempotencyKey(body)))!["job_id"]!.GetValue<string>();
var deadline = DateTime.UtcNow.AddSeconds(180);
JsonNode job;
while (true)
{
job = (await Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var status = job["status"]?.GetValue<string>();
if (status == "succeeded" || status == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException($"job {jobId} is still running; poll it again later");
await Task.Delay(1000);
}
if (job["status"]!.GetValue<string>() == "failed") throw new Exception("job failed: " + job["error"]?.ToJsonString());
var reply = ReplyObject(job["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"charged {job["charged_credits"]} credits");
Console.WriteLine(reply["headline"]);
Console.WriteLine(reply["arb"]?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
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:
| event | data |
|---|---|
delta | {text}: the next piece of the reply. |
job | The 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.
def run_stream(body, key):
"""POST /run-stream; prints deltas, returns the done payload."""
try:
res = urllib.request.urlopen(request("POST", "/run-stream", body, idempotency_key=key))
except urllib.error.HTTPError as e:
raise api_error(e) from None
with res:
if "text/event-stream" not in res.headers.get("Content-Type", ""):
return json.load(res).get("data") # idempotent replay: a plain {"data": ...} envelope
done, event, data = None, "message", ""
for raw in res:
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "": # a blank line ends the frame
if data:
payload = json.loads(data)
if event == "delta":
print(payload.get("text", ""), end="", flush=True)
elif event in ("done", "pending"):
done = payload
elif event == "error":
raise RuntimeError("%s: %s (job %s)" % (payload.get("code"), payload.get("message"), payload.get("job_id")))
event, data = "message", ""
return done
done = run_stream(INPUT, idempotency_key(INPUT))
reply = reply_object(done["output"]["output"]) # always read the final text from done
print("\ncharged", done.get("charged_credits"), "credits")
print(json.dumps(reply["arb"], ensure_ascii=False, indent=2))
// POST /run-stream; prints deltas, resolves with the done payload.
async function runStream(body, key) {
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: headers(TOKEN, key),
body: JSON.stringify(body),
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw apiError(res, json);
return json.data; // idempotent replay: a plain {"data": ...} envelope
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) { // a blank line ends the frame
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const payload = JSON.parse(data);
if (event === "delta") process.stdout.write(payload.text || ""); // in a browser, append to the page instead
else if (event === "done" || event === "pending") done = payload;
else if (event === "error") throw Object.assign(new Error(payload.message || "job failed"), { code: payload.code, job_id: payload.job_id });
}
}
return done;
}
const done = await runStream(INPUT, await idempotencyKey(INPUT));
const streamed = replyObject(done.output.output); // always read the final text from done
console.log("\ncharged", done.charged_credits, "credits");
console.log(JSON.stringify(streamed.arb, null, 2));
// runStream: POST /run-stream; prints deltas, returns the done payload.
func runStream(in map[string]any, key string) (*Job, error) {
req, err := newRequest("POST", "/run-stream", in, token, key)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var job Job // idempotent replay: a plain {"data": ...} envelope
return &job, decodeEnvelope(res, &job)
}
var done *Job
event, data := "message", ""
rd := bufio.NewReader(res.Body)
for {
raw, readErr := rd.ReadString('\n')
line := strings.TrimRight(raw, "\r\n")
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "": // a blank line ends the frame
if data != "" {
if err := handleFrame(event, data, &done); err != nil {
return nil, err
}
}
event, data = "message", ""
}
if readErr == io.EOF {
return done, nil
}
if readErr != nil {
return nil, readErr
}
}
}
func handleFrame(event, data string, done **Job) error {
switch event {
case "delta":
var d struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(data), &d) == nil {
fmt.Print(d.Text)
}
case "done", "pending":
var j Job
if err := json.Unmarshal([]byte(data), &j); err != nil {
return err
}
*done = &j
case "error":
var e struct {
Code string `json:"code"`
Message string `json:"message"`
JobID string `json:"job_id"`
}
_ = json.Unmarshal([]byte(data), &e)
return fmt.Errorf("%s: %s (job %s)", e.Code, e.Message, e.JobID)
}
return nil
}
func main() {
in, err := loadInput()
if err != nil {
panic(err)
}
done, err := runStream(in, idempotencyKey(in, 1))
if err != nil {
panic(err)
}
if done == nil {
panic("the stream ended without a done event")
}
reply, err := replyObject(done.Output.Output) // always read the final text from done
if err != nil {
panic(err)
}
arb, _ := json.MarshalIndent(reply["arb"], "", " ")
fmt.Println("\n" + string(arb))
}
/** POST /run-stream; prints deltas, returns the done payload. */
static JsonNode runStream(JsonNode body, String key) throws Exception {
HttpResponse<Stream<String>> res = HTTP.send(request("POST", "/run-stream", body, token, key),
HttpResponse.BodyHandlers.ofLines());
String ctype = res.headers().firstValue("content-type").orElse("");
if (!ctype.contains("text/event-stream")) // idempotent replay (or an error): a plain envelope
return unwrap(res.statusCode(), res.body().collect(Collectors.joining("\n")));
JsonNode done = null;
String event = "message";
StringBuilder data = new StringBuilder();
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
else if (line.isEmpty()) { // a blank line ends the frame
if (data.length() > 0) {
JsonNode payload = JSON.readTree(data.toString());
switch (event) {
case "delta" -> { System.out.print(payload.path("text").asText()); System.out.flush(); }
case "done", "pending" -> done = payload;
case "error" -> throw new IllegalStateException(payload.path("code").asText() + ": "
+ payload.path("message").asText() + " (job " + payload.path("job_id").asText() + ")");
default -> { }
}
}
event = "message";
data.setLength(0);
}
}
return done;
}
public static void main(String[] args) throws Exception {
JsonNode body = input();
JsonNode done = runStream(body, idempotencyKey(body, 1));
ObjectNode reply = replyObject(done.path("output").path("output").asText()); // always read the final text from done
System.out.println("\ncharged " + done.path("charged_credits") + " credits");
System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(reply.get("arb")));
}
# POST /run-stream; prints deltas, returns the done payload.
def run_stream(body, key)
uri, req = build_request("POST", "/run-stream", body: body, idempotency_key: key)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
# Idempotent replay (or an error): a plain {"data": ...} envelope.
return unwrap(res, res.read_body) unless res["content-type"].to_s.include?("text/event-stream")
done = nil
buffer = String.new # raw bytes; each complete frame is read as UTF-8
res.read_body do |chunk|
buffer << chunk.b
while (idx = buffer.index("\n\n".b)) # a blank line ends the frame
frame = buffer.slice!(0, idx + 2).force_encoding(Encoding::UTF_8)
event = "message"
data = +""
frame.each_line(chomp: true) do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = JSON.parse(data)
case event
when "delta" then print(payload["text"].to_s); $stdout.flush
when "done", "pending" then done = payload
when "error" then raise "#{payload["code"]}: #{payload["message"]} (job #{payload["job_id"]})"
end
end
end
return done
end
end
end
done = run_stream(INPUT, idempotency_key(INPUT))
reply = reply_object(done["output"]["output"]) # always read the final text from done
puts "\ncharged #{done["charged_credits"]} credits"
puts JSON.pretty_generate(reply["arb"])
/** POST /run-stream; prints deltas, returns the done payload. */
function run_stream(array $body, string $key) {
global $TOKEN;
$raw = ""; $buf = ""; $done = null; $failure = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers($TOKEN, $key),
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$raw, &$buf, &$done, &$failure) {
$raw .= $chunk;
$buf .= $chunk;
while (($i = strpos($buf, "\n\n")) !== false) { // a blank line ends the frame
$frame = substr($buf, 0, $i);
$buf = (string) substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strncmp($line, "event:", 6) === 0) $event = trim(substr($line, 6));
elseif (strncmp($line, "data:", 5) === 0) $data .= trim(substr($line, 5));
}
$payload = $data === "" ? null : json_decode($data, true);
if (!is_array($payload)) continue;
if ($event === "delta") { echo $payload["text"] ?? ""; flush(); }
elseif ($event === "done" || $event === "pending") $done = $payload;
elseif ($event === "error") $failure = $payload;
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
// Idempotent replay (or an error): a plain {"data": ...} envelope.
if (strpos($ctype, "text/event-stream") === false) return unwrap($status, $raw);
if ($failure) throw new RuntimeException(($failure["code"] ?? "") . ": " . ($failure["message"] ?? "job failed") . " (job " . ($failure["job_id"] ?? "") . ")");
return $done;
}
$done = run_stream($INPUT, idempotency_key($INPUT));
$reply = reply_object($done["output"]["output"]); // always read the final text from done
echo "\ncharged ", json_encode($done["charged_credits"] ?? null), " credits\n";
echo json_encode($reply["arb"], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";
// POST /run-stream; prints deltas, returns the done payload.
public static async Task<JsonNode?> RunStream(JsonNode body, string key)
{
using var req = Request("POST", "/run-stream", body, key);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream") // idempotent replay (or an error)
return Unwrap((int)res.StatusCode, await res.Content.ReadAsStringAsync());
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
JsonNode? done = null;
string evt = "message", data = "";
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:")) data += line[5..].Trim();
else if (line.Length == 0) // a blank line ends the frame
{
if (data.Length > 0)
{
var payload = JsonNode.Parse(data)!;
switch (evt)
{
case "delta": Console.Write(payload["text"]?.GetValue<string>()); break;
case "done": case "pending": done = payload; break;
case "error":
throw new Exception($"{payload["code"]}: {payload["message"]} (job {payload["job_id"]})");
}
}
evt = "message"; data = "";
}
}
return done;
}
public static async Task Main()
{
var body = Input();
var done = await RunStream(body, IdempotencyKey(body));
var reply = ReplyObject(done!["output"]!["output"]!.GetValue<string>()); // always read the final text from done
Console.WriteLine($"\ncharged {done["charged_credits"]} credits");
Console.WriteLine(reply["arb"]?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}