← Fine Print Desk / API
Tokens

Drive Fine Print Desk from your own code

Everything the web page does is available over HTTP: send the text of a Terms of Service, Privacy Policy or EULA, get the same structured consumer-rights analysis back — a verdict, a letter grade, a clause-by-clause breakdown quoting the document you sent, the six-right checklist, the red flags and the recommendations. The natural uses are a procurement script that grades every vendor agreement in a folder and fails on the ones that arbitrate away your claims, a nightly job that re-reads the terms of the services you depend on and tells you what changed, and a browser extension that hands the page's own text to the API.

The analysis is derived from the community skill terms-analyzer (MIT licence, by ClawFu), whose red-flag categories, severity calibration and six-right checklist it follows. Not affiliated with that repository's author or with ClawFu. It is a plain-English reading, not legal advice, and it never predicts enforceability in a particular jurisdiction.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... }, "meta": { "request_id": "req_...", "timestamp": "..." } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your token as Authorization: Bearer … on every call. The token is scoped to this app, so it identifies it — you may also send X-App-Slug: fine-print-desk and it is accepted, but the only call that genuinely needs to name the app is POST /guest, which takes {"slug": "fine-print-desk"} in its body.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page. Guest tokens expire about a month after they are minted.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
invalid_request400The body is not valid JSON, or a required field of the endpoint itself is missing — slug on /guest is the one people hit.
validation_error422The input object is missing a required field — document_text is the usual one — or a field is the wrong type.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Reveal, Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same localStorage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. Analysing a document is metered, so it needs a personal token from signing in. The guest response is {"token": "aut_…", "guest_id": "gst_…", "expires_at": "…"}; note that slug goes in the body, not in a header.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://fine-print-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; analysing a document needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "fine-print-desk"}'
# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"2026-09-21T11:27:37Z"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="fine-print-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://fine-print-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "X-App-Slug: $SLUG" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. The input, and pricing the run — free

Fine Print Desk does one thing, so there is one input shape and no task field to choose a lane. The input object is exactly what the app's own form submits:

fieldtypemeaning
document_textstring, requiredThe agreement itself — a Terms of Service, Privacy Policy, EULA, subscriber agreement, community guidelines, whatever was put in front of the user. All of it or an excerpt. This is the analysis's only evidence: every quote in the reply must appear here, and a clause that is not in it comes back named as absent rather than invented. The browser lane clips a document over 20,000 characters before sending it (see below); an API caller sending more than that gets an analysis of a long document, so clip it yourself if you care which parts are read.
document_typestringtos, privacy_policy, eula or other. It sets the expectations the reading is held to — a privacy policy is judged on collection, sharing and deletion; a EULA on licence scope and reverse-engineering bans. When you do not know, send the prescan's guessed_document_type, or other.
service_namestring, optionalThe service the document belongs to, e.g. "Acme Cloud". May be empty or omitted; it is used for the report title and for document_overview.service_name when the document itself never names the provider.
prescanobject, optionalWhat the free in-browser pattern engine found before the run — see below. Omit it and the analysis is told no prescan is available; it still reads document_text and simply has nothing to reconcile against.

prescan, honestly

In the browser, prescan is computed for free before any model call by a deterministic pattern engine: sixteen red-flag phrase patterns across eight categories, six rights-checklist topics, a document-type guess from weighted phrase hints, a readability label from average sentence length, and the "last updated" and governing-law lines when they are there. An API caller does not have to reproduce any of that. Leaving prescan out entirely is legitimate.

What makes it worth sending is the reconciliation contract: every id you send in prescan.red_flags must come back in reconciliation.confirmed or in reconciliation.dismissed. That turns a fact your own tooling already established into something the analysis is held to. A dismissed entry is a correct answer — a phrase pattern cannot tell a negation, a quoted definition or a mis-scoped match from the real thing — and it is a different outcome from silence. An id in neither list is unaccounted for, which the web app shows as a defect of the run rather than hiding.

These are the fields of the object, as the browser sends it:

