Drive GDPR Desk from your own code
The page does its free work in the browser: it reads the personal-data inventory (CSV, TSV or a Markdown table with a
header row) and your context notes, and works out each row's data categories (Art. 9, Art. 10, children, employees,
credentials, logs, non-production), its lawful basis and any Art. 9(2) condition, retention against the engineering
defaults, every location as EEA, adequate, US or third country, the DPIA criteria, which rights the notes describe,
flags GD-01..GD-17 and the rows grouped into processing activities. Its two paid lanes are available over HTTP. You
send one JSON object with task set to "audit" or "ropa", and the model replies
with one JSON object. The easiest way to build the facts string is the page itself:
PrivKit.buildInput(lane, PrivKit.analyze(inventory, context, {controller, question, today}), {handoff})
in privkit.js, which also runs under Node.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt. The output assesses the inventory as written and is not legal advice.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what the model does |
|---|---|
"audit" | Where the inventory breaks the GDPR: a verdict (ready, fix first, stop), every page flag confirmed or disputed, 1-15 findings with rows, the article, a word-for-word quote and the fix, the DPIA call (never below the page's reading) and the six data-subject rights. |
"ropa" | The Article 30(1) record of processing activities: one entry per activity with purpose, lawful basis, Art. 9 / Art. 10 condition, subjects, data categories, recipients, transfers and safeguards, retention, security and gaps; every inventory row placed exactly once; [placeholders] where the inventory is silent. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | audit or ropa |
controller | optional | The organisation responsible; the record writes [controller name] when it is empty. |
today | required | The as-of date, YYYY-MM-DD. |
inventory | required | The inventory rows as the page read them, one per line: R1: system | data | subjects | purpose | basis | retention | recipients | location | safeguard | security | dpa | notes (- for an empty cell). Up to 60 rows; a marker line says how many were cut. |
context | required (may be empty) | The context notes, numbered C1: ... by the page; past 6,000 characters the middle lines are dropped with a marker. |
facts | required | A JSON string: controller, today, rows[{id, system, data, subjects, purpose, basis_given, basis, art9_condition, categories[], retention, retention_kind, retention_days, location, countries[{name, status}], safeguard, security, dpa, recipients, notes}], dpia{level, triggers[], art35_3b, scale}, rights{access, rectification, erasure, restriction, portability, objection: {status, lines[]}}, breach, flags[{id, severity, rows[], text}], groups[{purpose, basis, rows[]}], completeness, counts, notes[]. |
question | optional | What you want to know; answered in summary. |
audit_handoff | ropa, optional | A JSON string of the audit the record follows: verdict, dpia, findings[{id, rows[], article, severity, issue, fix}]. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, task_note, headline, questions[], summary.
task: "audit"
verdict: ready|fix first|stop, dpia: required|recommended|not indicated, dpia_reasons[], flags[{flag, response: confirmed|disputed, note}] (one per page flag, in order), findings[{id, flag, rows[], article, severity: high|medium|low, issue, evidence, fix}] (1-15, F1.., sorted by severity), rights[{right, status: ready|gap|unknown, note}] (exactly 6, in order: access, rectification, erasure, restriction, portability, objection).
task: "ropa"
source (audit handoff|from the input), controller, activities[{id, name, purpose, legal_basis, art9_condition, subjects[], data_categories[], rows[], recipients[], transfers[{country, safeguard}], retention, security, gaps[]}] (1-15, P1..).
Worked examples
These are the page's own bundled examples (invented companies and inventories), with the input as app.js builds it and the saved model reply the page replays for free: an audit of a fitness app and its Article 30 record after the handoff, an audit of a staffing firm's HR systems, and a record for a B2B invoicing tool.
Audit: a fitness app with cycle data, a vendor in India and SHA-256 passwords
Input (each pasted text shown as its lines; facts shortened here):
{
"task": "audit",
"controller": "Brivamo GmbH",
"today": "2026-10-01",
"inventory": [
"R1: Accounts DB | email address, display name | app users | account login and service delivery | contract | until account deletion | - | eu-west-1 (Ireland) | - | AES-256 at rest | - | -",
"R2: Accounts DB | password | app users | account login | contract | until account deletion | - | eu-west-1 (Ireland) | - | SHA-256 | - | -",
"R3: Workouts DB | heart rate, sleep and menstrual cycle data | app users | personalised training plans | consent | indefinite | - | eu-west-1 (Ireland) | - | AES-256 at rest | - | consent checkbox in onboarding",
"R4: Routes | GPS route history | app users | route maps and route recommendations | legitimate interests | 5 years | Mapbox | United States | - | TLS | yes | -",
"R5: Product analytics | device ID, IP address, clickstream | app users | product analytics and personalised ads | legitimate interests | 26 months | Mixpanel | United States | SCCs | - | yes | -",
"... (12 lines in all)"
],
"context": [
"C1: Brivamo is a fitness and cycle-tracking app with 240,000 active users, most of them in Germany, France and Spain.",
"C2: Training plans are generated by a machine learning model from workouts, sleep and cycle data.",
"C3: Users can export their data as JSON from Settings.",
"C4: Users can edit their profile in the app at any time.",
"C5: Deleting the account in the app removes the profile within 30 days; backups roll off after 35 days.",
"... (8 lines in all)"
],
"facts": "(a JSON string; decoded: 12 rows - 2 special category, 0 criminal, 1 with no lawful basis, 3 with retention missing or indefinite, 1 third-country and 4 US; DPIA required (evaluation or scoring (profiling); systematic monitoring; sensitive or highly personal data; large scale; vulnerable data subjects; innovative technology); rights described: rectification, erasure, portability, objection; breach denied; 12 groups; Art. 30 completeness 81%; flags GD-01, GD-02, GD-04, GD-05, GD-06, GD-07, GD-11, GD-12, GD-13, GD-14, GD-15, GD-16, GD-17)",
"question": "Can we launch the cycle-tracking feature in Germany next month?"
}
Reply:
{
"task": "audit",
"task_note": "",
"verdict": "stop",
"dpia": "required",
"dpia_reasons": [
"evaluation or scoring (profiling): C2 training plans are generated by a machine learning model from workouts, sleep and cycle data",
"systematic monitoring: GPS route history (R4) and clickstream (R5)",
"sensitive or highly personal data: health and menstrual cycle data (R3), injuries (R9)",
"large scale: 240,000 active users (C1)",
"vulnerable data subjects: users aged 13-15 (R12)",
"innovative technology: machine learning model (C2)"
],
"flags": [
{
"flag": "GD-01",
"response": "confirmed",
"note": "R11 Staging DB has no lawful basis stated."
},
{
"flag": "GD-02",
"response": "confirmed",
"note": "R3 and R9 hold health data with no Art. 9(2) condition named; the R3 note \"consent checkbox in onboarding\" does not show explicit consent."
},
{
"flag": "GD-04",
"response": "confirmed",
"note": "R9 Coachvarra in India has an empty safeguard cell."
},
{
"flag": "GD-05",
"response": "confirmed",
"note": "R4 Mapbox in the United States has an empty safeguard cell."
},
{
"flag": "GD-06",
"response": "confirmed",
"note": "R3 and R10 are indefinite; R11 retention is empty."
},
{
"flag": "GD-07",
"response": "confirmed",
"note": "R5 keeps analytics for 26 months against the 13-month default."
},
{
"flag": "GD-11",
"response": "confirmed",
"note": "R9 security cell is empty."
},
{
"flag": "GD-12",
"response": "confirmed",
"note": "R2 passwords are noted as SHA-256."
},
{
"flag": "GD-13",
"response": "confirmed",
"note": "R9 dpa cell is \"no\"."
},
{
"flag": "GD-14",
"response": "confirmed",
"note": "R10 logs IP addresses and full request bodies with no masking noted."
},
{
"flag": "GD-15",
"response": "confirmed",
"note": "R11 is a copy of production users \"refreshed monthly from production\"."
},
{
"flag": "GD-16",
"response": "confirmed",
"note": "Access and restriction are not described; C7 only routes other requests to support."
},
{
"flag": "GD-17",
"response": "confirmed",
"note": "C8 states there is no written breach procedure."
}
],
"findings": [
{
"id": "F1",
"flag": "GD-01",
"rows": [
"R11"
],
"article": "Art. 6(1)",
"severity": "high",
"issue": "The st
... (shortened here; the page shows it whole)
Record: the same inventory, with the audit handed over
Input (with the audit handed over; texts shown as lines):
{
"task": "ropa",
"controller": "Brivamo GmbH",
"today": "2026-10-01",
"inventory": [
"R1: Accounts DB | email address, display name | app users | account login and service delivery | contract | until account deletion | - | eu-west-1 (Ireland) | - | AES-256 at rest | - | -",
"R2: Accounts DB | password | app users | account login | contract | until account deletion | - | eu-west-1 (Ireland) | - | SHA-256 | - | -",
"R3: Workouts DB | heart rate, sleep and menstrual cycle data | app users | personalised training plans | consent | indefinite | - | eu-west-1 (Ireland) | - | AES-256 at rest | - | consent checkbox in onboarding",
"R4: Routes | GPS route history | app users | route maps and route recommendations | legitimate interests | 5 years | Mapbox | United States | - | TLS | yes | -",
"R5: Product analytics | device ID, IP address, clickstream | app users | product analytics and personalised ads | legitimate interests | 26 months | Mixpanel | United States | SCCs | - | yes | -",
"... (12 lines in all)"
],
"context": [
"C1: Brivamo is a fitness and cycle-tracking app with 240,000 active users, most of them in Germany, France and Spain.",
"C2: Training plans are generated by a machine learning model from workouts, sleep and cycle data.",
"C3: Users can export their data as JSON from Settings.",
"C4: Users can edit their profile in the app at any time.",
"C5: Deleting the account in the app removes the profile within 30 days; backups roll off after 35 days.",
"... (8 lines in all)"
],
"facts": "(a JSON string; decoded: 12 rows - 2 special category, 0 criminal, 1 with no lawful basis, 3 with retention missing or indefinite, 1 third-country and 4 US; DPIA required (evaluation or scoring (profiling); systematic monitoring; sensitive or highly personal data; large scale; vulnerable data subjects; innovative technology); rights described: rectification, erasure, portability, objection; breach denied; 12 groups; Art. 30 completeness 81%; flags GD-01, GD-02, GD-04, GD-05, GD-06, GD-07, GD-11, GD-12, GD-13, GD-14, GD-15, GD-16, GD-17)",
"question": "Can we launch the cycle-tracking feature in Germany next month?",
"audit_handoff": "{\"verdict\":\"stop\",\"dpia\":\"required\",\"findings\":[{\"id\":\"F1\",\"rows\":[\"R11\"],\"article\":\"Art. 6(1)\",\"severity\":\"high\",\"issue\":\"The staging copy of the production users table has no lawful basis, so processing it for QA testing is unlawful as written.\",\"fix\":\"Stop copying production data into staging, or record a lawful bas..."
}
Reply:
{
"task": "ropa",
"task_note": "",
"source": "audit handoff",
"controller": "Brivamo GmbH",
"activities": [
{
"id": "P1",
"name": "User accounts and login",
"purpose": "account login and service delivery; account login",
"legal_basis": "contract",
"art9_condition": "",
"subjects": [
"app users"
],
"data_categories": [
"email address, display name",
"password"
],
"rows": [
"R1",
"R2"
],
"recipients": [],
"transfers": [],
"retention": "until account deletion (R1, R2)",
"security": "AES-256 at rest (R1); SHA-256 (R2)",
"gaps": [
"F4 (Art. 32(1)): passwords in R2 are stored with SHA-256, a fast hash; re-hash with Argon2id or bcrypt and update the R2 security cell"
]
},
{
"id": "P2",
"name": "Personalised training plans",
"purpose": "personalised training plans",
"legal_basis": "consent",
"art9_condition": "[missing]",
"subjects": [
"app users"
],
"data_categories": [
"heart rate, sleep and menstrual cycle data (health data, Art. 9)"
],
"rows": [
"R3"
],
"recipients": [],
"transfers": [],
"retention": "[retention period] (R3 says indefinite)",
"security": "AES-256 at rest",
"gaps": [
"F2 (Art. 9(2)): no Art. 9(2) condition named for health data; record [Art. 9(2) condition] and, if explicit consent, a separate unticked health-data opt-in rather than the onboarding checkbox",
"F6 (Art. 5(1)(e)): retention is indefinite; set [retention period] and enforce deletion",
"DPIA required (profiling by a machine learning model, sensitive data, large scale); reference it in this record"
]
},
{
"id": "P3",
"name": "Route maps and recommendations",
"purpose": "route maps and route recommendations",
"legal_basis": "legitimate interests",
"art9_condition": "",
"subjects": [
"app users"
],
"data_categories": [
"GPS route history (location)"
],
"rows": [
"R4"
],
"recipients": [
"Mapbox"
],
"transfers": [
{
"country": "United States",
"safeguard": "[missing]"
}
],
"retention": "5 years",
"security": "TLS",
"gaps": [
"F5 (Art. 46): no transfer mechanism named for Mapbox in the United States; record [transfer safeguard] (DPF certification or SCCs)"
]
},
{
"id": "P4",
"name": "Product analytics and personalised ads",
... (shortened here; the page shows it whole)
Audit: a staffing firm's HR systems, with an instruction hidden in the notes
Input (texts shown as lines):
{
"task": "audit",
"controller": "Dravenholt Staffing Ltd",
"today": "2026-10-01",
"inventory": [
"R1: Workday | name, home address, bank account, salary | employees | payroll and HR administration | contract | employment + 7 years | Workday Inc. | United States | DPF | encrypted at rest | yes | -",
"R2: Workday | national insurance number, tax code | employees | payroll tax reporting | legal obligation | employment + 7 years | Workday Inc. | United States | DPF | encrypted at rest | yes | -",
"R3: Absence tracker | sick leave notes and fit notes | employees | sickness absence management | legal obligation | 6 years | Sickvane Ltd | United Kingdom | - | encrypted at rest | yes | Art. 9(2)(b) employment law",
"R4: Recruiting ATS | CVs, interview notes | candidates | recruitment | consent | indefinite | Greenhouse | United States | - | SSO only | yes | -",
"R5: Background checks | criminal record check results | candidates for care roles | safeguarding checks for care roles | legal obligation | 6 months after decision | Clearvoss Vetting Ltd | United Kingdom | - | encrypted at rest | yes | required by law for regulated care roles",
"... (9 lines in all)"
],
"context": [
"C1: Dravenholt Staffing employs 1,800 people across care homes in Ireland and the Netherlands, plus head office in Dublin.",
"C2: Employees can ask HR for a copy of their personnel file; we answer within one month.",
"C3: Employees update their bank details and address in Workday self-service.",
"C4: Leavers' records are deleted 7 years after the end of employment; candidates are kept on file in case a role comes up.",
"C5: Laptop monitoring was rolled out last quarter to measure productivity; staff were asked to tick a consent box at login.",
"... (7 lines in all)"
],
"facts": "(a JSON string; decoded: 9 rows - 2 special category, 1 criminal, 0 with no lawful basis, 1 with retention missing or indefinite, 1 third-country and 3 US; DPIA required (evaluation or scoring (profiling); systematic monitoring; sensitive or highly personal data; vulnerable data subjects); rights described: access, rectification; breach described; 9 groups; Art. 30 completeness 97%; flags GD-02, GD-04, GD-05, GD-06, GD-09, GD-13, GD-15, GD-16)"
}
Reply:
{
"task": "audit",
"task_note": "",
"verdict": "stop",
"dpia": "required",
"dpia_reasons": [
"evaluation or scoring (profiling)",
"systematic monitoring",
"sensitive or highly personal data",
"vulnerable data subjects"
],
"flags": [
{
"flag": "GD-02",
"response": "confirmed",
"note": "R6 holds mental health data with consent as the Art. 6 basis and no Art. 9(2) condition named."
},
{
"flag": "GD-04",
"response": "confirmed",
"note": "R8 sends keystroke and screen activity to Keystrove in the Philippines with an empty safeguard cell."
},
{
"flag": "GD-05",
"response": "confirmed",
"note": "R4 Greenhouse in the United States has an empty safeguard cell."
},
{
"flag": "GD-06",
"response": "confirmed",
"note": "R4 retention is indefinite and C4 says candidates are kept on file in case a role comes up."
},
{
"flag": "GD-09",
"response": "confirmed",
"note": "R4, R6 and R8 rely on consent from candidates and employees; C5 shows consent collected by a tick box at login."
},
{
"flag": "GD-13",
"response": "confirmed",
"note": "R6 Peakon has dpa \"no\"."
},
{
"flag": "GD-15",
"response": "confirmed",
"note": "R9 notes say the full payroll extract is copied from production each quarter."
},
{
"flag": "GD-16",
"response": "confirmed",
"note": "The context describes access (C2) and rectification (C3) only."
}
],
"findings": [
{
"id": "F1",
"flag": "GD-02",
"rows": [
"R6"
],
"article": "Art. 9(2)",
"severity": "high",
"issue": "Mental health data is processed with no Art. 9(2) condition, so the processing is unlawful as written; consent from employees is unlikely to be the explicit, freely given consent Art. 9(2)(a) needs.",
"evidence": "mood scores, mental health questions",
"fix": "Write the Art. 9(2) condition the controller relies on in the R6 notes cell, [Art. 9(2) condition]; if none applies, collect the survey anonymously or stop collecting mental health answers."
},
{
"id": "F2",
"flag": "GD-04",
"rows": [
"R8"
],
"article": "Art. 46",
"severity": "high",
"issue": "Employee monitoring data is transferred to the Philippines, a third country without an adequacy decision, with no safeguard.",
"evidence": "Philippines",
"fix": "Sign SCCs with Keystrove and record them in the R8 saf
... (shortened here; the page shows it whole)
Record: a B2B invoicing SaaS
Input (texts shown as lines):
{
"task": "ropa",
"controller": "Invorando B.V.",
"today": "2026-10-01",
"inventory": [
"R1: App database | name, work email, company | customer users | providing the invoicing service | contract | until account closure + 30 days | AWS | eu-central-1 (Frankfurt) | - | AES-256 at rest, TLS 1.3 | - | -",
"R2: App database | password hash | customer users | login | contract | until account closure | AWS | eu-central-1 (Frankfurt) | - | Argon2id | - | -",
"R3: Invoices | invoice recipients' names, emails, postal addresses | our customers' clients | issuing invoices on our customers' behalf | contract | 10 years | AWS | eu-central-1 (Frankfurt) | - | AES-256 at rest | - | -",
"R4: Billing | card token, billing contact | customer admins | subscription billing | contract | 10 years | Stripe | Ireland, United States | SCCs | tokenised by Stripe | - | -",
"R5: Transactional email | email address, delivery events | customer users | sending invoices and account emails | contract | 45 days | Postmark | United States | SCCs | TLS | - | -",
"... (9 lines in all)"
],
"context": [
"C1: Invorando is a B2B invoicing tool used by about 3,000 small businesses in the EU.",
"C2: Customer admins can export all their data as CSV or JSON from Settings > Data.",
"C3: Users can correct their profile and company details in Settings.",
"C4: Account closure deletes the workspace 30 days later; invoices we must keep for tax are anonymised instead.",
"C5: Users can object to analytics from the privacy settings and unsubscribe from the newsletter in one click.",
"... (7 lines in all)"
],
"facts": "(a JSON string; decoded: 9 rows - 0 special category, 0 criminal, 0 with no lawful basis, 0 with retention missing or indefinite, 0 third-country and 3 US; DPIA recommended (sensitive or highly personal data); rights described: rectification, erasure, portability, objection; breach described; 9 groups; Art. 30 completeness 98%; flags GD-16)"
}
Reply:
{
"task": "ropa",
"task_note": "",
"source": "from the input",
"controller": "Invorando B.V.",
"activities": [
{
"id": "P1",
"name": "Invoicing service accounts",
"purpose": "providing the invoicing service",
"legal_basis": "contract",
"art9_condition": "",
"subjects": [
"customer users"
],
"data_categories": [
"name",
"work email",
"company"
],
"rows": [
"R1"
],
"recipients": [
"AWS"
],
"transfers": [],
"retention": "until account closure + 30 days",
"security": "AES-256 at rest, TLS 1.3",
"gaps": [
"No DPA with AWS recorded in the dpa column (Art. 28)."
]
},
{
"id": "P2",
"name": "Login credentials",
"purpose": "login",
"legal_basis": "contract",
"art9_condition": "",
"subjects": [
"customer users"
],
"data_categories": [
"password hash"
],
"rows": [
"R2"
],
"recipients": [
"AWS"
],
"transfers": [],
"retention": "until account closure",
"security": "Argon2id",
"gaps": [
"No DPA with AWS recorded in the dpa column (Art. 28).",
"Retention runs to account closure, while C4 says the workspace is deleted 30 days after closure; confirm the hash is deleted at closure."
]
},
{
"id": "P3",
"name": "Invoices issued for customers",
"purpose": "issuing invoices on our customers' behalf",
"legal_basis": "contract",
"art9_condition": "",
"subjects": [
"our customers' clients"
],
"data_categories": [
"invoice recipients' names",
"emails",
"postal addresses"
],
"rows": [
"R3"
],
"recipients": [
"AWS"
],
"transfers": [],
"retention": "10 years",
"security": "AES-256 at rest",
"gaps": [
"C6 says Invorando acts as processor for invoice recipients' data: record this activity under Art. 30(2) with each customer named as controller, not in the Art. 30(1) record.",
"The lawful basis is the customer's (controller's) decision; confirm [lawful basis per customer] rather than Invorando's contract.",
"No DPA with AWS (sub-processor) recorded in the dpa column (Art. 28(2) and (4))."
]
},
{
"id": "P4",
"name": "Subscription billing",
"purpose": "subscription billing",
"legal_basis": "contract",
"art9_condition": "",
"subjects": [
"customer admins"
],
"data_categories": [
"card token",
"bil
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js). For an audit: one answer per page flag, in the page's order, a disputed one with a reason; 1 to 15 findings numbered F1.., sorted high, medium, low, each citing real rows and an article, with every confirmed high or medium flag covered by a finding; the DPIA call never below the page's reading, with its reasons; the six rights in order, ready only where the notes describe the right; stop only with a high finding and ready only with no high or medium one. For a record: the source and the controller as given; activities numbered P1.. covering every inventory row exactly once; one lawful basis per activity, the one its rows state, or [missing]; an Art. 9 or Art. 10 condition, or [missing], wherever special-category or criminal-offence rows sit; every country outside the EEA listed as a transfer and no other, with a placeholder where no safeguard is written; a placeholder and a gap for missing retention; every recipient named; with an audit handoff, a gap wherever a finding's rows sit. In both, quotes are checked word for word and no figure the input, the page's reading or the skills do not contain may appear. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same input into the page.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://gdpr-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://gdpr-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// gdpr-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://gdpr-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://gdpr-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// GdprDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class GdprDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://gdpr-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://gdpr-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://gdpr-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://gdpr-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"gdpr-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "gdpr-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "gdpr-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "gdpr-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "gdpr-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "gdpr-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "gdpr-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "gdpr-desk" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "audit", "controller": "...", "today": "...", "inventory": "...", "context": "...", "facts": "..."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "audit", "controller": "...", "today": "...", "inventory": "...", "context": "...", "facts": "...", ...}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, controller, today, inventory, context, facts, question, ...}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses gdpr-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="gdpr-desk:audit:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "gdpr-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `gdpr-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "gdpr-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "gdpr-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "gdpr-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["headline"]].inspect
$key = "gdpr-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"gdpr-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream (ropa)
The same run as Server-Sent Events. ropa.json is a record-lane input, with audit_handoff when it follows an audit.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: gdpr-desk:ropa:$(shasum -a 256 ropa.json | cut -c1-16):a1" --data @ropa.json
rev = json.load(open("ropa.json")) # {"task": "ropa", "controller": "...", "today": "...", "inventory": "...", "context": "...", "facts": "...", "audit_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "gdpr-desk:ropa:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("ropa.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `gdpr-desk:ropa:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("ropa.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "gdpr-desk:ropa:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("ropa.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "gdpr-desk:ropa:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("ropa.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "gdpr-desk:ropa:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("ropa.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: gdpr-desk:ropa:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("ropa.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"gdpr-desk:ropa:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);