Driving LQA Desk from your own code
Everything the web app does over HTTP, you can do. The base URL is
https://api.skillsafe.ai/v1/app-api. Every call takes
Authorization: Bearer <token> except the one that mints a token.
One bilingual batch goes in, one review document comes out, and which document you get is decided
by a single field: task.
Two mistakes account for most of the 400s on this API, so they are worth saying before anything
else. First: the request body for /estimate, /run and
/run-stream is the input object itself. There is no
{"input": { ... }} wrapper — if you send one, task is not where the
server looks for it, and the run bills against a payload the prompt cannot read. Second:
there is no X-App-Slug header on any endpoint. The only headers are
Content-Type: application/json, Authorization: Bearer <token> and,
on the two run endpoints, Idempotency-Key. The slug appears exactly once in this whole
API: in the body of POST /guest.
The task field comes first
LQA Desk is one app with four lanes over one work object — the bilingual batch you paste
— and task selects the lane. The lanes run in the order you meet them in the
app: find the errors, settle the terminology, fix the segments, sign the locale off. Every lane
takes the same input object and returns the same envelope; only the meaning of the inner sections
changes.
task | lane | what it produces |
|---|---|---|
qa-review | QA review | An MQM-style review: per-segment severities, quoted spans in both languages, a fix per error, and a computed quality score. |
termbase | Termbase | One approved target per source term, the renderings the batch actually used, and the variants to retire. |
revise | Revise | The failing segments rewritten with every placeholder, tag, number and length budget preserved, old beside new. |
locale-brief | Locale sign-off | The punctuation, spacing, register and script conventions of the target locale, what the batch got wrong, and a release note. |
If task is missing or unrecognised the model does not fail: it picks the closest lane
and names the lane it chose in the first sentence of summary. That is a courtesy, not
a feature to rely on — send the lane you want.
How the paste is read
bilingual is free text, and the app aligns it into segments before anything is sent.
Five shapes are recognised, tried in this order, and the shape that won is reported back to the
user — a file read as the wrong shape makes every later finding nonsense:
format | what it looks like |
|---|---|
keyed | Two keyed string tables (key = value lines) separated by a line of ---, === or ## target, aligned on their keys. Keys present on one side only become empty-target findings. |
tabular | Tab-separated or pipe-separated columns. A header row naming id, source, target and max is honoured; without one, three or more columns are read as id/source/target/max and two columns as source/target. Edge whitespace is preserved on both sides, because in a string table a missing leading space is a run-time defect. |
delimited | One segment per line, the two languages separated by |||, :::, :: , => or an arrow. |
prefixed | Language-prefixed line pairs: EN: … then JA: …. Any two-or-three letter prefix works; two consecutive lines with different prefixes make a pair. |
blocks | Blank-line separated blocks of exactly two lines: source, then target. |
alternating | The fallback. Odd lines are sources, even lines are targets. An odd total leaves the last line unpaired and says so. |
If you are calling the API you do not have to rely on detection: send tab-separated columns with an
id\tsource\ttarget header and the alignment is unambiguous.
The glossary format
glossary is one entry per line. It is parsed, not pattern-matched, and three of its
rules exist because the obvious reading produces wrong findings:
# comments start with # or ;
seats = Plätze | Sitzplätze # either alternative satisfies the entry
sign in = anmelden | !einloggen # a leading ! bans a variant
log -> Protokoll # ->, =>, = and a tab all separate the sides
DNT: Acme Cloud # must survive untranslated
trial = n/a # NO required term - never reported as a miss
dashboard = Übersicht # trailing "# note" text is a note, never a term
- A right-hand side of
n/a,none,-,tbdor?means there is no agreed term yet. The entry is recorded for reference and never produces a glossary miss. - A trailing
#comment is split off before anything else is read, so a note that says "do not use Einloggen" cannot become a required term. - The same source term may appear on several lines; the alternatives merge rather than the last line winning.
Matching is asymmetric on purpose. On the SOURCE side a Latin, Cyrillic or Greek term is matched on
word boundaries, so log does not fire inside login. On the TARGET side a
term of four characters or more is accepted as a substring, because German, Dutch, Danish, Swedish,
Norwegian and Finnish compound — Protokoll is genuinely used inside
Aktivitätsprotokoll, and reporting it absent there would send a reviewer to fix a
segment that is already correct. CJK, Thai and Devanagari terms are matched as substrings on both
sides, because those scripts have no word boundaries to anchor on.
One worked example per lane
Each block below is a complete, valid request body for /estimate, /run
and /run-stream, taken from the app's own request builder — copy one, replace
bilingual and glossary, and send it as-is. Nothing wraps these objects.
Every key is present in every lane, including the ones a given lane makes less use of. The
prescan object is abbreviated here; the app sends the whole thing, and sending it is
what makes coverage_check possible.
task: "qa-review"
The review that decides whether the batch ships. It scores every segment, annotates each error with the quoted span in both languages and a fix, and computes an MQM-style quality score whose arithmetic is shown. verdict is release-ready, fix-then-release, rework or reject.
{
"task": "qa-review",
"bilingual": "id\tsource\ttarget\tmax\nnav.home\tHome\tStart\t12\nnav.settings\tSettings\tEinstellungen\t20\nbtn.save\tSave changes\tÄnderungen speichern\t24\nbtn.cancel\tCancel\tAbbrechen\t12\n... 37 further segments ...",
"bilingual_clipped": 0,
"source_locale": "en",
"target_locale": "de",
"domain": "software-ui",
"register": "formal",
"max_length": 0,
"glossary": "# Acme Cloud DE termbase, v4\nseats = Plätze | Sitzplätze\nsign in = anmelden | !einloggen\nsign out = abmelden\n# ... further entries ...",
"brands": "Acme Cloud, Acme Cloud Pro",
"notes": "Second pass after the vendor's fixes. The client rejected the last batch for using the informal register in error messages, and for translating the product name. Button labels have hard pixel budgets, so the declared limits are real.",
"upstream": "",
"prescan": {
"pair": "en to de",
"pair_label": "English to German",
"format": "tabular",
"locale": {
"target_name": "German",
"script": "latin",
"decimal": ",",
"group": ".",
"terminal": ".!?…",
"quotes": "„“",
"expand_band": [
1.05,
1.45
],
"fullwidth": false,
"nbsp_before": false,
"rtl": false
},
"stats": {
"segment_count": 41,
"translated_count": 41,
"empty_count": 0,
"glossary_size": 8,
"glossary_required": 7,
"dnt_count": 2,
"critical_count": 3,
"major_count": 4,
"expansion": 1.211
},
"by_severity": {
"critical": 3,
"major": 4,
"minor": 6,
"info": 8
},
"by_category": {
"accuracy": 5,
"fluency": 2,
"terminology": 5,
"markup": 4,
"locale": 3,
"consistency": 2
},
"flag_count": 21,
"flags_omitted": 0,
"flags": [
{
"id": "F1",
"rule": "tag-unbalanced",
"category": "markup",
"severity": "critical",
"segment": "help.docs",
"evidence": "<b> is opened and never closed.",
"mitigation": "",
"source": "Read the <b>billing guide</b> before you upgrade.",
"target": "Lesen Sie den <b>Abrechnungsleitfaden, bevor Sie upgraden."
},
{
"id": "F2",
"rule": "number-changed",
"category": "accuracy",
"severity": "critical",
"segment": "plan.trial",
"evidence": "The source says 14 where the target says 4 - and those are different values, not the same value in another format.",
"mitigation": "",
"source": "Your trial ends in 14 days.",
"target": "Ihre Testphase endet in 4 Tagen."
},
"... 19 more ..."
],
"glossary": [
{
"source": "seats",
"targets": [
"Plätze",
"Sitzplätze"
],
"forbidden": [],
"dnt": false
},
{
"source": "sign in",
"targets": [
"anmelden"
],
"forbidden": [
"einloggen"
],
"dnt": false
},
"... 6 more ..."
],
"sampling": {
"method": "all",
"taken": 41,
"total": 41,
"dropped": 0
},
"segments": [
{
"id": "nav.home",
"source": "Home",
"target": "Start",
"max": 12,
"flags": []
},
{
"id": "nav.settings",
"source": "Settings",
"target": "Einstellungen",
"max": 20,
"flags": []
},
"... 39 more ..."
]
}
}
task: "termbase"
The terminology lane. It reads every rendering the batch used for each governed term, picks the one to standardise on, and lists the variants to retire. verdict is consistent, drifting, conflicted or no-termbase. Chain it before qa-review and pass its table as upstream.
{
"task": "termbase",
"bilingual": "id\tsource\ttarget\tmax\nnav.home\tHome\tStart\t12\nnav.settings\tSettings\tEinstellungen\t20\nbtn.save\tSave changes\tÄnderungen speichern\t24\nbtn.cancel\tCancel\tAbbrechen\t12\n... 37 further segments ...",
"bilingual_clipped": 0,
"source_locale": "en",
"target_locale": "de",
"domain": "software-ui",
"register": "formal",
"max_length": 0,
"glossary": "# Acme Cloud DE termbase, v4\nseats = Plätze | Sitzplätze\nsign in = anmelden | !einloggen\nsign out = abmelden\n# ... further entries ...",
"brands": "Acme Cloud, Acme Cloud Pro",
"notes": "Second pass after the vendor's fixes. The client rejected the last batch for using the informal register in error messages, and for translating the product name. Button labels have hard pixel budgets, so the declared limits are real.",
"upstream": "",
"prescan": {
"pair": "en to de",
"pair_label": "English to German",
"format": "tabular",
"locale": {
"target_name": "German",
"script": "latin",
"decimal": ",",
"group": ".",
"terminal": ".!?…",
"quotes": "„“",
"expand_band": [
1.05,
1.45
],
"fullwidth": false,
"nbsp_before": false,
"rtl": false
},
"stats": {
"segment_count": 41,
"translated_count": 41,
"empty_count": 0,
"glossary_size": 8,
"glossary_required": 7,
"dnt_count": 2,
"critical_count": 3,
"major_count": 4,
"expansion": 1.211
},
"by_severity": {
"critical": 3,
"major": 4,
"minor": 6,
"info": 8
},
"by_category": {
"accuracy": 5,
"fluency": 2,
"terminology": 5,
"markup": 4,
"locale": 3,
"consistency": 2
},
"flag_count": 21,
"flags_omitted": 0,
"flags": [
{
"id": "F1",
"rule": "tag-unbalanced",
"category": "markup",
"severity": "critical",
"segment": "help.docs",
"evidence": "<b> is opened and never closed.",
"mitigation": "",
"source": "Read the <b>billing guide</b> before you upgrade.",
"target": "Lesen Sie den <b>Abrechnungsleitfaden, bevor Sie upgraden."
},
{
"id": "F2",
"rule": "number-changed",
"category": "accuracy",
"severity": "critical",
"segment": "plan.trial",
"evidence": "The source says 14 where the target says 4 - and those are different values, not the same value in another format.",
"mitigation": "",
"source": "Your trial ends in 14 days.",
"target": "Ihre Testphase endet in 4 Tagen."
},
"... 19 more ..."
],
"glossary": [
{
"source": "seats",
"targets": [
"Plätze",
"Sitzplätze"
],
"forbidden": [],
"dnt": false
},
{
"source": "sign in",
"targets": [
"anmelden"
],
"forbidden": [
"einloggen"
],
"dnt": false
},
"... 6 more ..."
],
"sampling": {
"method": "all",
"taken": 41,
"total": 41,
"dropped": 0
},
"segments": [
{
"id": "nav.home",
"source": "Home",
"target": "Start",
"max": 12,
"flags": []
},
{
"id": "nav.settings",
"source": "Settings",
"target": "Einstellungen",
"max": 20,
"flags": []
},
"... 39 more ..."
]
}
}
task: "revise"
The repair lane, and the only one that emits new target text. It rewrites the segments that failed and leaves the rest alone, holding every placeholder, tag, number, glossary term and length budget. verdict is all-fixed, most-fixed, partly-fixed or cannot-fix.
{
"task": "revise",
"bilingual": "id\tsource\ttarget\tmax\nnav.home\tHome\tStart\t12\nnav.settings\tSettings\tEinstellungen\t20\nbtn.save\tSave changes\tÄnderungen speichern\t24\nbtn.cancel\tCancel\tAbbrechen\t12\n... 37 further segments ...",
"bilingual_clipped": 0,
"source_locale": "en",
"target_locale": "de",
"domain": "software-ui",
"register": "formal",
"max_length": 0,
"glossary": "# Acme Cloud DE termbase, v4\nseats = Plätze | Sitzplätze\nsign in = anmelden | !einloggen\nsign out = abmelden\n# ... further entries ...",
"brands": "Acme Cloud, Acme Cloud Pro",
"notes": "Second pass after the vendor's fixes. The client rejected the last batch for using the informal register in error messages, and for translating the product name. Button labels have hard pixel budgets, so the declared limits are real.",
"upstream": "",
"prescan": {
"pair": "en to de",
"pair_label": "English to German",
"format": "tabular",
"locale": {
"target_name": "German",
"script": "latin",
"decimal": ",",
"group": ".",
"terminal": ".!?…",
"quotes": "„“",
"expand_band": [
1.05,
1.45
],
"fullwidth": false,
"nbsp_before": false,
"rtl": false
},
"stats": {
"segment_count": 41,
"translated_count": 41,
"empty_count": 0,
"glossary_size": 8,
"glossary_required": 7,
"dnt_count": 2,
"critical_count": 3,
"major_count": 4,
"expansion": 1.211
},
"by_severity": {
"critical": 3,
"major": 4,
"minor": 6,
"info": 8
},
"by_category": {
"accuracy": 5,
"fluency": 2,
"terminology": 5,
"markup": 4,
"locale": 3,
"consistency": 2
},
"flag_count": 21,
"flags_omitted": 0,
"flags": [
{
"id": "F1",
"rule": "tag-unbalanced",
"category": "markup",
"severity": "critical",
"segment": "help.docs",
"evidence": "<b> is opened and never closed.",
"mitigation": "",
"source": "Read the <b>billing guide</b> before you upgrade.",
"target": "Lesen Sie den <b>Abrechnungsleitfaden, bevor Sie upgraden."
},
{
"id": "F2",
"rule": "number-changed",
"category": "accuracy",
"severity": "critical",
"segment": "plan.trial",
"evidence": "The source says 14 where the target says 4 - and those are different values, not the same value in another format.",
"mitigation": "",
"source": "Your trial ends in 14 days.",
"target": "Ihre Testphase endet in 4 Tagen."
},
"... 19 more ..."
],
"glossary": [
{
"source": "seats",
"targets": [
"Plätze",
"Sitzplätze"
],
"forbidden": [],
"dnt": false
},
{
"source": "sign in",
"targets": [
"anmelden"
],
"forbidden": [
"einloggen"
],
"dnt": false
},
"... 6 more ..."
],
"sampling": {
"method": "all",
"taken": 41,
"total": 41,
"dropped": 0
},
"segments": [
{
"id": "nav.home",
"source": "Home",
"target": "Start",
"max": 12,
"flags": []
},
{
"id": "nav.settings",
"source": "Settings",
"target": "Einstellungen",
"max": 20,
"flags": []
},
"... 39 more ..."
]
}
}
task: "locale-brief"
The sign-off lane. It states the conventions this pair is actually judged on, says whether the batch follows each one, and ends with a release note. verdict is compliant, minor-deviations, non-compliant or unclear.
{
"task": "locale-brief",
"bilingual": "id\tsource\ttarget\tmax\nnav.home\tHome\tStart\t12\nnav.settings\tSettings\tEinstellungen\t20\nbtn.save\tSave changes\tÄnderungen speichern\t24\nbtn.cancel\tCancel\tAbbrechen\t12\n... 37 further segments ...",
"bilingual_clipped": 0,
"source_locale": "en",
"target_locale": "de",
"domain": "software-ui",
"register": "formal",
"max_length": 0,
"glossary": "# Acme Cloud DE termbase, v4\nseats = Plätze | Sitzplätze\nsign in = anmelden | !einloggen\nsign out = abmelden\n# ... further entries ...",
"brands": "Acme Cloud, Acme Cloud Pro",
"notes": "Second pass after the vendor's fixes. The client rejected the last batch for using the informal register in error messages, and for translating the product name. Button labels have hard pixel budgets, so the declared limits are real.",
"upstream": "",
"prescan": {
"pair": "en to de",
"pair_label": "English to German",
"format": "tabular",
"locale": {
"target_name": "German",
"script": "latin",
"decimal": ",",
"group": ".",
"terminal": ".!?…",
"quotes": "„“",
"expand_band": [
1.05,
1.45
],
"fullwidth": false,
"nbsp_before": false,
"rtl": false
},
"stats": {
"segment_count": 41,
"translated_count": 41,
"empty_count": 0,
"glossary_size": 8,
"glossary_required": 7,
"dnt_count": 2,
"critical_count": 3,
"major_count": 4,
"expansion": 1.211
},
"by_severity": {
"critical": 3,
"major": 4,
"minor": 6,
"info": 8
},
"by_category": {
"accuracy": 5,
"fluency": 2,
"terminology": 5,
"markup": 4,
"locale": 3,
"consistency": 2
},
"flag_count": 21,
"flags_omitted": 0,
"flags": [
{
"id": "F1",
"rule": "tag-unbalanced",
"category": "markup",
"severity": "critical",
"segment": "help.docs",
"evidence": "<b> is opened and never closed.",
"mitigation": "",
"source": "Read the <b>billing guide</b> before you upgrade.",
"target": "Lesen Sie den <b>Abrechnungsleitfaden, bevor Sie upgraden."
},
{
"id": "F2",
"rule": "number-changed",
"category": "accuracy",
"severity": "critical",
"segment": "plan.trial",
"evidence": "The source says 14 where the target says 4 - and those are different values, not the same value in another format.",
"mitigation": "",
"source": "Your trial ends in 14 days.",
"target": "Ihre Testphase endet in 4 Tagen."
},
"... 19 more ..."
],
"glossary": [
{
"source": "seats",
"targets": [
"Plätze",
"Sitzplätze"
],
"forbidden": [],
"dnt": false
},
{
"source": "sign in",
"targets": [
"anmelden"
],
"forbidden": [
"einloggen"
],
"dnt": false
},
"... 6 more ..."
],
"sampling": {
"method": "all",
"taken": 41,
"total": 41,
"dropped": 0
},
"segments": [
{
"id": "nav.home",
"source": "Home",
"target": "Start",
"max": 12,
"flags": []
},
{
"id": "nav.settings",
"source": "Settings",
"target": "Einstellungen",
"max": 20,
"flags": []
},
"... 39 more ..."
]
}
}
The envelope
Success is {"data": { ... }}. Failure is
{"error": {"code": "...", "message": "..."}} with a matching HTTP status. Read
error.code, never the message, when you branch — the messages are written for
people and will change.
// success
{ "data": { "job_id": "job_9f2c...", "status": "queued" } }
// failure
{ "error": { "code": "VALIDATION_ERROR", "message": "task must be one of qa-review, termbase, revise, locale-brief" } }
Error codes
| code | HTTP | what to do |
|---|---|---|
UNAUTHORIZED | 401 | Missing or expired app token. Mint a new guest token, or sign in and take a personal one from the tokens page. |
FORBIDDEN | 403 | The token is valid but not for this app — or the call is metered and you sent a guest token. |
VALIDATION_ERROR | 400 | The input object is malformed. The usual cause is an {"input": ...} wrapper around a body that should have been sent bare. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. Call /estimate and compare with /me. |
RATE_LIMITED | 429 | Back off and retry. Do not tight-loop. |
NOT_FOUND | 404 | Unknown job id on GET /jobs/{id}. Job ids are not guessable and do not live forever. |
INTERNAL | 500 | Retry with the same Idempotency-Key. A new key on a retry can bill twice. |
Step 1 — a tiny client
Six endpoints, one base URL, one header pair. Paste one of these helpers and every later step is a
one-liner. Replace "YOUR_TOKEN" with a token from
the tokens page if you already have one, or leave it empty and let
step 2 mint a guest token for you. Keep real tokens out of source control — read them from
your own secret store or, at worst, from an environment variable at startup.
# Every call in this guide uses these two values.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
# INPUT is one of the four objects above, on one line, sent as the WHOLE body.
INPUT=$(cat lqa-input.json) # or paste the object inline
# The envelope is the same on every endpoint:
# success -> {"data": { ... }}
# failure -> {"error": {"code": "...", "message": "..."}}
#
# There is no X-App-Slug header on any endpoint. Do not add one.
call() { curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" ${3:+-d "$3"}; }
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
def call(method, path, body=None, extra=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (extra or {}).items():
req.add_header(k, v) # never add X-App-Slug; it is not a header here
with urllib.request.urlopen(req) as r:
payload = json.load(r)
# success is {"data": ...}; failure is {"error": {"code", "message"}}
if "error" in payload:
raise RuntimeError(payload["error"]["code"])
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // Replace "YOUR_TOKEN" with a token from /tokens.html
async function call(method, path, body, extraHeaders) {
const headers = Object.assign({ "Content-Type": "application/json" }, extraHeaders || {});
if (TOKEN) headers.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (json.error) throw new Error(json.error.code + ": " + json.error.message);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Falls back to the literal placeholder so the sample runs unmodified.
var token = firstNonEmpty(os.Getenv("SKILLSAFE_TOKEN"), "YOUR_TOKEN")
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body any, extra map[string]string) (json.RawMessage, error) {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, r)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v) // Idempotency-Key only; there is no X-App-Slug
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var e envelope
json.NewDecoder(res.Body).Decode(&e)
if e.Error != nil {
return nil, errors.New(e.Error.Code)
}
return e.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class LqaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // Replace with a token from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String body, Map<String, String> extra)
throws Exception {
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method(method, pub);
if (extra != null) extra.forEach(b::header); // Idempotency-Key only
// success {"data":...}, failure {"error":{"code","message"}}
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v } # Idempotency-Key only; no X-App-Slug
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"]["code"] if payload["error"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // Replace "YOUR_TOKEN" with a token from /tokens.html
function call(string $method, string $path, $body = null, array $extra = []) {
global $TOKEN;
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([
"Authorization: Bearer " . $TOKEN,
"Content-Type: application/json",
], $extra)); // $extra carries Idempotency-Key only
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new RuntimeException($payload["error"]["code"]);
}
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class LqaDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static string Token = "YOUR_TOKEN"; // Replace with a token from /tokens.html
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path, object body = null,
(string, string)? extra = null)
{
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (extra is (string name, string value))
req.Headers.Add(name, value); // Idempotency-Key only
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString());
return doc.RootElement.GetProperty("data");
}
}
Step 2 — get a token
POST /guest needs no Authorization header, and its body key is
slug. This is the one and only place the string lqa-desk belongs in a
request — it goes in the body, never in a header. A guest token is enough for
/me and /estimate; running a lane is metered and wants a personal token
from the tokens page.
# A guest token needs NO Authorization header. The body key is "slug".
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug": "lqa-desk"}'
# -> {"data":{"token":"aut_...","subject_type":"guest", ...}}
#
# For a metered run, take a personal token instead:
# https://lqa-desk.skillsafe.ai/tokens.html
# The body key is "slug" - not "app_slug", and not a header.
req = urllib.request.Request(BASE + "/guest",
data=json.dumps({"slug": "lqa-desk"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
print(TOKEN[:12] + "...")
// The body key is "slug" - not "app_slug", and not a header.
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "lqa-desk" }),
});
TOKEN = (await res.json()).data.token;
// The body key is "slug" - not "app_slug", and not a header.
raw, err := call("POST", "/guest", map[string]string{"slug": "lqa-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
}
json.Unmarshal(raw, &guest)
token = guest.Token
// The body key is "slug" - not "app_slug", and not a header.
String guest = call("POST", "/guest", "{\"slug\":\"lqa-desk\"}", null);
// parse guest with your JSON library and read data.token into `token`
# The body key is "slug" - not "app_slug", and not a header.
TOKEN = call("POST", "/guest", { "slug" => "lqa-desk" })["token"]
<?php
// The body key is "slug" - not "app_slug", and not a header.
$guest = call("POST", "/guest", ["slug" => "lqa-desk"]);
$TOKEN = $guest["token"];
// The body key is "slug" - not "app_slug", and not a header.
var guest = await Call(HttpMethod.Post, "/guest", new { slug = "lqa-desk" });
Token = guest.GetProperty("token").GetString();
Step 3 — who am I
GET /me is free and tells you two things worth branching on:
subject_type (guest or user) and credits.
Compare the balance with the min_credits that step 4 reports before you spend a run.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"data":{"subject_type":"user","username":"...","credits":123456}}
# subject_type is "guest" or "user". Only a "user" can run a lane.
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
raw, _ := call("GET", "/me", nil, nil)
fmt.Println(string(raw))
System.out.println(call("GET", "/me", null, null));
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 4 — price the run (free)
/estimate creates no job and charges nothing. The request body is the input
object — the same object you saw five times above, sent bare, with no input key
wrapped around it and no X-App-Slug header. Price each lane separately: the four lanes
have different prompts and different output caps, so the revise hold on a batch of forty segments is nothing like the termbase hold on the same paste.
# /estimate is FREE. It creates no job and charges nothing.
# The body IS the input object - it is not wrapped in anything.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "$INPUT"
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":2900,"min_credits":400,
# "sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged. It prices the full output cap; the
# actual charge is usually far lower. Estimate each lane separately.
#
# WRONG: -d '{"input": {"task": "qa-review", ...}}' <- the model never sees `task`
# WRONG: -H "X-App-Slug: lqa-desk" <- no such header
# INPUT is one of the four objects above, sent as the whole body.
est = call("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], "reserved,", est["min_credits"], "minimum")
# Do NOT do this - there is no wrapper key:
# call("POST", "/estimate", {"input": INPUT})
// INPUT is one of the four objects above, sent as the whole body.
const est = await call("POST", "/estimate", INPUT);
console.log(est.model_alias, est.hold_credits, "reserved");
// Do NOT do this - there is no wrapper key:
// call("POST", "/estimate", { input: INPUT })
// input is a map or struct that marshals to the input object itself.
raw, _ := call("POST", "/estimate", input, nil)
var est struct {
ModelAlias string `json:"model_alias"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.ModelAlias, est.HoldCredits, est.MinCredits)
// inputJson is the serialised input object - no wrapper, no X-App-Slug.
System.out.println(call("POST", "/estimate", inputJson, null));
# input is the Hash for the input object itself.
est = call("POST", "/estimate", input)
puts est["model_alias"], est["hold_credits"], est["min_credits"]
<?php
// $input is the array for the input object itself - not ["input" => ...].
$est = call("POST", "/estimate", $input);
echo $est["model_alias"], " ", $est["hold_credits"], "\n";
// input is the input object itself - not new { input = ... }.
var est = await Call(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
The input object, field by field
Every key is always present, in every lane. Send them all; a lane that does not use a field is
happy to receive it, and a missing field is a VALIDATION_ERROR waiting to happen.
These are the fields the app's own readForm() builds — the same object the
estimate, the preflight, the fixture match and the run all share.
| field | type | meaning |
|---|---|---|
task | string | The lane. One of qa-review, termbase, revise, locale-brief. Missing or unrecognised, the model picks the closest lane and names its choice in the first sentence of summary. |
bilingual | string | The pasted batch, in any of the six shapes above. Required. Clipped to 60,000 characters on whole-segment boundaries from the END, with the header line kept and the cut announced in-band — a blind slice would cut a row in half and lose the newest strings, which is what a second-pass reviewer came for. |
bilingual_clipped | number | How many characters the clip removed. 0 when nothing was cut. |
source_locale | string | The source language code, e.g. en. It decides which decimal and thousands separators the source side is read with, and which spelled-out numerals count as numbers. |
target_locale | string | The target language code, e.g. de, ja, pt-BR. It decides the whole locale rule set: separators, sentence-final punctuation, quotation marks, full-width punctuation after an ideograph, French no-break spacing, the expected expansion band and the script-leak check. |
domain | string | software-ui, help-centre, marketing, legal, medical, finance, ecommerce, gaming or general. It changes what counts as a major error, not just the wording. |
register | string | neutral, formal, informal or house (follow the glossary and notes). Register drift across a batch is a real finding, and this is what it is judged against. |
max_length | number | A global per-segment character limit, clamped to 0–999. 0 means none. A per-segment max column in the paste overrides it. |
glossary | string | The termbase as pasted, in the format above. Up to 8,000 characters. |
brands | string | Comma- or newline-separated names that stay in the source language. They are subtracted from the script-leak check, so a Latin brand name in a Japanese target is not reported as untranslated text. |
notes | string | Free context: which pass this is, what the client objected to, which constraints are real. |
upstream | string | The previous lane's output when you are chaining lanes, else "". This is how termbase feeds qa-review, qa-review feeds revise, and revise feeds locale-brief. It is context, not instructions. |
prescan | object | The browser's own measurements. See below. Send it: without it there is nothing for coverage_check to answer, and the model is left guessing at the mechanical facts. |
The prescan object
Produced entirely in the browser by lqa.js, with no network call and no account. If you
are driving the API yourself you may compute it however you like, or send a minimal version —
but the two invariants below are not optional, because breaking either produces a review that
cannot be trusted.
| key | meaning |
|---|---|
pair, pair_label | "en to de" and "English to German". |
format | Which of the six shapes the paste was read as. |
locale | The conventions actually applied: target_name, script, decimal, group, terminal, quotes, expand_band, fullwidth, nbsp_before, rtl. |
stats | segment_count, translated_count, empty_count, glossary_size, glossary_required, dnt_count, critical_count, major_count, expansion. |
by_severity, by_category | Counts per severity and per MQM-style dimension. |
flag_count | The length of the flags array that ships — never the number of findings that were made. A prescan that says 16 and hands over 15 asks the model to reconcile something it cannot see, and it will invent the sixteenth. |
flags_omitted | Findings that existed and were NOT sent, because the payload budget was reached. Reported separately so nothing is silently lost. |
flags[] | {id, rule, category, severity, segment, evidence, mitigation, source, target}. id is F1, F2, …, and coverage_check answers exactly these ids. A non-empty mitigation means a mitigating fact was already considered and the severity already reflects it. |
glossary[] | The parsed termbase: {source, targets[], forbidden[], dnt}. |
sampling | {method, taken, total, dropped}. When the batch is larger than the segment budget, every flagged segment goes first and the rest are drawn with a golden-ratio (low-discrepancy) sequence — never every k-th row. A fixed stride resonates with the periodic structure a string-table export always has (grouped by screen, by feature, by translator), and a stride of four over a file whose every fourth row is one translator's work hands the model that one translator while every "we sampled 25%" assertion still passes. |
segments[] | The segments actually sent: {id, source, target, max, flags[]}, each cell clipped to 400 characters. |
Step 5 — run it and poll
POST /run takes the same bare input object and returns a job_id; poll
GET /jobs/{id} until status is succeeded or
failed. Always send an Idempotency-Key: hash the lane, the input and an
attempt counter. A retry after a network blip must reuse the exact same key or it bills
twice. On INTERNAL (500) that is not a nicety — it is the documented retry.
# Metered. The body is still the bare input object.
# Idempotency-Key = lane + input + attempt, hashed. Reuse it on a retry.
KEY="lqa-desk:qa-review:$(printf %s "$INPUT" | shasum | cut -c1-16):1"
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal. Two seconds is polite; do not tight-loop.
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| grep -q '"status":"succeeded"'; do sleep 2; done
curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"
# -> data.output is a STRING holding the JSON envelope described below.
import hashlib, time
key = "lqa-desk:%s:%s:1" % (
INPUT["task"],
hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16],
)
job_id = call("POST", "/run", INPUT, {"Idempotency-Key": key})["job_id"]
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
result = json.loads(job["output"]) # the envelope described below
print(result["lane"], result["verdict"], len(result["rows"]), "rows")
const key = `lqa-desk:${INPUT.task}:${await sha256Hex(JSON.stringify(INPUT))}:1`;
const { job_id } = await call("POST", "/run", INPUT, { "Idempotency-Key": key });
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await call("GET", "/jobs/" + job_id);
} while (job.status !== "succeeded" && job.status !== "failed");
const result = JSON.parse(job.output);
console.log(result.lane, result.verdict, result.rows.length, "rows");
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
}
// POST /run with an Idempotency-Key header, then poll GET /jobs/{id}.
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
key := fmt.Sprintf("lqa-desk:%s:%x:1", input["task"], sum[:8])
raw, _ := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var started struct {
JobID string `json:"job_id"`
}
json.Unmarshal(raw, &started)
for {
time.Sleep(2 * time.Second)
raw, _ = call("GET", "/jobs/"+started.JobID, nil, nil)
var job struct {
Status string `json:"status"`
Output string `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
fmt.Println(job.Output) // a string holding the JSON envelope
break
}
}
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id}.
String key = "lqa-desk:qa-review:" + Integer.toHexString(inputJson.hashCode()) + ":1";
String started = call("POST", "/run", inputJson, Map.of("Idempotency-Key", key));
// read data.job_id from `started` with your JSON library, then:
String jobId = readJobId(started);
String job;
do {
Thread.sleep(2000);
job = call("GET", "/jobs/" + jobId, null, null);
} while (!isTerminal(job)); // status "succeeded" or "failed"
// data.output is a String holding the JSON envelope described below.
require "digest"
key = "lqa-desk:#{input['task']}:#{Digest::SHA256.hexdigest(JSON.dump(input))[0, 16]}:1"
started = call("POST", "/run", input, { "Idempotency-Key" => key })
job = nil
loop do
sleep 2
job = call("GET", "/jobs/#{started['job_id']}")
break if %w[succeeded failed].include?(job["status"])
end
result = JSON.parse(job["output"])
puts result["lane"], result["verdict"]
<?php
$key = "lqa-desk:" . $input["task"] . ":"
. substr(hash("sha256", json_encode($input)), 0, 16) . ":1";
$started = call("POST", "/run", $input, ["Idempotency-Key: " . $key]);
do {
sleep(2);
$job = call("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
$result = json_decode($job["output"], true);
echo $result["lane"], " ", $result["verdict"], "\n";
var payload = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
Encoding.UTF8.GetBytes(payload))).Substring(0, 16).ToLowerInvariant();
var key = $"lqa-desk:{input.task}:{hash}:1";
var started = await Call(HttpMethod.Post, "/run", input, ("Idempotency-Key", key));
var id = started.GetProperty("job_id").GetString();
JsonElement job;
string status;
do
{
await Task.Delay(2000);
job = await Call(HttpMethod.Get, "/jobs/" + id);
status = job.GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed");
var result = JsonDocument.Parse(job.GetProperty("output").GetString());
Step 6 — stream it instead
POST /run-stream takes the same bare body and the same
Idempotency-Key, and returns text/event-stream with four event names:
job, delta, done and error. A
delta frame's data is {"text": "..."} and the text is
cumulative — the whole output so far, not the increment. Render the latest
delta, and take the authoritative result from done.
# Server-sent events. Four event names: job, delta, done, error.
# Same bare input object, same Idempotency-Key header.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"qa-review\",\"title\":\"QA review..."}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":142,"output":"{...}"}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
event, buf, result = "message", "", None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
buf += line[5:].strip()
elif line == "":
if buf and event == "delta":
print(len(json.loads(buf)["text"]), "chars so far") # cumulative
elif buf and event == "done":
result = json.loads(json.loads(buf)["output"])
buf = "" # reset for the next frame
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT), // the bare input object
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
let name = "message", data = "";
frame.split("\n").forEach((l) => {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
});
if (name === "delta") render(JSON.parse(data).text); // cumulative
if (name === "done") finish(JSON.parse(JSON.parse(data).output));
if (name === "error") throw new Error(JSON.parse(data).code);
}
}
// POST /run-stream and read the body line by line with a bufio.Scanner.
// Frames end at a blank line; "event:" names the frame, "data:" carries JSON.
// job -> {"job_id": "..."}
// delta -> {"text": "<the whole output so far>"} (cumulative)
// done -> {"status": "succeeded", "output": "<the envelope as a string>"}
// error -> {"code": "...", "message": "..."}
// Send the same Idempotency-Key you would send to /run.
// POST /run-stream with HttpResponse.BodyHandlers.ofLines() and fold the
// "event:"/"data:" pairs into frames separated by blank lines.
// delta -> {"text": "..."} and the text is cumulative, not incremental
// done -> {"output": "..."} holds the envelope as a JSON string
// Send the same Idempotency-Key header you would send to /run.
# Net::HTTP#request with a block and res.read_body streams the SSE frames.
# Split on a blank line, read "event:" and "data:", JSON.parse the data.
# delta -> {"text" => "..."} (cumulative)
# done -> {"output" => "..."} the envelope as a string
# Send the same Idempotency-Key header you would send to /run.
<?php
// Set CURLOPT_WRITEFUNCTION and parse "event:"/"data:" frames as they arrive.
// delta frames carry {"text": "..."} and the text is cumulative; the done
// frame carries {"output": "..."} holding the envelope as a JSON string.
// Send the same "Idempotency-Key: ..." header you would send to /run.
// Use HttpCompletionOption.ResponseHeadersRead, then read the stream line by
// line and group lines into frames at each blank line.
// delta -> {"text": "..."} cumulative
// done -> {"output": "..."} the envelope as a JSON string
// Send the same Idempotency-Key header you would send to /run.
The output envelope
Every lane returns the same outer object. The renderer in app.js normalises it before
anything is drawn, so a missing key degrades to an empty section rather than a broken page —
but a lane that omits sections is a lane whose output you should not trust.
| key | type | meaning |
|---|---|---|
lane | string | The lane the reply is for. Compare it with the task you sent. |
title | string | Short title naming the batch and the language pair. |
verdict | string | One of the four verdicts that lane defines. See the table below. |
headline | string | One sentence a manager could paste into a ticket. |
summary | string | Two to four short paragraphs, separated by blank lines. Names the lane it chose if your task was unrecognised. |
score | string | The computed quality score with its arithmetic shown. Substantive in qa-review; may be empty elsewhere. |
checks | array | {name, value, verdict, note}; verdict is pass|weak|fail|n-a. value is the measurement, not an opinion. |
findings | array | {id, severity, category, segment, quote_source, quote_target, why, fix}. severity is critical|major|minor|info; category is accuracy|fluency|terminology|markup|locale|consistency|style. The two quotes are lifted spans, in the source and target languages respectively. |
rows | array | {key, label, a, b, c, note} — six strings, always. One shape for all four lanes; what the columns MEAN is set by the lane and the table is labelled from the lane, not from the reply. |
artifact | string | The Markdown document, complete on its own. |
artifact_json | object | The same document in structured form, shaped per lane. |
coverage_check | array | {flag_id, status, note}; status is confirmed|adjusted|set-aside. See the rule below. |
questions | array | Strings: what could not be decided from what was sent. |
confidence | string | high|medium|low. |
A reply that arrives truncated is not thrown away. The client walks the JSON brackets, drops the incomplete trailing member, closes what is open, and renders whatever parsed with an honest "N of M sections recovered" note. Recovery is counted against that lane's required sections, not the union of all four, so a complete reply never reports itself damaged.
The output contract, lane by lane
The envelope is identical; the inner meanings are not. In particular rows is six
strings in every lane, and what those six strings hold is the only thing that changes — so
read this table before you parse rows in your own code, and do not assume
label means the same thing in revise as it does in termbase.
task | verdict is one of | rows columns: key / label / a / b / c / note | artifact |
|---|---|---|---|
qa-review | release-ready · fix-then-release · rework · reject | segment id / pass|minor|major|critical / worst error's category / penalty points / running score / short clause | LQA-REPORT.md |
termbase | consistent · drifting · conflicted · no-termbase | source term / approved target / renderings used with counts / renderings to retire / occurrences / why this one wins | TERMBASE.md |
revise | all-fixed · most-fixed · partly-fixed · cannot-fix | segment id / old target verbatim / new target ready to paste / what changed / constraints held / why the old one was wrong | REVISED.md |
locale-brief | compliant · minor-deviations · non-compliant · unclear | kebab-case rule id / the convention / follows|deviates|mixed|not exercised / segment ids / fix pattern / how strict it is | LOCALE-SIGNOFF.md |
Two contract details worth building against. In revise, every new target must carry
the source's placeholder set unchanged in spelling and count — reordering is allowed, renaming
and dropping are not — with balanced markup, the same numeric values formatted for the target
locale, the approved glossary term, do-not-translate names verbatim, and the segment's declared
max respected. In locale-brief, a convention the batch never exercises
comes back as not exercised, never as follows: a rule nothing tested is
not a rule the batch passed.
Answering the prescan: the coverage_check rule
The browser measures the batch before any model sees it — the alignment, placeholder and
numeric parity, markup balance, glossary adherence, the batch's consistency with itself, and the
target locale's punctuation, spacing and script rules. Those findings are handed over as
prescan.flags, and the contract is that the reply answers every one of
them, by id:
confirmed— the finding is real and has been kept.adjusted— the finding is real but its severity or reading was wrong, and the note says what it should be.set-aside— the finding is a false alarm, and the note says why in the target language's terms.
The app then reconciles in the other direction: it renders one row per flag it sent, and any flag
the reply never mentioned is shown as unanswered rather than quietly forgiven. If
you are consuming this API, do the same — a flag with no matching flag_id is the
cheapest signal that the reply is thin.
A flag carrying a non-empty mitigation has already had a mitigating fact applied and
its severity already reflects it. Examples of mitigations the check produces on its own, before any
model is involved: a target identical to its source is informational when the source is a URL, a
lone placeholder, a two-character label or a do-not-translate term; a reordered placeholder set is
informational because every placeholder is still present; a "changed number" is not reported at all
when the two sides are the same value in different decimal conventions, or when one side spells the
numeral out as a word; a missing closing tag reports once, with the derived "markup missing"
demoted and told which finding explains it; and an approved term absent literally is dropped to
minor when the target carries an inflected, separable or compounded form of it. Re-arguing a
mitigation the check already made is how a review loses its reader.
Rate limits and cost
/guest,/meand/estimateare free.- A run reserves
hold_creditsand chargescharged_credits, usually far less, because the hold prices the full output cap. - The four lanes price differently. Estimate the lane you are about to run, not the one you ran
last time; a
reviseover a long batch is the most expensive request this app makes. - If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced cap and returns"truncated": true. Surface that rather than presenting a clipped review as complete. - On
429, back off. On500, retry with the sameIdempotency-Key. Never tight-loop either one.
Two last reminders
- The body of
/estimate,/runand/run-streamis the input object itself. Noinputwrapper, ever. - There is no
X-App-Slugheader. The slug goes in thePOST /guestbody and nowhere else.