fieldtypemeaning
word_countnumberWords in the whole document, before any clipping.
readabilityenumplain language, mixed or legalese, from average words per sentence — over 28 is legalese, over 17 is mixed. A crude measure, and honest about being one.
guessed_document_typestringtos, privacy_policy, eula or other, from weighted phrase hints. The analysis is free to disagree with it.
red_flagsobject[]{id, category, severity, label, quote}. The ids are stable and listed below; quote is the sentence the pattern matched, which is what makes a wrong match visible.
rights_signalsobject[]{right, mentioned, quote} for each of the six rights, in the fixed order. It reports only whether the topic is mentioned; it never claims a right is granted or denied, because a pattern cannot tell a real carve-out from a hollow one.
category_hitsobjectCount of flags per category, e.g. {"Data ownership": 3}.
clipped / clip_notebool / stringWhether the text sent was clipped, and a note saying exactly what was kept. See below.
sentence_count, overview_hints, provisional_verdictnumber, object, stringAlso sent by the browser and optional for you: the sentence count, {last_updated, jurisdiction} when the patterns found them, and the prescan's own severity-tally guess of Good, Caution or Avoid — a tally, not a reading, and the analysis regularly overrules it.

The sixteen red-flag ids, with the pattern each one actually recognises:

idcategory / severityfires on
rf_perpetual_licenseData ownership · Highperpetual and irrevocable within one clause of each other.
rf_sublicensableData ownership · Mediumsublicense, sublicensable, sublicensing.
rf_transferable_licenseData ownership · Mediumtransferable followed closely by licence or right.
rf_new_productsData usage · Highdeveloping new products, for any purpose, or training AI / machine-learning models.
rf_data_sharing_marketingData sharing · Mediumaffiliates, business partners or third parties near marketing or advertis….
rf_sell_dataData sharing · Highsell near personal information, personal data or your data.
rf_unilateral_modificationTerms changes · Mediuma right to modify / change / update / amend these Termsat any time.
rf_no_notice_changesTerms changes · Mediumwithout notice or without prior notice, anywhere. The loosest pattern in the set, and the one most often dismissed.
rf_sole_discretion_terminationTermination · Highterminate or suspend within a clause of sole discretion.
rf_termination_no_causeTermination · Highwith or without cause.
rf_mandatory_arbitrationDispute resolution · Mediumbinding arbitration or mandatory arbitration.
rf_class_action_waiverDispute resolution · Mediumclass action followed by waiv… — in that order, which is why "you waive any right to participate in a class action" slips past it.
rf_indemnificationLiability · Highindemnify, indemnifies, indemnification.
rf_liability_disclaimerLiability · Lowin no event shall / in no event will.
rf_no_refundBilling · Low-Mediumno refund, no refunds, non-refundable.
rf_auto_renewalBilling · Low-Mediumauto-renew, automatically renew — not renews automatically, which it misses.

Those last two rows are the point of the reconciliation, not a defect to hide. A phrase pattern reads word order, not meaning: a class-action waiver written the other way round and a subscription that "renews automatically" are both invisible to it, and both land in reconciliation.missed_by_prescan when the analysis reads the clause. That is what the paid lane is for.

Clipping, when the document is long

Over 20,000 characters, the browser lane does not send the first 20,000 and drop the rest. It keeps the opening 6,000 characters, the closing 3,000, and a ±400-character window around every flagged clause it can fit, joins them with explicit [...] markers, and sets clipped: true with a clip_note saying how many characters were sent and how many flagged locations are included out of how many found. If you clip on your own side, say so in the text the same way — an elision the analysis can see is one it can tell you about, and an invisible one turns into a confident reading of a document nobody sent.

The document every example on this page uses

Short enough to read, long enough to be interesting. Save it as acme-tos.txt; the code below reads it from there.

Acme Cloud Terms of Service. Last updated: 3 March 2026.

4. Your Content. By uploading content to the Service you grant Acme Cloud a
perpetual, irrevocable, worldwide, royalty-free, sublicensable and transferable
licence to host, reproduce, modify and distribute that content, including for
developing new products and training machine learning models.

7. Changes. We may modify these Terms at any time in our sole discretion. Your
continued use of the Service after the changes take effect constitutes acceptance.

9. Termination. We may suspend or terminate your account at any time, with or
without cause and with or without notice.

12. Disputes. All disputes arising out of these Terms will be resolved through
final and binding arbitration on an individual basis. You waive any right to
participate in a class action.

14. Fees. Subscriptions renew automatically. All fees are non-refundable.

18. Governing law. These Terms are governed by the laws of Delaware.

