← LQA Desk / API
Get a token

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.

tasklanewhat it produces
qa-reviewQA reviewAn MQM-style review: per-segment severities, quoted spans in both languages, a fix per error, and a computed quality score.
termbaseTermbaseOne approved target per source term, the renderings the batch actually used, and the variants to retire.
reviseReviseThe failing segments rewritten with every placeholder, tag, number and length budget preserved, old beside new.
locale-briefLocale sign-offThe 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:

formatwhat it looks like
keyedTwo 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.
tabularTab-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.
delimitedOne segment per line, the two languages separated by |||, :::, :: , => or an arrow.
prefixedLanguage-prefixed line pairs: EN: … then JA: …. Any two-or-three letter prefix works; two consecutive lines with different prefixes make a pair.
blocksBlank-line separated blocks of exactly two lines: source, then target.
alternatingThe 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

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

codeHTTPwhat to do
UNAUTHORIZED401Missing or expired app token. Mint a new guest token, or sign in and take a personal one from the tokens page.
FORBIDDEN403The token is valid but not for this app — or the call is metered and you sent a guest token.
VALIDATION_ERROR400The input object is malformed. The usual cause is an {"input": ...} wrapper around a body that should have been sent bare.
INSUFFICIENT_CREDITS402The balance is below min_credits. Call /estimate and compare with /me.
RATE_LIMITED429Back off and retry. Do not tight-loop.
NOT_FOUND404Unknown job id on GET /jobs/{id}. Job ids are not guessable and do not live forever.
INTERNAL500Retry 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"}; }

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

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.

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

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.

fieldtypemeaning
taskstringThe 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.
bilingualstringThe 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_clippednumberHow many characters the clip removed. 0 when nothing was cut.
source_localestringThe 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_localestringThe 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.
domainstringsoftware-ui, help-centre, marketing, legal, medical, finance, ecommerce, gaming or general. It changes what counts as a major error, not just the wording.
registerstringneutral, 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_lengthnumberA global per-segment character limit, clamped to 0–999. 0 means none. A per-segment max column in the paste overrides it.
glossarystringThe termbase as pasted, in the format above. Up to 8,000 characters.
brandsstringComma- 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.
notesstringFree context: which pass this is, what the client objected to, which constraints are real.
upstreamstringThe 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.
prescanobjectThe 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.

keymeaning
pair, pair_label"en to de" and "English to German".
formatWhich of the six shapes the paste was read as.
localeThe conventions actually applied: target_name, script, decimal, group, terminal, quotes, expand_band, fullwidth, nbsp_before, rtl.
statssegment_count, translated_count, empty_count, glossary_size, glossary_required, dnt_count, critical_count, major_count, expansion.
by_severity, by_categoryCounts per severity and per MQM-style dimension.
flag_countThe 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_omittedFindings 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.

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":"{...}"}

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.

keytypemeaning
lanestringThe lane the reply is for. Compare it with the task you sent.
titlestringShort title naming the batch and the language pair.
verdictstringOne of the four verdicts that lane defines. See the table below.
headlinestringOne sentence a manager could paste into a ticket.
summarystringTwo to four short paragraphs, separated by blank lines. Names the lane it chose if your task was unrecognised.
scorestringThe computed quality score with its arithmetic shown. Substantive in qa-review; may be empty elsewhere.
checksarray{name, value, verdict, note}; verdict is pass|weak|fail|n-a. value is the measurement, not an opinion.
findingsarray{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.
rowsarray{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.
artifactstringThe Markdown document, complete on its own.
artifact_jsonobjectThe same document in structured form, shaped per lane.
coverage_checkarray{flag_id, status, note}; status is confirmed|adjusted|set-aside. See the rule below.
questionsarrayStrings: what could not be decided from what was sent.
confidencestringhigh|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.

taskverdict is one ofrows columns: key / label / a / b / c / noteartifact
qa-reviewrelease-ready · fix-then-release · rework · rejectsegment id / pass|minor|major|critical / worst error's category / penalty points / running score / short clauseLQA-REPORT.md
termbaseconsistent · drifting · conflicted · no-termbasesource term / approved target / renderings used with counts / renderings to retire / occurrences / why this one winsTERMBASE.md
reviseall-fixed · most-fixed · partly-fixed · cannot-fixsegment id / old target verbatim / new target ready to paste / what changed / constraints held / why the old one was wrongREVISED.md
locale-briefcompliant · minor-deviations · non-compliant · unclearkebab-case rule id / the convention / follows|deviates|mixed|not exercised / segment ids / fix pattern / how strict it isLOCALE-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:

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

Two last reminders