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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The 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_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
invalid_request | 400 | The body is not valid JSON, or a required field of the endpoint itself is missing — slug on /guest is the one people hit. |
validation_error | 422 | The input object is missing a required field — document_text is the usual one — or a field is the wrong type. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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"}}
# Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered analysis.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "fine-print-desk"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered analysis.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "fine-print-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered analysis.
guestBody := []byte(`{"slug": "fine-print-desk"}`)
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token, guest.Data.ExpiresAt)
// Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered analysis.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"fine-print-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"aut_...","guest_id":"gst_..."}}
# Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered analysis.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ "slug" => "fine-print-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered analysis.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "fine-print-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://fine-print-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered analysis.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\": \"fine-print-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "fine-print-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "fine-print-desk";
const TOKEN = "YOUR_TOKEN"; // from https://fine-print-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "fine-print-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class FinePrintDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "fine-print-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "fine-print-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "fine-print-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN, "X-App-Slug: " . SLUG];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class FinePrintDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "fine-print-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("X-App-Slug", Slug);
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await FinePrintDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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:
| field | type | meaning |
|---|---|---|
document_text | string, required | The 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_type | string | tos, 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_name | string, optional | The 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. |
prescan | object, optional | What 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:
| field | type | meaning |
|---|---|---|
word_count | number | Words in the whole document, before any clipping. |
readability | enum | plain 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_type | string | tos, privacy_policy, eula or other, from weighted phrase hints. The analysis is free to disagree with it. |
red_flags | object[] | {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_signals | object[] | {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_hits | object | Count of flags per category, e.g. {"Data ownership": 3}. |
clipped / clip_note | bool / string | Whether the text sent was clipped, and a note saying exactly what was kept. See below. |
sentence_count, overview_hints, provisional_verdict | number, object, string | Also 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:
| id | category / severity | fires on |
|---|---|---|
rf_perpetual_license | Data ownership · High | perpetual and irrevocable within one clause of each other. |
rf_sublicensable | Data ownership · Medium | sublicense, sublicensable, sublicensing. |
rf_transferable_license | Data ownership · Medium | transferable followed closely by licence or right. |
rf_new_products | Data usage · High | developing new products, for any purpose, or training AI / machine-learning models. |
rf_data_sharing_marketing | Data sharing · Medium | affiliates, business partners or third parties near marketing or advertis…. |
rf_sell_data | Data sharing · High | sell near personal information, personal data or your data. |
rf_unilateral_modification | Terms changes · Medium | a right to modify / change / update / amend these Terms … at any time. |
rf_no_notice_changes | Terms changes · Medium | without notice or without prior notice, anywhere. The loosest pattern in the set, and the one most often dismissed. |
rf_sole_discretion_termination | Termination · High | terminate or suspend within a clause of sole discretion. |
rf_termination_no_cause | Termination · High | with or without cause. |
rf_mandatory_arbitration | Dispute resolution · Medium | binding arbitration or mandatory arbitration. |
rf_class_action_waiver | Dispute resolution · Medium | class action followed by waiv… — in that order, which is why "you waive any right to participate in a class action" slips past it. |
rf_indemnification | Liability · High | indemnify, indemnifies, indemnification. |
rf_liability_disclaimer | Liability · Low | in no event shall / in no event will. |
rf_no_refund | Billing · Low-Medium | no refund, no refunds, non-refundable. |
rf_auto_renewal | Billing · Low-Medium | auto-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.
import pathlib
DOC = pathlib.Path("acme-tos.txt").read_text(encoding="utf-8")
# The prescan is trimmed to four flags here for legibility; the browser sends all
# nine. Every id you send must come back in reconciliation. Omitting `prescan`
# entirely is legitimate too - there is then simply nothing to reconcile.
INPUT = {
"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,
},
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
import { readFileSync } from "node:fs";
const DOC = readFileSync("acme-tos.txt", "utf8");
// Trimmed to four flags for legibility; the browser sends all nine. Every id you
// send must come back in reconciliation.confirmed or reconciliation.dismissed.
const INPUT = {
document_text: DOC,
document_type: "tos",
service_name: "Acme Cloud",
prescan: {
word_count: DOC.split(/\s+/).length,
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: null },
{ 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: null,
},
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// estimate is free: no job is created and nothing is charged. hold_credits is a
// reservation against the output cap; charged_credits is normally far lower.
doc, err := os.ReadFile("acme-tos.txt")
if err != nil {
panic(err)
}
// Trimmed to three flags for legibility; the browser sends all nine. Every id you
// send must come back in reconciliation.
input := map[string]any{
"document_text": string(doc),
"document_type": "tos",
"service_name": "Acme Cloud",
"prescan": map[string]any{
"word_count": len(strings.Fields(string(doc))),
"readability": "plain language",
"guessed_document_type": "tos",
"red_flags": []any{
map[string]string{"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"},
map[string]string{"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."},
map[string]string{"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": []any{
map[string]any{"right": "Data portability", "mentioned": false, "quote": nil},
map[string]any{"right": "Refund rights", "mentioned": true, "quote": "All fees are non-refundable."},
},
"category_hits": map[string]int{"Data ownership": 1, "Termination": 1, "Dispute resolution": 1},
"clipped": false,
"clip_note": nil,
},
}
raw, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(raw)) // estimate is free - no job, no charge; the hold is a reservation
// Read the document, then build the input. The JSON string below is written by
// hand for brevity; use your JSON library of choice for the escaping of `doc`.
String doc = java.nio.file.Files.readString(java.nio.file.Path.of("acme-tos.txt"));
String docJson = jsonEscape(doc); // e.g. Jackson: mapper.writeValueAsString(doc)
String input = """
{
"document_text": %s,
"document_type": "tos",
"service_name": "Acme Cloud",
"prescan": {
"word_count": 150,
"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_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": null },
{ "right": "Refund rights", "mentioned": true, "quote": "All fees are non-refundable." }
],
"category_hits": { "Data ownership": 1, "Termination": 1, "Dispute resolution": 1 },
"clipped": false,
"clip_note": null
}
}
""".formatted(docJson);
System.out.println(call("estimate", input));
// estimate is free: no job is created and nothing is charged.
// The data object carries model, model_alias, markup_bps, hold_credits,
// min_credits, sponsor_enabled and byok. hold_credits is a reservation against
// the full output cap, so the settled charge is normally far lower.
doc = File.read("acme-tos.txt")
# Trimmed to three flags for legibility; the browser sends all nine. Every id you
# send must come back in reconciliation.
input = {
"document_text" => doc,
"document_type" => "tos",
"service_name" => "Acme Cloud",
"prescan" => {
"word_count" => doc.split.length,
"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_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" => nil },
{ "right" => "Refund rights", "mentioned" => true, "quote" => "All fees are non-refundable." }
],
"category_hits" => { "Data ownership" => 1, "Termination" => 1, "Dispute resolution" => 1 },
"clipped" => false,
"clip_note" => nil
}
}
est = call("estimate", input)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
# estimate is free: no job is created and nothing is charged.
<?php
$doc = file_get_contents("acme-tos.txt");
// Trimmed to three flags for legibility; the browser sends all nine. Every id you
// send must come back in reconciliation.
$input = [
"document_text" => $doc,
"document_type" => "tos",
"service_name" => "Acme Cloud",
"prescan" => [
"word_count" => str_word_count($doc),
"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_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" => null],
["right" => "Refund rights", "mentioned" => true, "quote" => "All fees are non-refundable."],
],
"category_hits" => ["Data ownership" => 1, "Termination" => 1, "Dispute resolution" => 1],
"clipped" => false,
"clip_note" => null,
],
];
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
// estimate is free: no job is created and nothing is charged.
var doc = File.ReadAllText("acme-tos.txt");
// Trimmed to three flags for legibility; the browser sends all nine. Every id you
// send must come back in reconciliation.
var input = new
{
document_text = doc,
document_type = "tos",
service_name = "Acme Cloud",
prescan = new
{
word_count = doc.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length,
readability = "plain language",
guessed_document_type = "tos",
red_flags = new[]
{
new { 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" },
new { 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." },
new { 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 = new object[]
{
new { right = "Data portability", mentioned = false, quote = (string?)null },
new { right = "Refund rights", mentioned = true, quote = "All fees are non-refundable." }
},
category_hits = new Dictionary<string, int> {
["Data ownership"] = 1, ["Termination"] = 1, ["Dispute resolution"] = 1 },
clipped = false,
clip_note = (string?)null
}
};
var est = await FinePrintDesk.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("sponsor_enabled").GetBoolean());
// estimate is free: no job is created and nothing is charged. The hold is a
// reservation against the output cap, not the price of the run.
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"])'
import hashlib, json, time
# 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.
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"fine-print-desk:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
report = json.loads(job["output"]["output"])
print(report["verdict"], report["grade"], report["confidence"])
print(len(report["clauses"]), "clauses,", len(report["red_flags"]), "red flags")
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
// 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.
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `fine-print-desk:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const report = JSON.parse(job.output.output);
console.log(report.verdict, report.grade, report.clauses.length, "clauses", job.charged_credits);
// 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.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("fine-print-desk:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
ChargedCredits int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output) // the report JSON, as a string
fmt.Println(job.ChargedCredits, job.Truncated)
break
}
if job.Status == "failed" {
panic("run failed")
}
time.Sleep(2 * time.Second)
}
// 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.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "fine-print-desk:" + java.util.HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
// Parse job_id out of `started`, then poll GET jobs/{job_id} every two seconds
// until status is "succeeded" or "failed". The report JSON is data.output.output,
// and the terminal job also carries charged_credits and truncated.
System.out.println(started);
require "digest"
# 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.
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
key = "fine-print-desk:#{digest}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("jobs/#{job_id}")
if job["status"] == "succeeded"
report = JSON.parse(job["output"]["output"])
puts "#{report['verdict']} #{report['grade']} - #{report['clauses'].length} clauses"
puts "charged=#{job['charged_credits']} truncated=#{job['truncated']}"
break
end
raise "run failed" if job["status"] == "failed"
sleep 2
end
<?php
// 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.
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "fine-print-desk:{$digest}:a1";
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"X-App-Slug: " . SLUG,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded") {
$report = json_decode($job["output"]["output"], true);
echo $report["verdict"], " ", $report["grade"], PHP_EOL;
echo "charged=", $job["charged_credits"], " truncated=", var_export($job["truncated"], true), PHP_EOL;
break;
}
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
using System.Security.Cryptography;
using System.Text;
// 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.
var json = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLowerInvariant();
var key = $"fine-print-desk:{digest}:a1";
var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
run.Headers.Add("X-App-Slug", "fine-print-desk");
run.Headers.Add("Idempotency-Key", key);
run.Content = JsonContent.Create(input);
// POST it, read data.job_id, then poll GET jobs/{job_id} every two seconds until
// status is "succeeded" or "failed". The report JSON is data.output.output, and
// the terminal job also carries charged_credits and truncated.
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}
# Server-sent events: the report arrives in chunks, so a UI can show progress.
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
done = {}
event = None
stage = "reading the document"
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
if '"reconciliation"' in raw:
stage = "reconciling the prescan"
elif '"recommendations"' in raw:
stage = "writing the recommendations"
elif '"rights_checklist"' in raw:
stage = "answering the six rights"
elif '"clauses"' in raw:
stage = "reading it clause by clause"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
report = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, report["verdict"], report["grade"], done.get("charged_credits"))
// Server-sent events: the report arrives in chunks, so a UI can show progress.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let done = {};
let event = null;
let stage = "reading the document";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text ?? "";
// The arrival of a key name is the progress signal the web app uses.
if (raw.includes('"reconciliation"')) stage = "reconciling the prescan";
else if (raw.includes('"rights_checklist"')) stage = "answering the six rights";
else if (raw.includes('"clauses"')) stage = "reading it clause by clause";
} else if (line.startsWith("data: ") && event === "done") {
done = JSON.parse(line.slice(6));
}
}
}
const report = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, report.verdict, report.grade, done.charged_credits);
// Server-sent events: the report arrives in chunks, so a UI can show progress.
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
var raw strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw.WriteString(d.Text)
// The arrival of "clauses", "rights_checklist" or "reconciliation"
// advances the progress stage.
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(strings.TrimPrefix(line, "data: ")) // status, charged_credits, truncated
}
}
fmt.Println(raw.String())
// Server-sent events: the report arrives in chunks, so a UI can show progress.
var stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
raw.append(line.substring(6)); // each data line is {"text":"..."} - decode and append .text
}
});
System.out.println(raw);
// Watch the accumulating text for "clauses", "rights_checklist" and
// "reconciliation" to advance a progress display. The final `done` event carries
// status, charged_credits and truncated.
# Server-sent events: the report arrives in chunks, so a UI can show progress.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(input)
raw = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
# The arrival of "clauses" or "reconciliation" advances the stage.
end
end
end
end
end
report = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{report['verdict']} #{report['grade']} - #{report['clauses'].length} clauses"
<?php
// Server-sent events: the report arrives in chunks, so a UI can show progress.
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"X-App-Slug: " . SLUG,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$report = json_decode(substr($raw, strpos($raw, "{")), true);
echo $report["verdict"], " ", $report["grade"], PHP_EOL;
// Server-sent events: the report arrives in chunks, so a UI can show progress.
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
stream.Headers.Add("X-App-Slug", "fine-print-desk");
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = JsonContent.Create(input);
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var d = JsonSerializer.Deserialize<JsonElement>(line[6..]);
if (d.TryGetProperty("text", out var t)) raw.Append(t.GetString());
// Watch raw for "clauses" and "reconciliation" to advance a progress display.
}
}
Console.WriteLine(raw.ToString());
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:
- Thirteen top-level sections are tracked:
verdict,grade,confidence,summary,document_overview,key_findings,clauses,rights_checklist,red_flags,better_language,recommendations,reconciliation,open_questions. Each one that is missing ornullis backfilled —[]for the arrays,{}forrecommendationsanddocument_overview,""for the strings — and named in amissing_sectionslist. - Any missing section sets
truncated. An empty array in a rendered report therefore means one of two different things, and onlymissing_sectionstells them apart: the model said "nothing here", or the reply never got that far. grade_noteis not one of the thirteen. It is passed through untouched and may simply be absent; treat it as optional.- A section that arrives as the wrong type is coerced: a non-array
clauses,key_findings,rights_checklist,red_flags,better_languageoropen_questionsbecomes[]; arecommendationsthat is not an object becomes{}, and itsbusiness_useis forced to an array. reconciliationis forced to{confirmed: [], dismissed: [], missed_by_prescan: []}shape, so those three arrays always exist even when the model omitted the object.- If the string does not parse at all, it is repaired before being given up on: unterminated strings are cut back to their last quote, a trailing comma is dropped, and open braces and brackets are closed in order. A stream cut mid-clause therefore still yields the clauses that completed. If even the repaired text does not parse, every section is reported missing and
truncatedis true. - Nothing renames or reorders your data:
clauseskeep the ids the model gave them, andrights_checklistis not re-sorted into the canonical order, so check the order rather than assuming it.
Invariants worth asserting
- Every id you sent in
prescan.red_flagsappears inreconciliation.confirmedor inreconciliation.dismissed, exactly once, and no id you did not send appears in either. rights_checklisthas exactly six entries, and they are Data portability, Account deletion, Content ownership, Privacy controls, Opt-out options, Refund rights, in that order.- Every
clauses[].quoteis text from yourdocument_text. Check it: normalize whitespace and assert the quote is a substring, splitting on...and requiring each fragment to appear. This is the single most valuable assertion on the page — it is what separates a reading of your document from a plausible essay about documents like it. verdictandgradeagree:Goodnever sits withDorF,Avoidnever withAorB, and aGoodverdict never sits above aHIGHclause or aHigh-severity red flag.clauseshas between three and eight entries with unique ids, and no clause has an emptyquote.- A
statusofYesin the rights checklist comes with anotethat cites something;Unknownwith a note explaining the silence is a pass, not a failure.
# 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
import re
report = json.loads(job["output"]["output"])
RIGHTS = ["Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights"]
def norm(s):
return re.sub(r"\s+", " ", s or "").strip().lower()
# 1. Every prescan flag id appears exactly once across confirmed + dismissed,
# and nothing you did not send appears at all.
sent = [f["id"] for f in INPUT.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:
raise RuntimeError(f"reconciliation drift: unaccounted={missing} invented={extra}")
# 2. The six rights, in the fixed order. The normalizer does NOT re-sort them.
if [r["right"] for r in report["rights_checklist"]] != RIGHTS:
raise RuntimeError("rights_checklist is not the six canonical rights in order")
# 3. Every quote is text from the document. Fragments are split on "..." because
# an elided quote is legitimate; short fragments are skipped as noise.
doc = norm(INPUT["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:
raise RuntimeError(f"clause {c['id']} quotes text not in document_text: {frag[:60]}")
# 4. The verdict, the grade and the findings cannot contradict each other.
worst = {c["concern_level"] for c in report["clauses"]}
if report["verdict"] == "Good" and ("HIGH" in worst or report["grade"] in ("D", "F")):
raise RuntimeError("verdict Good over a HIGH clause or a D/F grade")
if report["verdict"] == "Avoid" and report["grade"] in ("A", "B"):
raise RuntimeError("verdict Avoid with grade " + report["grade"])
# 5. A truncated reply is a prefix, not a report. Retry, do not repair.
if job.get("truncated"):
raise RuntimeError("truncated reply - re-run with a shorter document_text")
print(report["verdict"], report["grade"], report.get("grade_note", ""))
for c in report["clauses"]:
print(f" {c['id']:4} {c['concern_level']:8} {c['title']}")
for r in report["rights_checklist"]:
print(f" {r['status']:8} {r['right']:18} {r['note']}")
for f in report["red_flags"]:
print(f" [{f['severity']}] {f['flag']} ({f['clause_ref']})")
print("dismissed:", [(d["id"], d["reason"][:40]) for d in rec["dismissed"]])
print("missed by the prescan:", rec["missed_by_prescan"])
const report = JSON.parse(job.output.output);
const RIGHTS = ["Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights"];
const norm = (s) => (s || "").replace(/\s+/g, " ").trim().toLowerCase();
// 1. Every prescan flag id appears exactly once across confirmed + dismissed.
const sent = (INPUT.prescan?.red_flags ?? []).map((f) => f.id);
const rec = report.reconciliation;
const seen = [...rec.confirmed, ...rec.dismissed.map((d) => d.id)];
const missing = sent.filter((id) => seen.filter((s) => s === id).length !== 1);
const extra = seen.filter((id) => !sent.includes(id));
if (missing.length || extra.length) {
throw new Error(`reconciliation drift: unaccounted=${missing} invented=${extra}`);
}
// 2. The six rights, in the fixed order. The normalizer does NOT re-sort them.
const order = report.rights_checklist.map((r) => r.right);
if (order.join("|") !== RIGHTS.join("|")) {
throw new Error("rights_checklist is not the six canonical rights in order");
}
// 3. Every quote is text from the document that was sent.
const doc = norm(INPUT.document_text);
for (const c of report.clauses) {
for (const frag of norm(c.quote).split("...").filter((f) => f.length > 25)) {
if (!doc.includes(frag)) {
throw new Error(`clause ${c.id} quotes text not in document_text: ${frag.slice(0, 60)}`);
}
}
}
// 4. The verdict cannot contradict the grade or the clauses.
const anyHigh = report.clauses.some((c) => c.concern_level === "HIGH");
if (report.verdict === "Good" && (anyHigh || ["D", "F"].includes(report.grade))) {
throw new Error("verdict Good over a HIGH clause or a D/F grade");
}
if (report.verdict === "Avoid" && ["A", "B"].includes(report.grade)) {
throw new Error(`verdict Avoid with grade ${report.grade}`);
}
if (job.truncated) throw new Error("truncated reply - the report is a prefix");
console.log(report.verdict, report.grade, report.grade_note ?? "");
for (const c of report.clauses) console.log(" ", c.id, c.concern_level, c.title);
for (const r of report.rights_checklist) console.log(" ", r.status.padEnd(8), r.right, "—", r.note);
console.log("missed by the prescan:", rec.missed_by_prescan);
type clause struct {
ID string `json:"id"`
Title string `json:"title"`
ConcernLevel string `json:"concern_level"`
Quote string `json:"quote"`
Breakdown []struct {
Term string `json:"term"`
Meaning string `json:"meaning"`
Concern string `json:"concern"`
} `json:"breakdown"`
WhatItMeans string `json:"what_it_means"`
IndustryStandard string `json:"industry_standard"`
Recommendation string `json:"recommendation"`
}
type report struct {
Verdict string `json:"verdict"`
Grade string `json:"grade"`
GradeNote string `json:"grade_note"`
Confidence string `json:"confidence"`
Summary string `json:"summary"`
Clauses []clause `json:"clauses"`
Rights []struct {
Right string `json:"right"`
Status string `json:"status"`
Note string `json:"note"`
} `json:"rights_checklist"`
RedFlags []struct {
Flag string `json:"flag"`
Severity string `json:"severity"`
ClauseRef string `json:"clause_ref"`
} `json:"red_flags"`
Reconciliation struct {
Confirmed []string `json:"confirmed"`
Dismissed []struct {
ID string `json:"id"`
Reason string `json:"reason"`
} `json:"dismissed"`
MissedByPrescan []string `json:"missed_by_prescan"`
} `json:"reconciliation"`
OpenQuestions []string `json:"open_questions"`
}
var r report
if err := json.Unmarshal([]byte(job.Output.Output), &r); err != nil {
panic(err)
}
// 1. Every prescan flag id must be confirmed or dismissed, exactly once.
count := map[string]int{}
for _, id := range r.Reconciliation.Confirmed {
count[id]++
}
for _, d := range r.Reconciliation.Dismissed {
count[d.ID]++
}
for _, id := range []string{"rf_perpetual_license", "rf_new_products",
"rf_termination_no_cause", "rf_mandatory_arbitration"} {
if count[id] != 1 {
panic("unreconciled prescan flag: " + id)
}
}
// 2. The six rights, in the fixed order.
want := []string{"Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights"}
if len(r.Rights) != len(want) {
panic("rights_checklist is not six entries")
}
for i, w := range want {
if r.Rights[i].Right != w {
panic("rights_checklist out of order at " + w)
}
}
// 3. Every quote must come from the document that was sent.
norm := func(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }
docText := norm(string(doc)) // `doc` is the []byte read from acme-tos.txt
for _, c := range r.Clauses {
for _, frag := range strings.Split(norm(c.Quote), "...") {
if len(frag) > 25 && !strings.Contains(docText, frag) {
panic("clause " + c.ID + " quotes text not in document_text")
}
}
}
fmt.Println(r.Verdict, r.Grade, len(r.Clauses), "clauses", r.Reconciliation.MissedByPrescan)
// The report JSON is a string inside data.output.output - parse it, then check the
// invariants before you trust it:
//
// 1. every prescan.red_flags id appears exactly once across
// reconciliation.confirmed and reconciliation.dismissed, and no id you did
// not send appears in either;
// 2. rights_checklist is exactly six entries - Data portability, Account
// deletion, Content ownership, Privacy controls, Opt-out options, Refund
// rights - in that order, because nothing re-sorts them for you;
// 3. every clauses[].quote is text from your document_text: normalise
// whitespace, split the quote on "..." and require each fragment longer
// than ~25 characters to be a substring. This is the assertion that
// distinguishes a reading of your document from an essay about documents;
// 4. verdict "Good" never sits with grade D or F or above a HIGH clause, and
// verdict "Avoid" never sits with grade A or B.
//
// A `truncated` job is a prefix, not a report: the sections that arrived are
// usable, the ones that did not are absent, and re-running with a shorter
// document_text is the fix - not appending closing braces.
String reportJson = /* data.output.output */ call("jobs/" + jobId, null);
System.out.println(reportJson);
// rights_checklist is always the same six rights, so a table can be rendered by
// index; clauses is 3-8 entries chosen for this document, so it cannot.
report = JSON.parse(job["output"]["output"])
RIGHTS = ["Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights"].freeze
def norm(s)
(s || "").gsub(/\s+/, " ").strip.downcase
end
# 1. Every prescan flag id appears exactly once across confirmed + dismissed.
sent = (input.dig("prescan", "red_flags") || []).map { |f| f["id"] }
rec = report["reconciliation"]
seen = rec["confirmed"] + rec["dismissed"].map { |d| d["id"] }
missing = sent.reject { |id| seen.count(id) == 1 }
extra = seen - sent
raise "reconciliation drift: #{missing} / #{extra}" unless missing.empty? && extra.empty?
# 2. The six rights, in the fixed order.
raise "rights_checklist out of order" if report["rights_checklist"].map { |r| r["right"] } != RIGHTS
# 3. Every quote is text from the document that was sent.
doc = norm(input["document_text"])
report["clauses"].each do |c|
norm(c["quote"]).split("...").each do |frag|
next if frag.length <= 25
raise "clause #{c['id']} quotes text not in document_text" unless doc.include?(frag)
end
end
# 4. The verdict cannot contradict the grade or the clauses.
any_high = report["clauses"].any? { |c| c["concern_level"] == "HIGH" }
raise "verdict Good over a HIGH clause" if report["verdict"] == "Good" && any_high
raise "verdict Avoid with grade #{report['grade']}" if
report["verdict"] == "Avoid" && %w[A B].include?(report["grade"])
puts "#{report['verdict']} #{report['grade']} - #{report['grade_note']}"
report["clauses"].each { |c| puts format(" %-4s %-8s %s", c["id"], c["concern_level"], c["title"]) }
report["rights_checklist"].each { |r| puts format(" %-8s %-18s %s", r["status"], r["right"], r["note"]) }
puts "missed by the prescan: #{rec['missed_by_prescan'].join('; ')}"
<?php
$report = json_decode($job["output"]["output"], true);
const RIGHTS = ["Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights"];
function norm(?string $s): string {
return strtolower(trim(preg_replace('/\s+/', " ", $s ?? "")));
}
// 1. Every prescan flag id appears exactly once across confirmed + dismissed.
$sent = array_column($input["prescan"]["red_flags"] ?? [], "id");
$rec = $report["reconciliation"];
$seen = array_merge($rec["confirmed"], array_column($rec["dismissed"], "id"));
$counts = array_count_values($seen);
foreach ($sent as $id) {
if (($counts[$id] ?? 0) !== 1) {
throw new RuntimeException("unreconciled prescan flag: " . $id);
}
}
foreach (array_diff($seen, $sent) as $invented) {
throw new RuntimeException("reconciliation names an id that was never sent: " . $invented);
}
// 2. The six rights, in the fixed order.
if (array_column($report["rights_checklist"], "right") !== RIGHTS) {
throw new RuntimeException("rights_checklist is not the six canonical rights in order");
}
// 3. Every quote is text from the document that was sent.
$doc = norm($input["document_text"]);
foreach ($report["clauses"] as $c) {
foreach (explode("...", norm($c["quote"])) as $frag) {
if (strlen($frag) > 25 && !str_contains($doc, $frag)) {
throw new RuntimeException("clause " . $c["id"] . " quotes text not in document_text");
}
}
}
// 4. The verdict cannot contradict the grade or the clauses.
$anyHigh = (bool) array_filter($report["clauses"], fn($c) => $c["concern_level"] === "HIGH");
if ($report["verdict"] === "Good" && ($anyHigh || in_array($report["grade"], ["D", "F"], true))) {
throw new RuntimeException("verdict Good over a HIGH clause or a D/F grade");
}
echo $report["verdict"], " ", $report["grade"], PHP_EOL;
foreach ($report["rights_checklist"] as $r) {
printf(" %-8s %-18s %s\n", $r["status"], $r["right"], $r["note"]);
}
var report = JsonSerializer.Deserialize<JsonElement>(reportJson);
string[] rights = { "Data portability", "Account deletion", "Content ownership",
"Privacy controls", "Opt-out options", "Refund rights" };
static string Norm(string? s) =>
string.Join(" ", (s ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
// 1. Every prescan flag id appears exactly once across confirmed + dismissed.
var rec = report.GetProperty("reconciliation");
var seen = rec.GetProperty("confirmed").EnumerateArray().Select(c => c.GetString())
.Concat(rec.GetProperty("dismissed").EnumerateArray().Select(d => d.GetProperty("id").GetString()))
.ToList();
foreach (var id in new[] { "rf_perpetual_license", "rf_new_products",
"rf_termination_no_cause", "rf_mandatory_arbitration" })
{
if (seen.Count(s => s == id) != 1) throw new Exception($"unreconciled prescan flag: {id}");
}
// 2. The six rights, in the fixed order.
var order = report.GetProperty("rights_checklist").EnumerateArray()
.Select(r => r.GetProperty("right").GetString()).ToArray();
if (!order.SequenceEqual(rights))
throw new Exception("rights_checklist is not the six canonical rights in order");
// 3. Every quote is text from the document that was sent.
var docNorm = Norm(doc);
foreach (var c in report.GetProperty("clauses").EnumerateArray())
{
foreach (var frag in Norm(c.GetProperty("quote").GetString()).Split("..."))
{
if (frag.Length > 25 && !docNorm.Contains(frag))
throw new Exception($"clause {c.GetProperty("id")} quotes text not in document_text");
}
}
// 4. The verdict cannot contradict the grade.
var verdict = report.GetProperty("verdict").GetString();
var grade = report.GetProperty("grade").GetString();
if (verdict == "Avoid" && (grade == "A" || grade == "B"))
throw new Exception($"verdict Avoid with grade {grade}");
Console.WriteLine($"{verdict} {grade}");
foreach (var r in report.GetProperty("rights_checklist").EnumerateArray())
Console.WriteLine($" {r.GetProperty("status")} {r.GetProperty("right")}");
The output contract
Every key in the object, as the web app reads it:
| key | type | meaning |
|---|---|---|
verdict | enum | Good, 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. |
grade | enum | A to F. A finer reading of the same judgement as verdict; the two are expected to agree. |
grade_note | string, optional | One 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. |
confidence | enum | High, 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. |
summary | string | Two to four sentences a non-lawyer would actually read. |
document_overview | object | {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_findings | object[] | {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. |
clauses | object[] | {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_checklist | object[] | {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_flags | object[] | {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_language | object[] | {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. |
recommendations | object | {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. |
reconciliation | object | {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_questions | string[] | 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
| field | values | notes |
|---|---|---|
verdict | Good, Caution, Avoid | Good: 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. |
grade | A–F | Reads 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_level | LOW, MEDIUM, HIGH | On key_findings and on each clause. Upper case, unlike severity — the two scales are deliberately different words so a diff never confuses them. |
severity | High, Medium, Low-Medium, Low | On 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[].status | Fair, Restrictive, Problematic, One-Sided, Not addressed | Not addressed is a real answer, and a common one on a privacy policy that never mentions retention. |
rights_checklist[].status | Yes, No, Limited, Unknown | Unknown 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[].concern | CONCERNING, STANDARD, GOOD | Per 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"
#!/usr/bin/env python3
"""terms_gate.py - fail when a vendor's terms are unacceptable.
Reuses the `call` helper from section 2. Exits 1 on any Avoid verdict, any
High-severity red flag, or a truncated reply, which is a prefix and not a report.
"""
import hashlib, json, pathlib, sys, time, urllib.request
failed = []
for path in sorted(pathlib.Path("terms").glob("*.txt")):
INPUT = {
"document_text": path.read_text(encoding="utf-8"),
"document_type": "tos",
"service_name": path.stem.replace("-", " ").title(),
# No prescan: legitimate. Anything you DO send must come back in
# reconciliation.confirmed or reconciliation.dismissed.
}
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"fine-print-desk:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
sys.exit(f"{path.name}: run failed: {job.get('error')}")
if job.get("truncated"):
failed.append(f"{path.name}: reply truncated, report is a prefix")
continue
report = json.loads(job["output"]["output"])
high = [f["flag"] for f in report["red_flags"] if f["severity"] == "High"]
denied = [r["right"] for r in report["rights_checklist"] if r["status"] == "No"]
print(f"{path.name:28} {report['verdict']:8} {report['grade']} {report['summary']}")
for f in high:
print(f" HIGH {f}")
if denied:
print(f" denied rights: {', '.join(denied)}")
print(f" charged {job.get('charged_credits')} credits")
if report["verdict"] == "Avoid" or high:
failed.append(f"{path.name}: {report['verdict']} ({report['grade']}), "
f"{len(high)} high-severity flag(s)")
if failed:
sys.exit("terms-gate:\n " + "\n ".join(failed))
print("terms-gate: every agreement passed")
// terms-gate.mjs - fail when a vendor's terms are unacceptable.
// Reuses the `call` helper from section 2.
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
const failed = [];
for (const name of readdirSync("terms").filter((f) => f.endsWith(".txt")).sort()) {
const INPUT = {
document_text: readFileSync(`terms/${name}`, "utf8"),
document_type: "tos",
service_name: name.replace(/\.txt$/, "").replace(/-/g, " "),
// No prescan: legitimate, and there is then nothing to reconcile.
};
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": `fine-print-desk:${digest}:a1`,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(`${name}: run failed`);
if (job.truncated) { failed.push(`${name}: reply truncated`); continue; }
const report = JSON.parse(job.output.output);
const high = report.red_flags.filter((f) => f.severity === "High").map((f) => f.flag);
console.log(name, report.verdict, report.grade, "-", report.summary);
for (const f of high) console.log(" HIGH ", f);
if (report.verdict === "Avoid" || high.length) {
failed.push(`${name}: ${report.verdict} (${report.grade}), ${high.length} high flag(s)`);
}
}
if (failed.length) {
console.error("terms-gate:\n " + failed.join("\n "));
process.exitCode = 1;
} else {
console.log("terms-gate: every agreement passed");
}
// The gate, on top of the client and the report struct from the earlier sections:
// read every agreement in terms/, analyse it, exit non-zero on an Avoid verdict
// or any High-severity red flag.
entries, err := os.ReadDir("terms")
if err != nil {
panic(err)
}
var failed []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".txt") {
continue
}
b, err := os.ReadFile("terms/" + e.Name())
if err != nil {
continue
}
input := map[string]any{
"document_text": string(b),
"document_type": "tos",
"service_name": strings.TrimSuffix(e.Name(), ".txt"),
// No prescan: legitimate, and there is then nothing to reconcile.
}
// ... POST /run with the Idempotency-Key, poll jobs/{job_id}, unmarshal into `r`.
var high []string
for _, f := range r.RedFlags {
if f.Severity == "High" {
high = append(high, f.Flag)
}
}
fmt.Printf("%-28s %-8s %s\n", e.Name(), r.Verdict, r.Grade)
if r.Verdict == "Avoid" || len(high) > 0 {
failed = append(failed, fmt.Sprintf("%s: %s (%s), %d high flag(s)",
e.Name(), r.Verdict, r.Grade, len(high)))
}
}
if len(failed) > 0 {
fmt.Fprintln(os.Stderr, "terms-gate:\n "+strings.Join(failed, "\n "))
os.Exit(1)
}
fmt.Println("terms-gate: every agreement passed")
// The gate, on top of the FinePrintDesk client from section 2. Read each file in
// terms/ into document_text, POST /run with an Idempotency-Key derived from the
// document, poll jobs/{job_id}, then:
//
// var report = /* parse data.output.output */;
// boolean bad = "Avoid".equals(report.verdict)
// || report.red_flags.stream().anyMatch(f -> "High".equals(f.severity));
// if (bad) System.exit(1);
//
// Sending no `prescan` at all is legitimate - there is then nothing to reconcile,
// and reconciliation.confirmed comes back empty. A truncated job is also a
// failure: the report you hold is a prefix.
for (var p : java.nio.file.Files.newDirectoryStream(
java.nio.file.Path.of("terms"), "*.txt")) {
String doc = java.nio.file.Files.readString(p);
String input = "{\"document_text\":" + jsonEscape(doc)
+ ",\"document_type\":\"tos\",\"service_name\":\"" + p.getFileName() + "\"}";
System.out.println(p.getFileName() + ": " + doc.length() + " characters to read");
// ... run, poll, parse, gate.
}
# terms_gate.rb - fail when a vendor's terms are unacceptable.
# Reuses the `call` helper from section 2.
require "digest"
failed = []
Dir.glob("terms/*.txt").sort.each do |path|
input = {
"document_text" => File.read(path),
"document_type" => "tos",
"service_name" => File.basename(path, ".txt").tr("-", " ")
# No prescan: legitimate, and there is then nothing to reconcile.
}
key = "fine-print-desk:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
# ... POST /run with that Idempotency-Key, then poll jobs/{job_id} as in section 5.
report = JSON.parse(job["output"]["output"])
high = report["red_flags"].select { |f| f["severity"] == "High" }.map { |f| f["flag"] }
puts format("%-28s %-8s %s", File.basename(path), report["verdict"], report["grade"])
high.each { |f| puts " HIGH #{f}" }
if report["verdict"] == "Avoid" || !high.empty?
failed << "#{File.basename(path)}: #{report['verdict']} (#{report['grade']})"
end
end
abort "terms-gate:\n #{failed.join("\n ")}" unless failed.empty?
puts "terms-gate: every agreement passed"
<?php
// terms-gate.php - fail when a vendor's terms are unacceptable.
// Reuses the `call` helper from section 2.
$failed = [];
foreach (glob("terms/*.txt") as $path) {
$input = [
"document_text" => file_get_contents($path),
"document_type" => "tos",
"service_name" => str_replace("-", " ", basename($path, ".txt")),
// No prescan: legitimate, and there is then nothing to reconcile.
];
$key = "fine-print-desk:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
// ... POST /run with that Idempotency-Key, then poll jobs/{job_id} as in section 5.
$report = json_decode($job["output"]["output"], true);
$high = array_column(array_filter($report["red_flags"],
fn($f) => $f["severity"] === "High"), "flag");
printf("%-28s %-8s %s\n", basename($path), $report["verdict"], $report["grade"]);
foreach ($high as $f) { echo " HIGH ", $f, PHP_EOL; }
if ($report["verdict"] === "Avoid" || $high) {
$failed[] = basename($path) . ": " . $report["verdict"] . " (" . $report["grade"] . ")";
}
}
if ($failed) {
fwrite(STDERR, "terms-gate:\n " . implode("\n ", $failed) . "\n");
exit(1);
}
echo "terms-gate: every agreement passed", PHP_EOL;
// The gate, on top of the FinePrintDesk client from section 2.
var failed = new List<string>();
foreach (var path in Directory.GetFiles("terms", "*.txt").OrderBy(p => p))
{
var input = new
{
document_text = File.ReadAllText(path),
document_type = "tos",
service_name = Path.GetFileNameWithoutExtension(path).Replace('-', ' '),
// No prescan: legitimate, and there is then nothing to reconcile.
};
// ... POST /run with the Idempotency-Key, poll jobs/{job_id}, parse data.output.output.
var verdict = report.GetProperty("verdict").GetString();
var grade = report.GetProperty("grade").GetString();
var high = report.GetProperty("red_flags").EnumerateArray()
.Where(f => f.GetProperty("severity").GetString() == "High")
.Select(f => f.GetProperty("flag").GetString())
.ToList();
Console.WriteLine($"{Path.GetFileName(path),-28} {verdict,-8} {grade}");
foreach (var f in high) Console.WriteLine($" HIGH {f}");
if (verdict == "Avoid" || high.Count > 0)
failed.Add($"{Path.GetFileName(path)}: {verdict} ({grade})");
}
if (failed.Count > 0)
{
Console.Error.WriteLine("terms-gate:\n " + string.Join("\n ", failed));
Environment.Exit(1);
}
Console.WriteLine("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.