This is the complete input the browser lane would send for it — the prescan below is the real output of the in-browser engine on that text, not an idealised one:

{
  "document_text": "Acme Cloud Terms of Service. Last updated: 3 March 2026.\n\n4. Your Content. ...",
  "document_type": "tos",
  "service_name": "Acme Cloud",
  "prescan": {
    "word_count": 150,
    "sentence_count": 23,
    "readability": "plain language",
    "guessed_document_type": "tos",
    "overview_hints": { "last_updated": "3 March 2026.", "jurisdiction": "Delaware" },
    "red_flags": [
      { "id": "rf_perpetual_license", "category": "Data ownership", "severity": "High",
        "label": "Perpetual / irrevocable content license",
        "quote": "By uploading content to the Service you grant Acme Cloud a perpetual, irrevocable, worldwide, royalty-free, sublicensable and transferable licence to host, reproduce, modify and distribute that content, including for developing new products and training machine learning models." },
      { "id": "rf_sublicensable", "category": "Data ownership", "severity": "Medium",
        "label": "Sublicensable license (can be handed to others)", "quote": "By uploading content ... machine learning models." },
      { "id": "rf_transferable_license", "category": "Data ownership", "severity": "Medium",
        "label": "Transferable license (survives a sale of the company)", "quote": "By uploading content ... machine learning models." },
      { "id": "rf_new_products", "category": "Data usage", "severity": "High",
        "label": "Content usable beyond operating the service (new products / AI training)", "quote": "By uploading content ... machine learning models." },
      { "id": "rf_unilateral_modification", "category": "Terms changes", "severity": "Medium",
        "label": "Terms changeable unilaterally at any time",
        "quote": "We may modify these Terms at any time in our sole discretion." },
      { "id": "rf_no_notice_changes", "category": "Terms changes", "severity": "Medium",
        "label": "Changes made without prior notice",
        "quote": "We may suspend or terminate your account at any time, with or without cause and with or without notice." },
      { "id": "rf_termination_no_cause", "category": "Termination", "severity": "High",
        "label": "Termination with or without cause",
        "quote": "We may suspend or terminate your account at any time, with or without cause and with or without notice." },
      { "id": "rf_mandatory_arbitration", "category": "Dispute resolution", "severity": "Medium",
        "label": "Mandatory / binding arbitration",
        "quote": "All disputes arising out of these Terms will be resolved through final and binding arbitration on an individual basis." },
      { "id": "rf_no_refund", "category": "Billing", "severity": "Low-Medium",
        "label": "No-refund clause", "quote": "All fees are non-refundable." }
    ],
    "rights_signals": [
      { "right": "Data portability",  "mentioned": false, "quote": null },
      { "right": "Account deletion",  "mentioned": true,
        "quote": "We may suspend or terminate your account at any time, with or without cause and with or without notice." },
      { "right": "Content ownership", "mentioned": false, "quote": null },
      { "right": "Privacy controls",  "mentioned": false, "quote": null },
      { "right": "Opt-out options",   "mentioned": false, "quote": null },
      { "right": "Refund rights",     "mentioned": true, "quote": "All fees are non-refundable." }
    ],
    "category_hits": { "Data ownership": 3, "Data usage": 1, "Terms changes": 2,
                       "Termination": 1, "Dispute resolution": 1, "Billing": 1 },
    "provisional_verdict": "Avoid",
    "clipped": false,
    "clip_note": null
  }
}

Two of those entries are worth watching through the rest of the page. rf_no_notice_changes is a Terms changes flag whose quote is the termination clause — the phrase "without notice" appears there and nowhere else — so a correct analysis dismisses it as mis-scoped and covers the substance under termination instead. And the "Account deletion" rights signal is mentioned: true because the provider reserves the right to delete your account, which is the opposite of the right being granted. The prescan cannot tell the difference. The analysis is expected to.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps, byok — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower. Fine Print Desk carries no flat fee — price_credits is 0 and you pay the model's metered usage plus a 10% markup (markup_bps: 1000), which means a short excerpt costs a fraction of what a forty-page privacy policy does.

# Build the input with python3 so a document full of quotes and newlines does not
# have to be escaped by hand. The prescan here is trimmed to four flags for
# legibility; the browser sends all nine. Whatever you DO send must come back in
# reconciliation.confirmed or reconciliation.dismissed.
INPUT=$(python3 - <<'PY'
import json, pathlib
doc = pathlib.Path("acme-tos.txt").read_text(encoding="utf-8")
print(json.dumps({
    "document_text": doc,
    "document_type": "tos",
    "service_name": "Acme Cloud",
    "prescan": {
        "word_count": len(doc.split()),
        "readability": "plain language",
        "guessed_document_type": "tos",
        "red_flags": [
            {"id": "rf_perpetual_license", "category": "Data ownership", "severity": "High",
             "label": "Perpetual / irrevocable content license",
             "quote": "you grant Acme Cloud a perpetual, irrevocable, worldwide, royalty-free, sublicensable and transferable licence"},
            {"id": "rf_new_products", "category": "Data usage", "severity": "High",
             "label": "Content usable beyond operating the service",
             "quote": "including for developing new products and training machine learning models"},
            {"id": "rf_termination_no_cause", "category": "Termination", "severity": "High",
             "label": "Termination with or without cause",
             "quote": "We may suspend or terminate your account at any time, with or without cause and with or without notice."},
            {"id": "rf_mandatory_arbitration", "category": "Dispute resolution", "severity": "Medium",
             "label": "Mandatory / binding arbitration",
             "quote": "resolved through final and binding arbitration on an individual basis"},
        ],
        "rights_signals": [
            {"right": "Data portability", "mentioned": False, "quote": None},
            {"right": "Refund rights", "mentioned": True, "quote": "All fees are non-refundable."},
        ],
        "category_hits": {"Data ownership": 1, "Data usage": 1, "Termination": 1,
                          "Dispute resolution": 1},
        "clipped": False,
        "clip_note": None,
    },
}))
PY
)

call estimate "$INPUT"
# {"ok":true,"data":{"hold_credits":1617,"min_credits":189,"model":"gpt-5.6-terra",
#   "model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false,"byok":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run takes the same input object as its body — the input is the body, not wrapped in anything — and returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The report JSON is the string at data.output.output. The terminal job also carries charged_credits — the real price — and the truncated flag.

Always send an Idempotency-Key. It is not formally required by the endpoint, and it is required in practice: derive it from the input as the web app does, a content hash plus an attempt counter (fine-print-desk:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a retry safe after a network blip — and it matters more here than in most apps, because the same terms document is re-analysed by every script that walks a folder. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second analysis.
KEY="fine-print-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"verdict\":\"Avoid\",\"grade\":\"D\", ...}"},
#   "charged_credits":412,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the report JSON, and the final done event carries status, charged_credits — the real price, normally a fraction of the hold — and the truncated flag. Events are separated by a blank line, and a job event arrives first with the job_id.

The practical tip: the web app does not parse the partial JSON to drive its progress display, it watches for key names arriving in the accumulating text. The keys come in contract order, so the appearance of "clauses", then "rights_checklist", then "recommendations", then "reconciliation" is what advances the stage from grading the document to reading it clause by clause, answering the six rights, and finally reconciling the prescan. Substring matching on the quoted key name is enough, and it costs nothing.

# Server-sent events. Each `delta` carries a chunk of the report JSON; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"verdict\":\"Avoid\",\"grade\""}
# event: delta  {"text":":\"D\",\"grade_note\":\"D, and"}
# event: done   {"status":"succeeded","charged_credits":412,"truncated":false}

7. Read the report

data.output.output is a string holding one JSON object. The web app strips an optional code fence, takes everything from the first { to the last }, parses that, and then normalizes it. Doing the same two things — the slice and the normalization — is what makes a caller robust against the small variations a model produces.

Here is the report for the Acme Cloud excerpt above, abbreviated but structurally complete. Long strings are wrapped and three clauses are elided with a comment for reading on this page — the real reply is one line per string and carries every clause:

{
  "verdict": "Avoid",
  "grade": "D",
  "grade_note": "D, and an F on the content licence read on its own",
  "confidence": "Medium",
  "summary": "Acme Cloud takes a perpetual, irrevocable, sublicensable and transferable licence to
    everything you upload, and reserves the right to use it for new products and model training —
    which is far broader than running the service you signed up for. It can change the terms or
    close your account at any time, and disputes go to individual arbitration. This is a reading of
    the excerpt provided, not legal advice.",
  "document_overview": {
    "service_name": "Acme Cloud",
    "provider": "Not stated",
    "jurisdiction": "Delaware, United States",
    "last_updated": "3 March 2026",
    "readability": "plain language"
  },
  "key_findings": [
    { "area": "Data Rights",        "status": "Problematic", "concern_level": "HIGH" },
    { "area": "Data Sharing",       "status": "Not addressed", "concern_level": "MEDIUM" },
    { "area": "Terms Changes",      "status": "One-Sided",   "concern_level": "MEDIUM" },
    { "area": "Dispute Resolution", "status": "Restrictive", "concern_level": "MEDIUM" },
    { "area": "Termination",        "status": "One-Sided",   "concern_level": "HIGH" },
    { "area": "Liability",          "status": "Not addressed", "concern_level": "LOW" }
  ],
  "clauses": [
    {
      "id": "C1",
      "title": "Content licence",
      "concern_level": "HIGH",
      "quote": "By uploading content to the Service you grant Acme Cloud a perpetual, irrevocable,
        worldwide, royalty-free, sublicensable and transferable licence to host, reproduce, modify
        and distribute that content, including for developing new products and training machine
        learning models.",
      "breakdown": [
        { "term": "Perpetual, irrevocable", "meaning": "The licence never ends and you cannot take it back, even after you delete the file or close the account.", "concern": "CONCERNING" },
        { "term": "Sublicensable", "meaning": "They can hand the same rights to someone else.", "concern": "CONCERNING" },
        { "term": "Transferable", "meaning": "The licence survives a sale of the company and moves to the buyer.", "concern": "CONCERNING" },
        { "term": "Royalty-free", "meaning": "No payment is owed to you for any of it.", "concern": "STANDARD" },
        { "term": "Host, reproduce, distribute", "meaning": "The rights actually needed to run a cloud service.", "concern": "STANDARD" }
      ],
      "what_it_means": "The operational half of this clause is ordinary — a service cannot store and
        show your files without a licence to copy them. What is not ordinary is the combination of
        perpetual and irrevocable with 'developing new products and training machine learning
        models': that is a grant for purposes unrelated to serving you, which does not expire when
        you leave.",
      "industry_standard": "A fair version limits the licence to 'operating, promoting and improving
        the Service', ends it when you delete the content, and asks separately — with an opt-out —
        before using customer content to train models.",
      "recommendation": "Do not upload anything you would mind seeing in a product you are not paid
        for. Consider asking Acme whether an enterprise agreement carves out model training."
    },
    { "id": "C2", "title": "Unilateral changes", "concern_level": "MEDIUM", "quote": "We may modify
        these Terms at any time in our sole discretion.", "breakdown": [ ... ],
      "what_it_means": "...", "industry_standard": "...", "recommendation": "..." }
    // C3 termination, C4 arbitration and the class-action waiver, C5 billing — same shape
  ],
  "rights_checklist": [
    { "right": "Data portability",  "status": "Unknown", "note": "The excerpt never mentions export or download of your content." },
    { "right": "Account deletion",  "status": "Unknown", "note": "Clause 9 is the provider's right to close your account, not yours. No user-initiated deletion is described." },
    { "right": "Content ownership", "status": "Limited", "note": "C1 does not transfer title, but the licence it takes is close to ownership in practice." },
    { "right": "Privacy controls",  "status": "Unknown", "note": "Not addressed in the excerpt; likely in a separate privacy policy." },
    { "right": "Opt-out options",   "status": "No",      "note": "No opt-out is offered for the model-training use in C1." },
    { "right": "Refund rights",     "status": "No",      "note": "Clause 14: 'All fees are non-refundable', with automatic renewal and no cancellation window described." }
  ],
  "red_flags": [
    { "flag": "Perpetual, irrevocable, sublicensable content licence", "severity": "High", "clause_ref": "C1 (clause 4)" },
    { "flag": "Content usable for new products and model training", "severity": "High", "clause_ref": "C1 (clause 4)" },
    { "flag": "Termination with or without cause and without notice", "severity": "High", "clause_ref": "C3 (clause 9)" },
    { "flag": "Mandatory individual arbitration", "severity": "Medium", "clause_ref": "C4 (clause 12)" },
    { "flag": "Class-action waiver", "severity": "Medium", "clause_ref": "C4 (clause 12)" },
    { "flag": "Automatic renewal with no refunds", "severity": "Low-Medium", "clause_ref": "C5 (clause 14)" }
  ],
  "better_language": [
    { "topic": "Content licence",
      "current": "a perpetual, irrevocable ... licence ... including for developing new products and training machine learning models",
      "better_standard": "a non-exclusive licence, for the purpose of operating and improving the Service, ending when you delete the content; separate written consent for any model training." },
    { "topic": "Termination",
      "current": "at any time, with or without cause and with or without notice",
      "better_standard": "30 days' notice except for material breach, and a 30-day window to export your data after termination." }
  ],
  "recommendations": {
    "personal_use": "Usable for things you would publish anyway; keep anything you consider yours out of it.",
    "business_use": [
      "Do not put client-confidential material here under these terms.",
      "Ask for a written carve-out on model training and on the sublicensable and transferable wording.",
      "Ask for a notice period and a data-export window before termination.",
      "Check whether your own contracts allow a vendor to arbitrate individually rather than litigate."
    ],
    "data_strategy": "Treat the service as a distribution channel, not a repository: keep the master
      copy elsewhere, and export on a schedule since no export right is promised."
  },
  "reconciliation": {
    "confirmed": ["rf_perpetual_license", "rf_sublicensable", "rf_transferable_license",
                  "rf_new_products", "rf_unilateral_modification", "rf_termination_no_cause",
                  "rf_mandatory_arbitration", "rf_no_refund"],
    "dismissed": [
      { "id": "rf_no_notice_changes",
        "reason": "The matched phrase 'without notice' is in the termination clause, not the changes
          clause; the substance is covered under C3. The changes clause has its own problem, which
          is unilateral modification, already flagged." }
    ],
    "missed_by_prescan": [
      "Class-action waiver — written as 'you waive any right to participate in a class action', which the phrase pattern does not match",
      "Automatic renewal — 'Subscriptions renew automatically' reads the other way round to the pattern",
      "The 'Account deletion' signal is the provider's termination right, not a user deletion right"
    ]
  },
  "open_questions": [
    "Is there a separate privacy policy covering collection, sharing and retention?",
    "Does clause 9 leave any window to export content after an account is closed?"
  ]
}

What the normalizer does to it

The web app does not trust the reply verbatim, and neither should a caller. These are the behaviours you will actually hit:

Invariants worth asserting

# The report JSON is a string inside the envelope, so unwrap it twice.
REPORT=$(printf '%s' "$OUT" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

REPORT="$REPORT" INPUT="$INPUT" python3 - <<'PY'
import json, os, re, sys

report = json.loads(os.environ["REPORT"])
inp = json.loads(os.environ["INPUT"])

print(report["verdict"], report["grade"], "-", report.get("grade_note", ""))
print(report["summary"])
for c in report["clauses"]:
    print("  %-4s %-8s %s" % (c["id"], c["concern_level"], c["title"]))
for r in report["rights_checklist"]:
    print("  %-9s %-18s %s" % (r["status"], r["right"], r["note"]))

# 1. Every prescan flag id must be confirmed or dismissed, exactly once.
sent = [f["id"] for f in inp.get("prescan", {}).get("red_flags", [])]
rec = report["reconciliation"]
seen = list(rec["confirmed"]) + [d["id"] for d in rec["dismissed"]]
missing = [i for i in sent if seen.count(i) != 1]
extra = [i for i in seen if i not in sent]
if missing or extra:
    sys.exit("reconciliation drift: unaccounted=%s invented=%s" % (missing, extra))

# 2. The six rights, in the fixed order.
RIGHTS = ["Data portability", "Account deletion", "Content ownership",
          "Privacy controls", "Opt-out options", "Refund rights"]
if [r["right"] for r in report["rights_checklist"]] != RIGHTS:
    sys.exit("rights_checklist is not the six canonical rights in order")

# 3. Every quote must come from the document that was sent.
def norm(s):
    return re.sub(r"\s+", " ", s).strip().lower()
doc = norm(inp["document_text"])
for c in report["clauses"]:
    for frag in [f for f in norm(c["quote"]).split("...") if len(f) > 25]:
        if frag not in doc:
            sys.exit("clause %s quotes text not in document_text: %s" % (c["id"], frag[:60]))

# 4. The verdict and the grade cannot contradict each other.
if report["verdict"] == "Good" and report["grade"] in ("D", "F"):
    sys.exit("verdict Good with grade " + report["grade"])
if report["verdict"] == "Avoid" and report["grade"] in ("A", "B"):
    sys.exit("verdict Avoid with grade " + report["grade"])
print("report is grounded, reconciled and self-consistent")
PY

The output contract

Every key in the object, as the web app reads it:

keytypemeaning
verdictenumGood, Caution or Avoid. The one value a script should branch on. Not normalized to a default — an unexpected string is passed through, so validate it if you switch on it.
gradeenumA to F. A finer reading of the same judgement as verdict; the two are expected to agree.
grade_notestring, optionalOne clause qualifying the letter — "C, leaning D on renewal terms". Not one of the thirteen tracked sections, so it can be absent without the report counting as incomplete.
confidenceenumHigh, Medium or Low. Low is the honest answer on a short excerpt, and a low-confidence report is not a defective one — it is a report about a document nobody sent in full.
summarystringTwo to four sentences a non-lawyer would actually read.
document_overviewobject{service_name, provider, jurisdiction, last_updated, readability}. Each is the string "Not stated" when the document does not say — never a guess. readability is plain language, legalese or mixed.
key_findingsobject[]{area, status, concern_level}, covering at minimum Data Rights, Data Sharing, Terms Changes, Dispute Resolution, Termination and Liability. status is Fair, Restrictive, Problematic, One-Sided or Not addressed; concern_level is LOW, MEDIUM or HIGH. This is the row set for a dashboard.
clausesobject[]{id, title, concern_level, quote, breakdown, what_it_means, industry_standard, recommendation}. Three to eight entries, ids C1, C2, …, chosen for the document in front of it rather than from a fixed checklist — so do not render this by index. quote is verbatim from document_text, or a stated "not present in the pasted text" note. breakdown is {term, meaning, concern} with concern in CONCERNING, STANDARD, GOOD — the phrase-by-phrase translation, and the part users read first.
rights_checklistobject[]{right, status, note}. Always the same six rights in the same order (below). status is Yes, No, Limited or Unknown; note cites the clause or explains the silence. Diffable row by row between two documents.
red_flagsobject[]{flag, severity, clause_ref}. severity is High, Medium, Low-Medium or Low — the same scale the prescan uses. clause_ref points back at a clauses[].id or a numbered clause of the document.
better_languageobject[]{topic, current, better_standard}, two to five entries drawn from the worst flags. current quotes the document; better_standard is what a fair version of that clause says. Useful verbatim in a redline or a vendor email.
recommendationsobject{personal_use: string, business_use: string[], data_strategy: string}. business_use is always coerced to an array. Phrased as "consider" and "ask", never as a legal conclusion.
reconciliationobject{confirmed: string[], dismissed: [{id, reason}], missed_by_prescan: string[]}. The contract with your prescan: every id you sent lands in confirmed or dismissed, and missed_by_prescan names what a phrase pattern could not see. All three arrays always exist after normalization, even if the model omitted the object.
open_questionsstring[]What the excerpt cannot answer and would change the reading — a missing privacy policy, a referenced schedule that was not pasted, a jurisdiction the document never names.

The enums

fieldvaluesnotes
verdictGood, Caution, AvoidGood: nothing here a reasonable consumer would object to once it is explained. Caution: standard-but-worth-knowing terms — arbitration, a class-action waiver, unilateral changes, broad affiliate sharing. Avoid: something significant is given up with no counterweight, such as a perpetual licence beyond service operation, termination with no notice and no data-retrieval window, or a consumer indemnifying the provider.
gradeAFReads with the verdict: A/B alongside Good, B/C alongside Caution, D/F alongside Avoid. A mismatch is a contradiction worth surfacing rather than smoothing over.
concern_levelLOW, MEDIUM, HIGHOn key_findings and on each clause. Upper case, unlike severity — the two scales are deliberately different words so a diff never confuses them.
severityHigh, Medium, Low-Medium, LowOn red_flags, and the same scale the prescan's own flags carry, so a confirmed flag can be compared against the severity the pattern assigned it. High = significant loss with no counterweight; Medium = a term a B2B buyer would negotiate and a consumer cannot; Low/Low-Medium = common boilerplate.
key_findings[].statusFair, Restrictive, Problematic, One-Sided, Not addressedNot addressed is a real answer, and a common one on a privacy policy that never mentions retention.
rights_checklist[].statusYes, No, Limited, UnknownUnknown means the document is silent; No means the document actually denies it. Do not collapse them — silence in a Terms of Service is a different fact from a refusal, and only one of the two is worth quoting back at the provider.
breakdown[].concernCONCERNING, STANDARD, GOODPer phrase inside a clause. A clause is usually a mix: "royalty-free" is STANDARD in the same sentence where "perpetual, irrevocable" is CONCERNING.

The six rights

rights_checklist always carries these six right strings, in this order, on every run — so a table can be rendered by index and two documents are diffable row by row:

 1.  Data portability      4.  Privacy controls
 2.  Account deletion      5.  Opt-out options
 3.  Content ownership     6.  Refund rights

The analysis never repeats a secret. If the document you paste happens to carry an account number, an API key or a signature block, it is not echoed into quote, better_language or summary. And it is not legal advice: the reading says what a clause does and what a fairer version says, and leaves enforceability — which differs sharply between the EU and the US on arbitration and class-action waivers alone — explicitly open.

8. Use it in a script

The worked example: a review gate over a folder of vendor agreements. It reads every *.txt in terms/, analyses each one, prints the verdict and grade, and exits non-zero when any document comes back Avoid or carries a High severity red flag. Derive the Idempotency-Key from the document text so re-running the gate on unchanged terms replays the same job instead of re-billing, and only bump the attempt suffix when the document itself changed — which, for a Terms of Service, is exactly the event you want the gate to fire on. No prescan is sent, which is legitimate: there is then nothing to reconcile, and the analysis reads the document either way.

#!/bin/sh
# terms-gate.sh - fail when a vendor's terms are unacceptable.
set -eu

BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="fine-print-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://fine-print-desk.skillsafe.ai/tokens.html
FAILED=0

for f in terms/*.txt; do
  [ -f "$f" ] || continue

  # 1. Build the input. No prescan: legitimate, nothing to reconcile.
  INPUT=$(DOC="$f" python3 -c '
import json, os, pathlib
p = pathlib.Path(os.environ["DOC"])
print(json.dumps({
    "document_text": p.read_text(encoding="utf-8"),
    "document_type": "tos",
    "service_name": p.stem.replace("-", " ").title(),
}))')

  KEY="fine-print-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

  JOB=$(curl -sS -X POST "$BASE/run" \
    -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG" \
    -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
    -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

  while :; do
    OUT=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG")
    STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
    [ "$STATUS" = "succeeded" ] && break
    [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
    sleep 2
  done

  # 2. Gate on the verdict and on any High-severity red flag.
  printf '%s' "$OUT" | DOC="$f" python3 -c '
import json, os, sys
job = json.load(sys.stdin)["data"]
name = os.environ["DOC"]
if job.get("truncated"):
    print("%s: reply was truncated, report incomplete" % name); sys.exit(2)
r = json.loads(job["output"]["output"])
high = [f["flag"] for f in r["red_flags"] if f["severity"] == "High"]
print("%-28s %-8s %s  %s" % (name, r["verdict"], r["grade"], r["summary"].split(".")[0]))
for f in high:
    print("    HIGH  %s" % f)
sys.exit(2 if r["verdict"] == "Avoid" or high else 0)
' || FAILED=1
done

[ "$FAILED" = 0 ] || { echo "terms-gate: at least one agreement is unacceptable"; exit 1; }
echo "terms-gate: every agreement passed"

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the report, not the report — the verdict, the grade and the first clauses may be complete while recommendations, reconciliation and open_questions are missing or cut mid-string.

A long document is the other way to get there. The model has an output cap, and a forty-page privacy policy analysed clause by clause can reach it honestly. If that happens, send less document rather than asking for less analysis: the clause windows around the terms you actually care about, marked as an elision, beat a complete document with a cut-off reading of it.

Check the flag before you treat a report as complete, and check reconciliation is present before you trust that nothing was dismissed. The right response is a retry, not a repair: resubmit with a shorter document_text and the attempt suffix on the Idempotency-Key incremented so the new body is not a replay of the old key. The in-browser normalizer will brace-balance a truncated reply so the sections that arrived can be read, and that is a recovery for display — not a report you should file, quote or gate on.