Drive ISO Desk from your own code
ISO Desk checks the JSON records of an ISO readiness pack - scope intake, document register, CAPA
records, design and risk traceability, supplier controls, QMSR transition evidence and the evidence
manifest - under one of four profiles: iso-13485, iso-14971,
iso-17025 or iso-15189. In the page, a JavaScript port of the
standard-library scripts bundled with the iso-standards-readiness agent skill runs on every record
with the same JSON report and exit status as the Python. The same scripts run on your machine
(python3 scripts/check_capa.py capa.json), and the page's "Copy commands" button gives
you the exact lines.
The metered lanes read the check findings (as the facts string) and a compact copy of
the records: review writes a draft evidence review for authorized human assessment;
draft drafts one missing controlled document with its document-register row. Neither
opens your evidence files, and nothing either lane writes is a certificate, an accreditation, a
compliance determination or an audit result.
Two lanes: the task field
| task | send | you get back |
|---|---|---|
review | standard, facts, records; context and question optional | title (starting "Draft evidence review for authorized human assessment"), status (blocked, gaps_to_close, ready_for_human_review), declared, a response to every B and P item, findings (refs, record, process, risk, evidence, action, owner_role; at most 25), sampling, unresolved_decisions, next_decision, boundaries, open_questions and assumptions. |
draft | standard, facts, records, target; context, question and review optional | status (drafted, drafted_with_gaps, cannot_draft), document, eight fixed sections, a register_entry (status draft, approval pending), to_fill, links_to_pack, open_questions and assumptions. |
Input fields
Every field is a string except target, an object of three strings. facts is JSON text, not an object.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "review" or "draft". |
standard | string | yes | The profile key: iso-13485, iso-14971, iso-17025 or iso-15189. |
facts | string | yes | The check results, numbered items and gap groups, as JSON text (below). Build it with json.dumps(facts) or JSON.stringify(facts). |
records | string | yes | The supplied records as compact JSON text, one block per slot headed ### <slot> (<tool>). The page sends at most about 36,000 characters in total; long arrays keep their first items and end with a marker saying how many items were not sent. |
context | string | no | The owner's notes, as written - roles, sites, what happened since the records were exported. Up to about 3,000 characters. |
question | string | no | A question to answer in the reply. Up to about 1,500 characters. |
target | object | draft only | document_type (procedure, work-instruction, quality-manual-section, plan), topic (what the document must control; the page requires it, up to about 400 characters) and domain (one of the profile's process domains, or ""). |
review | string | no | Draft lane only: an earlier review of this pack, as text. Up to about 6,000 characters. |
retry_note | string | no | Only on a reformat retry. |
The facts string
The page computes facts from the skill's checks; you normally do not write it by hand.
Build it from the skill's own script output, or copy facts_sent from "Download .json"
after a page run. Its keys:
profile:key,label,assurance_lane,process_domains.records: one entry per record supplied -slot(scope,register,capa,trace,supplier,qmsr,manifest),tool(the skill script),exit(0 no structural finding, 1 findings, 2 unreadable or invalid input),result,metrics,input_error,finding_count,blocker_count.not_supplied: the slots with no record.items: what the reply must answer one by one -B1,B2, ... are check findings of severityblockerand input errors;P1,P2, ... are the page's own checks, each quoting a rule of the skill. Each hasid,kind(check,input,page),slot,code,severity,path,text. At most 80 are sent;items_not_sentcounts the rest.gap_groups:G1,G2, ... - the findings of severitygap, grouped by record and code, withcount, up to six samplepathsand the firstmessage.domains: the evidence manifest's gap view against the profile's process domains -domain,status(not-assessed,evidence-missing,evidence-incomplete,evidence-present-for-human-review),entry_ids. Empty without a manifest.purpose: the manifest'saudit_context.purpose, ornull.browser_status:blocked(a blocker or an input error),gaps(gap findings, or a page check of medium or high severity),complete_for_human_review.sent: what the page cut before sending -records_cut,chars_cut, andevidence_files_opened(alwaysfalse).
Worked example: one CAPA record
An iso-13485 pack with one CAPA register: CAPA-2026-014 is closed while its
effectiveness result is still pending (blocker B1, CLOSURE_BLOCKED), its
systemic-extent review is empty (gap group G1, TEXT_REQUIRED), and no
evidence manifest was supplied (page check P1, NO_MANIFEST, severity low).
The example is hand-built and short, and the result and metrics values are
only illustrations. For real values, copy facts_sent from a page run.
The facts, before json.dumps:
{
"profile": {
"key": "iso-13485",
"label": "ISO 13485 medical device quality management system",
"assurance_lane": "third-party certification",
"process_domains": [
"scope-and-roles",
"document-and-record-control",
"risk-management",
"design-and-development",
"supplier-controls",
"production-and-service",
"process-and-software-validation",
"identification-and-traceability",
"complaints-and-feedback",
"postmarket-and-vigilance",
"nonconformity-and-capa",
"internal-audit",
"management-review",
"training-and-competence",
"change-control"
]
},
"records": [
{
"slot": "capa",
"tool": "check_capa",
"exit": 1,
"result": "blocked",
"metrics": {
"capas": 1,
"closed": 1
},
"input_error": null,
"finding_count": 2,
"blocker_count": 1
}
],
"not_supplied": [
"scope",
"register",
"trace",
"supplier",
"qmsr",
"manifest"
],
"items": [
{
"id": "B1",
"kind": "check",
"slot": "capa",
"code": "CLOSURE_BLOCKED",
"severity": "blocker",
"path": "capas[0].effectiveness.result",
"text": "closed CAPA requires approved effective result"
},
{
"id": "P1",
"kind": "page",
"slot": "manifest",
"code": "NO_MANIFEST",
"severity": "low",
"path": "(record)",
"text": "No evidence manifest was supplied, so there is no domain gap view: every process domain of the profile is not-assessed, which is not a not-applicable determination."
}
],
"items_not_sent": 0,
"gap_groups": [
{
"id": "G1",
"slot": "capa",
"code": "TEXT_REQUIRED",
"count": 1,
"paths": [
"capas[0].investigation.systemic_extent_review"
],
"message": "requires non-placeholder text"
}
],
"domains": [],
"purpose": null,
"browser_status": "blocked",
"sent": {
"records_cut": [],
"chars_cut": 0,
"evidence_files_opened": false
}
}
The records string, shown pretty-printed here (the page sends the JSON compact, on one line after the heading):
### capa (check_capa)
{
"metadata": {
"register_id": "CAPA-REG-01",
"review_date": "2026-09-01",
"owner": "QA Manager",
"status": "approved",
"evidence": [
"CAPA-REG-01 export 2026-09-01"
],
"approval": {
"status": "approved",
"by": "QA Manager",
"date": "2026-09-01"
},
"source_refs": [
"SOP-CAPA-01 rev C"
]
},
"capas": [
{
"id": "CAPA-2026-014",
"owner": "QA Manager",
"status": "closed",
"source_event": "NC-2026-031",
"problem_statement": "Labels printed misaligned on line 2 for lot 2608",
"scope": "Line 2 label printer; lots 2601-2608 reviewed",
"correction_or_containment": "Lot 2608 quarantined and relabelled",
"evidence": [
"NC-2026-031"
],
"source_refs": [
"SOP-CAPA-01 rev C"
],
"approval": {
"status": "approved",
"by": "QA Manager",
"date": "2026-08-20"
},
"investigation": {
"method": "5 Whys",
"root_cause_or_justified_conclusion": "No calibration interval for the label printer",
"systemic_extent_review": "",
"owner": "QA Engineer",
"evidence": [
"INV-2026-031"
],
"approval": {
"status": "approved",
"by": "QA Manager",
"date": "2026-08-20"
}
},
"actions": [
{
"id": "ACT-1",
"description": "Add a printer calibration interval to WI-PRN-02",
"owner": "Production Engineer",
"due_date": "2026-07-15",
"implemented_date": "2026-07-10",
"evidence": [
"WI-PRN-02 rev B"
],
"approval": {
"status": "approved",
"by": "QA Manager",
"date": "2026-07-11"
}
}
],
"effectiveness": {
"plan": "Review label rejects on the next 3 lots",
"objective_acceptance_criteria": "Zero misaligned labels in 3 consecutive lots",
"owner": "QA Engineer",
"independent_reviewer": "Regulatory Affairs Lead",
"due_date": "2026-09-30",
"result": "pending",
"conclusion": "",
"review_date": "",
"evidence": [],
"approval": {
"status": "pending",
"by": "",
"date": ""
}
},
"closure_date": "2026-08-20",
"closure_summary": "Action ACT-1 implemented"
}
]
}
Build the body in code so facts goes as a string:
body = {
"task": "review", # or "draft"
"standard": "iso-13485",
"facts": json.dumps(facts, separators=(",", ":")), # a STRING, not an object
"records": "### capa (check_capa)\n" + json.dumps(capa, separators=(",", ":")),
"context": "CAPA-2026-014 was closed on 2026-08-20 by the QA Manager. ...",
"question": "Can CAPA-2026-014 stay closed?",
}
# JavaScript: facts: JSON.stringify(facts),
# records: "### capa (check_capa)\n" + JSON.stringify(capa)
Worked example: review
The request body, exactly as it goes on the wire (body.json in step 4):
{
"task": "review",
"standard": "iso-13485",
"facts": "{\"profile\":{\"key\":\"iso-13485\",\"label\":\"ISO 13485 medical device quality management system\",\"assurance_lane\":\"third-party certification\",\"process_domains\":[\"scope-and-roles\",\"document-and-record-control\",\"risk-management\",\"design-and-development\",\"supplier-controls\",\"production-and-service\",\"process-and-software-validation\",\"identification-and-traceability\",\"complaints-and-feedback\",\"postmarket-and-vigilance\",\"nonconformity-and-capa\",\"internal-audit\",\"management-review\",\"training-and-competence\",\"change-control\"]},\"records\":[{\"slot\":\"capa\",\"tool\":\"check_capa\",\"exit\":1,\"result\":\"blocked\",\"metrics\":{\"capas\":1,\"closed\":1},\"input_error\":null,\"finding_count\":2,\"blocker_count\":1}],\"not_supplied\":[\"scope\",\"register\",\"trace\",\"supplier\",\"qmsr\",\"manifest\"],\"items\":[{\"id\":\"B1\",\"kind\":\"check\",\"slot\":\"capa\",\"code\":\"CLOSURE_BLOCKED\",\"severity\":\"blocker\",\"path\":\"capas[0].effectiveness.result\",\"text\":\"closed CAPA requires approved effective result\"},{\"id\":\"P1\",\"kind\":\"page\",\"slot\":\"manifest\",\"code\":\"NO_MANIFEST\",\"severity\":\"low\",\"path\":\"(record)\",\"text\":\"No evidence manifest was supplied, so there is no domain gap view: every process domain of the profile is not-assessed, which is not a not-applicable determination.\"}],\"items_not_sent\":0,\"gap_groups\":[{\"id\":\"G1\",\"slot\":\"capa\",\"code\":\"TEXT_REQUIRED\",\"count\":1,\"paths\":[\"capas[0].investigation.systemic_extent_review\"],\"message\":\"requires non-placeholder text\"}],\"domains\":[],\"purpose\":null,\"browser_status\":\"blocked\",\"sent\":{\"records_cut\":[],\"chars_cut\":0,\"evidence_files_opened\":false}}",
"records": "### capa (check_capa)\n{\"metadata\":{\"register_id\":\"CAPA-REG-01\",\"review_date\":\"2026-09-01\",\"owner\":\"QA Manager\",\"status\":\"approved\",\"evidence\":[\"CAPA-REG-01 export 2026-09-01\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-09-01\"},\"source_refs\":[\"SOP-CAPA-01 rev C\"]},\"capas\":[{\"id\":\"CAPA-2026-014\",\"owner\":\"QA Manager\",\"status\":\"closed\",\"source_event\":\"NC-2026-031\",\"problem_statement\":\"Labels printed misaligned on line 2 for lot 2608\",\"scope\":\"Line 2 label printer; lots 2601-2608 reviewed\",\"correction_or_containment\":\"Lot 2608 quarantined and relabelled\",\"evidence\":[\"NC-2026-031\"],\"source_refs\":[\"SOP-CAPA-01 rev C\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-08-20\"},\"investigation\":{\"method\":\"5 Whys\",\"root_cause_or_justified_conclusion\":\"No calibration interval for the label printer\",\"systemic_extent_review\":\"\",\"owner\":\"QA Engineer\",\"evidence\":[\"INV-2026-031\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-08-20\"}},\"actions\":[{\"id\":\"ACT-1\",\"description\":\"Add a printer calibration interval to WI-PRN-02\",\"owner\":\"Production Engineer\",\"due_date\":\"2026-07-15\",\"implemented_date\":\"2026-07-10\",\"evidence\":[\"WI-PRN-02 rev B\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-07-11\"}}],\"effectiveness\":{\"plan\":\"Review label rejects on the next 3 lots\",\"objective_acceptance_criteria\":\"Zero misaligned labels in 3 consecutive lots\",\"owner\":\"QA Engineer\",\"independent_reviewer\":\"Regulatory Affairs Lead\",\"due_date\":\"2026-09-30\",\"result\":\"pending\",\"conclusion\":\"\",\"review_date\":\"\",\"evidence\":[],\"approval\":{\"status\":\"pending\",\"by\":\"\",\"date\":\"\"}},\"closure_date\":\"2026-08-20\",\"closure_summary\":\"Action ACT-1 implemented\"}]}",
"context": "CAPA-2026-014 was closed on 2026-08-20 by the QA Manager. Lots 2609-2611 have been labelled since; reject data is in MES.",
"question": "Can CAPA-2026-014 stay closed?"
}
An abbreviated reply. B1 can only be confirmed or needs_owner,
never explained or dismissed, so the status is blocked; every B and P item is answered
once, and the findings cover B1, G1 and the confirmed P1:
{
"task": "review",
"title": "Draft evidence review for authorized human assessment - ISO 13485 certification lane",
"headline": "Blocked: CAPA-2026-014 is recorded as closed while its effectiveness result is still pending.",
"status": "blocked",
"declared": {
"standard": "iso-13485",
"assurance_lane": "third-party certification",
"purpose": "not declared",
"scope": "not declared (no scope intake supplied; the CAPA names line 2)"
},
"item_responses": [
{
"ref": "B1",
"stance": "confirmed",
"note": "capas[0].status is closed and closure_date is 2026-08-20, but effectiveness.result is pending with no approval."
},
{
"ref": "P1",
"stance": "confirmed",
"note": "Only the CAPA record was supplied, so no process domain has been assessed."
}
],
"findings": [
{
"refs": "B1",
"record": "capa",
"process": "nonconformity-and-capa",
"risk": "high",
"evidence": "capas[0].effectiveness.result = \"pending\", approval pending; capas[0].closure_date = \"2026-08-20\"",
"action": "Reopen CAPA-2026-014 until the planned check on the next 3 lots is concluded, reviewed and approved.",
"owner_role": "QA Manager"
},
{
"refs": "G1",
"record": "capa",
"process": "nonconformity-and-capa",
"risk": "medium",
"evidence": "capas[0].investigation.systemic_extent_review is empty",
"action": "Record whether other printers, lines or products share the missing calibration interval.",
"owner_role": "QA Engineer"
},
{
"refs": "P1",
"record": "manifest",
"process": "other",
"risk": "low",
"evidence": "not_supplied lists scope, register, trace, supplier, qmsr and manifest",
"action": "Supply an evidence manifest and a scope intake before a wider review.",
"owner_role": "[to fill: owner]"
}
],
"sampling": {
"reviewed": [
"CAPA register CAPA-REG-01: CAPA-2026-014"
],
"not_reviewed": [
"scope intake",
"document register",
"traceability",
"supplier controls",
"QMSR transition",
"evidence manifest",
"the evidence files themselves"
]
},
"unresolved_decisions": [
"Whether CAPA-2026-014 may remain closed is for the authorized CAPA approver, not this review."
],
"next_decision": {
"party": "QA Manager",
"decision": "Reopen CAPA-2026-014 or record an approved effectiveness conclusion."
},
"boundaries": [
"This review is not a certificate, accreditation, compliance determination, audit result or inspection outcome.",
"The checks never opened the evidence files themselves."
],
"open_questions": [
"Do the reject data for lots 2609-2611 meet the acceptance criteria?"
],
"assumptions": [
"The CAPA register export of 2026-09-01 is the current record."
]
}
Worked example: draft
The same pack and facts, asking for the missing procedure, with the review above passed as text:
{
"task": "draft",
"standard": "iso-13485",
"facts": "{\"profile\":{\"key\":\"iso-13485\",\"label\":\"ISO 13485 medical device quality management system\",\"assurance_lane\":\"third-party certification\",\"process_domains\":[\"scope-and-roles\",\"document-and-record-control\",\"risk-management\",\"design-and-development\",\"supplier-controls\",\"production-and-service\",\"process-and-software-validation\",\"identification-and-traceability\",\"complaints-and-feedback\",\"postmarket-and-vigilance\",\"nonconformity-and-capa\",\"internal-audit\",\"management-review\",\"training-and-competence\",\"change-control\"]},\"records\":[{\"slot\":\"capa\",\"tool\":\"check_capa\",\"exit\":1,\"result\":\"blocked\",\"metrics\":{\"capas\":1,\"closed\":1},\"input_error\":null,\"finding_count\":2,\"blocker_count\":1}],\"not_supplied\":[\"scope\",\"register\",\"trace\",\"supplier\",\"qmsr\",\"manifest\"],\"items\":[{\"id\":\"B1\",\"kind\":\"check\",\"slot\":\"capa\",\"code\":\"CLOSURE_BLOCKED\",\"severity\":\"blocker\",\"path\":\"capas[0].effectiveness.result\",\"text\":\"closed CAPA requires approved effective result\"},{\"id\":\"P1\",\"kind\":\"page\",\"slot\":\"manifest\",\"code\":\"NO_MANIFEST\",\"severity\":\"low\",\"path\":\"(record)\",\"text\":\"No evidence manifest was supplied, so there is no domain gap view: every process domain of the profile is not-assessed, which is not a not-applicable determination.\"}],\"items_not_sent\":0,\"gap_groups\":[{\"id\":\"G1\",\"slot\":\"capa\",\"code\":\"TEXT_REQUIRED\",\"count\":1,\"paths\":[\"capas[0].investigation.systemic_extent_review\"],\"message\":\"requires non-placeholder text\"}],\"domains\":[],\"purpose\":null,\"browser_status\":\"blocked\",\"sent\":{\"records_cut\":[],\"chars_cut\":0,\"evidence_files_opened\":false}}",
"records": "### capa (check_capa)\n{\"metadata\":{\"register_id\":\"CAPA-REG-01\",\"review_date\":\"2026-09-01\",\"owner\":\"QA Manager\",\"status\":\"approved\",\"evidence\":[\"CAPA-REG-01 export 2026-09-01\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-09-01\"},\"source_refs\":[\"SOP-CAPA-01 rev C\"]},\"capas\":[{\"id\":\"CAPA-2026-014\",\"owner\":\"QA Manager\",\"status\":\"closed\",\"source_event\":\"NC-2026-031\",\"problem_statement\":\"Labels printed misaligned on line 2 for lot 2608\",\"scope\":\"Line 2 label printer; lots 2601-2608 reviewed\",\"correction_or_containment\":\"Lot 2608 quarantined and relabelled\",\"evidence\":[\"NC-2026-031\"],\"source_refs\":[\"SOP-CAPA-01 rev C\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-08-20\"},\"investigation\":{\"method\":\"5 Whys\",\"root_cause_or_justified_conclusion\":\"No calibration interval for the label printer\",\"systemic_extent_review\":\"\",\"owner\":\"QA Engineer\",\"evidence\":[\"INV-2026-031\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-08-20\"}},\"actions\":[{\"id\":\"ACT-1\",\"description\":\"Add a printer calibration interval to WI-PRN-02\",\"owner\":\"Production Engineer\",\"due_date\":\"2026-07-15\",\"implemented_date\":\"2026-07-10\",\"evidence\":[\"WI-PRN-02 rev B\"],\"approval\":{\"status\":\"approved\",\"by\":\"QA Manager\",\"date\":\"2026-07-11\"}}],\"effectiveness\":{\"plan\":\"Review label rejects on the next 3 lots\",\"objective_acceptance_criteria\":\"Zero misaligned labels in 3 consecutive lots\",\"owner\":\"QA Engineer\",\"independent_reviewer\":\"Regulatory Affairs Lead\",\"due_date\":\"2026-09-30\",\"result\":\"pending\",\"conclusion\":\"\",\"review_date\":\"\",\"evidence\":[],\"approval\":{\"status\":\"pending\",\"by\":\"\",\"date\":\"\"}},\"closure_date\":\"2026-08-20\",\"closure_summary\":\"Action ACT-1 implemented\"}]}",
"context": "CAPA-2026-014 was closed on 2026-08-20 by the QA Manager. Lots 2609-2611 have been labelled since; reject data is in MES.",
"target": {
"document_type": "procedure",
"topic": "Verifying CAPA effectiveness before a CAPA is closed",
"domain": "nonconformity-and-capa"
},
"review": "Status: blocked. B1 confirmed: CAPA-2026-014 is closed with effectiveness.result pending. G1: systemic_extent_review is empty. P1 confirmed: no evidence manifest."
}
An abbreviated reply. The eight section headings are fixed and in this order; the register row is
a draft with approval pending and empty by and date, and
to_fill lists every placeholder as written. The page re-checks
register_entry with audit_document_records, where the placeholder fields fail closed
until a human fills them.
{
"task": "draft",
"headline": "Drafted a CAPA effectiveness verification procedure; the retention basis, related documents, training plan and approvals are still open.",
"status": "drafted_with_gaps",
"document": {
"title": "CAPA effectiveness verification before closure",
"document_type": "procedure",
"domain": "nonconformity-and-capa",
"purpose_line": "How CAPA effectiveness is verified and approved before closure."
},
"sections": [
{
"heading": "Purpose and scope",
"body": "Defines how the effectiveness of a CAPA is verified and approved before the CAPA is closed in CAPA-REG-01. Applies to every CAPA raised under SOP-CAPA-01."
},
{
"heading": "Roles and authority",
"body": "- **CAPA owner**: plans the check.\n- **Independent reviewer**: reviews the result.\n- **QA Manager**: approves closure."
},
{
"heading": "Definitions",
"body": "- **Effectiveness check**: the planned, objective check that the action worked."
},
{
"heading": "Procedure",
"body": "1. The CAPA owner records the plan and objective acceptance criteria. (CAPA owner)\n2. After the observation window, the owner records the result and conclusion. (CAPA owner)\n3. The independent reviewer reviews the result. (Independent reviewer)\n4. Only a result of `effective` with approval allows closure; otherwise the CAPA stays open or is reopened. (QA Manager)"
},
{
"heading": "Records",
"body": "The effectiveness section of each CAPA in CAPA-REG-01. Retention follows the organization's approved retention basis: [to fill: retention basis reference]."
},
{
"heading": "Interfaces and related documents",
"body": "SOP-CAPA-01 rev C; WI-PRN-02 rev B; [to fill: related document IDs]."
},
{
"heading": "Change and training impact",
"body": "New procedure. Training for CAPA owners and approvers: [to fill: training plan]."
},
{
"heading": "Approval",
"body": "| Role | Name | Status | Date |\n|---|---|---|---|\n| QA Manager | [to fill: name] | pending | [to fill: date] |"
}
],
"register_entry": {
"id": "[to fill: document ID]",
"title": "CAPA effectiveness verification before closure",
"document_type": "procedure",
"revision": "[to fill: revision]",
"status": "draft",
"effective_date": "",
"supersedes": "",
"change_summary": "New procedure for verifying CAPA effectiveness before closure.",
"training_impact": "CAPA owners and approvers need training before use.",
"owner": "QA Manager",
"evidence": [],
"approval": {
"status": "pending",
"by": "",
"date": ""
},
"source_refs": []
},
"to_fill": [
"[to fill: retention basis reference]",
"[to fill: related document IDs]",
"[to fill: training plan]",
"[to fill: name]",
"[to fill: date]",
"[to fill: document ID]",
"[to fill: revision]"
],
"links_to_pack": [
{
"ref": "B1",
"how": "Sets the rule that a CAPA closes only on an approved effective result."
},
{
"ref": "G1",
"how": "Gives the step where the systemic extent is recorded before closure."
}
],
"open_questions": [
"Which role is the independent reviewer for CAPAs owned by the QA Manager?"
],
"assumptions": [
"SOP-CAPA-01 rev C stays the parent CAPA procedure."
]
}
The output
One JSON object, serialised as a string at data.output.output. Keys in both lanes:
task, headline, status, open_questions,
assumptions. Review adds title, declared,
item_responses (ref, stance = confirmed | needs_owner |
explained | dismissed, note), findings, sampling,
unresolved_decisions, next_decision and boundaries. Draft
adds document, sections, register_entry, to_fill
and links_to_pack. Processes are named by the profile's domain keys, or
other.
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": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"iso-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. facts must be a JSON-encoded string, not an object; target is the only object field. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# 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="iso-desk"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # from https://iso-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 "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "iso-desk"
# from https://iso-desk.skillsafe.ai/tokens.html
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
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
method = "POST" if body is not None else "GET"
req = urllib.request.Request(f"{BASE}/{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
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"]
import { readFileSync, writeFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "iso-desk";
// Paste the token from https://iso-desk.skillsafe.ai/tokens.html here.
const TOKEN = "YOUR_TOKEN";
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(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 = "iso-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://iso-desk.skillsafe.ai/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)
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.*;
import java.nio.file.*;
public class IsoDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "iso-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);
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 = "iso-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://iso-desk.skillsafe.ai/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}"
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
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 = "iso-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];
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"])) {
$e = $payload["error"];
throw new RuntimeException($e["code"] . ": " . $e["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class IsoDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "iso-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 method = body is null ? HttpMethod.Get : HttpMethod.Post;
var req = new HttpRequestMessage(method, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
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");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"iso-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://iso-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; a run 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":"iso-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://iso-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 start a metered run.
import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest",
data=b'{"slug": "iso-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://iso-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 start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "iso-desk" }),
});
const guestToken = (await res.json()).data.token; // use it as TOKEN in step 1
// Open https://iso-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 start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", strings.NewReader(`{"slug":"iso-desk"}`))
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"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://iso-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 start a metered run.
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\":\"iso-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://iso-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 start a metered run.
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 = { slug: "iso-desk" }.to_json
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://iso-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 start a metered run.
$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" => "iso-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://iso-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 start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"iso-desk\"}",
System.Text.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());
3. Check the session and the balance
call me
# {"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 IsoDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra.
hold_credits is a reservation, not the price. min_credits is
the least balance that can start a run. What you pay is charged_credits, reported on the
finished job, usually far lower. The body is the input object itself, with no
{"input": ...} wrapper. /estimate does no input validation,
so send a JSON object and check its shape yourself: task equal to review or
draft, standard one of the four profile keys, a facts that is a
JSON string parsing to an object, a non-empty records string, every other value a string
- and, for draft, a target object with a non-empty topic.
# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("review", "draft")
assert b.get("standard") in ("iso-13485", "iso-14971", "iso-17025", "iso-15189")
assert all(isinstance(v, str) for k, v in b.items() if k != "target")
assert isinstance(json.loads(b["facts"]), dict) and b["records"].strip()
if b["task"] == "draft":
assert isinstance(b.get("target"), dict) and b["target"].get("topic", "").strip()
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.
INPUT = json.load(open("body.json")) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "draft")
assert INPUT.get("standard") in ("iso-13485", "iso-14971", "iso-17025", "iso-15189")
assert all(isinstance(v, str) for k, v in INPUT.items() if k != "target")
assert isinstance(json.loads(INPUT["facts"]), dict), "facts is a JSON string of an object"
assert INPUT["records"].strip(), "records is a non-empty string"
if INPUT["task"] == "draft":
assert isinstance(INPUT.get("target"), dict) and INPUT["target"].get("topic", "").strip()
est = call("estimate", INPUT)
print(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
const STANDARDS = ["iso-13485", "iso-14971", "iso-17025", "iso-15189"];
const fail = (m) => { throw new Error(m); };
if (!INPUT || typeof INPUT !== "object" || Array.isArray(INPUT)) fail("send a JSON object");
if (!["review", "draft"].includes(INPUT.task)) fail("task must be review or draft");
if (!STANDARDS.includes(INPUT.standard)) fail("unknown standard");
for (const [k, v] of Object.entries(INPUT)) {
if (k !== "target" && typeof v !== "string") fail(`${k} must be a string`);
}
if (typeof JSON.parse(INPUT.facts) !== "object") fail("facts is a JSON string of an object");
if (!INPUT.records.trim()) fail("records is a non-empty string");
if (INPUT.task === "draft" && !(INPUT.target && String(INPUT.target.topic || "").trim())) {
fail("draft needs target.topic");
}
const est = await call("estimate", INPUT);
console.log(est.model_alias, "reserves", est.hold_credits, "credits (not the price)");
bodyRaw, _ := os.ReadFile("body.json")
var input map[string]any // an object; target is the only non-string value
if err := json.Unmarshal(bodyRaw, &input); err != nil {
panic("body.json must be a JSON object: " + err.Error())
}
for k, v := range input {
if _, ok := v.(string); !ok && k != "target" {
panic(k + " must be a string")
}
}
if input["task"] != "review" && input["task"] != "draft" {
panic("task must be review or draft")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"].(string)), &facts); err != nil {
panic("facts must be a JSON string of an object")
}
estRaw, err := call("estimate", input)
if err != nil {
panic(err)
}
var est map[string]any
_ = json.Unmarshal(estRaw, &est)
fmt.Println(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
String input = Files.readString(Path.of("body.json")); // the input object itself
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(review|draft)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review or draft");
if (!input.contains("\"facts\"") || !input.contains("\"records\""))
throw new IllegalStateException("both lanes need facts and records");
// Check the rest (facts parses, draft has target.topic) with your JSON library.
System.out.println(call("estimate", input)); // hold_credits is a reservation, not the price
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "send a JSON object" unless INPUT.is_a?(Hash)
raise "task must be review or draft" unless %w[review draft].include?(INPUT["task"])
STANDARDS = %w[iso-13485 iso-14971 iso-17025 iso-15189]
raise "unknown standard" unless STANDARDS.include?(INPUT["standard"])
raise "fields are strings" unless INPUT.reject { |k, _| k == "target" }.values.all?(String)
raise "facts is a JSON string of an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
if INPUT["task"] == "draft"
raise "draft needs target.topic" unless INPUT.dig("target", "topic").to_s.strip != ""
end
est = call("estimate", INPUT)
puts "#{est['model_alias']} reserves #{est['hold_credits']} credits (not the price)"
<?php
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "draft"], true)) {
throw new Exception("send a JSON object with task review or draft");
}
foreach ($input as $k => $v) {
if ($k !== "target" && !is_string($v)) { throw new Exception("$k must be a string"); }
}
if (!is_array(json_decode($input["facts"] ?? "", true))) {
throw new Exception("facts is a JSON string of an object");
}
if ($input["task"] === "draft" && trim($input["target"]["topic"] ?? "") === "") {
throw new Exception("draft needs target.topic");
}
$est = call("estimate", $input);
echo $est["model_alias"], " reserves ", $est["hold_credits"], " credits (not the price)\n";
var input = File.ReadAllText("body.json"); // the input object itself
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "review" && lane != "draft") throw new Exception("task must be review or draft");
foreach (var p in root.EnumerateObject())
if (p.Name != "target" && p.Value.ValueKind != JsonValueKind.String)
throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // throws unless facts is JSON
var topic = lane == "draft"
? root.GetProperty("target").GetProperty("topic").GetString() : "n/a";
if (string.IsNullOrWhiteSpace(topic))
throw new Exception("draft needs target.topic");
var est = await IsoDesk.Call("estimate", JsonSerializer.Deserialize<JsonElement>(input));
Console.WriteLine(est); // hold_credits is a reservation, not the price
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, iso-desk:<lane>:<hash>:a<attempt>, so a retried
request returns the same job instead of billing a second run. The lane is in the key because a
review and a draft of the same pack are different runs. Use one key per distinct input: edited
facts, records, context, question, target or review are a new hash, and replaying an old key with a
different body is a 409. Any stable digest of the body works. Leave
retry_note out of the hash and bump the attempt instead.
# 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 run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')
KEY="iso-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-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=$(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
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"task\":\"review\",\"title\":\"Draft evidence review ...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' \
> reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"iso-desk:{INPUT['task']}:{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("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":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `iso-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
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 text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("iso-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
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"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output // the reply, as a string
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(review|draft)\".*", "$1");
String key = "iso-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) break;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(
// MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "iso-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = INPUT.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job = JSON.parse(res.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "iso-desk:" . $input["task"] . ":" . $digest . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)));
var key = $"iso-desk:{lane}:{hash[..16].ToLower()}:a1";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {token}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var runRes = await new HttpClient().SendAsync(req);
var started = await runRes.Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await IsoDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
// the reply, as a string
var outputString = job.GetProperty("output").GetProperty("output").GetString()!;
6. Or stream it
POST /run-stream takes the same body and headers, including the
Idempotency-Key with the lane in it, and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"task\":\"review\",\"title\":\"Draft evidence review"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(),
method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
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", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text || "";
} else if (line.startsWith("data: ") && event === "done") {
done = JSON.parse(line.slice(6));
}
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.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"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
],
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"] ?? "";
} elseif (str_starts_with($line, "data: ") && $event === "done") {
echo substr($line, 6), PHP_EOL;
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq,
HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta")
raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..])
.GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object serialised as a string. Parse it and check that task is the
lane you asked for. For review, list the findings; for draft, write the
sections out and put register_entry through the skill's
audit_document_records.py before it goes into your register - its placeholders are
meant to fail until a human fills them.
# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c 'import json;r=json.load(open("reply.json"));print(r["task"],r["status"],r["headline"])'
python3 -c '
import json
r = json.load(open("reply.json"))
if r["task"] == "review":
for f in r["findings"]: print("-", f["risk"], f["refs"], f["action"])
else:
with open("draft.md", "w") as fh:
for s in r["sections"]: fh.write("## " + s["heading"] + "\n\n" + s["body"] + "\n\n")
print("\n".join(r["to_fill"]))
'
reply = json.loads(job["output"]["output"])
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["status"], reply["headline"])
if reply["task"] == "review":
for f in reply["findings"]:
print("-", f["risk"], f["refs"], f["evidence"], "->", f["action"])
else:
with open("draft.md", "w") as fh:
for s in reply["sections"]:
fh.write(f"## {s['heading']}\n\n{s['body']}\n\n")
print("to fill:", reply["to_fill"])
const reply = JSON.parse(job.output.output);
if (reply.task !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.status, reply.headline);
if (reply.task === "review") {
for (const f of reply.findings) console.log("-", f.risk, f.refs, f.action);
} else {
const md = reply.sections.map((s) => `## ${s.heading}\n\n${s.body}\n`).join("\n");
writeFileSync("draft.md", md);
console.log("to fill:", reply.to_fill);
}
var reply map[string]any
_ = json.Unmarshal([]byte(jobOutput), &reply)
if reply["task"] != input["task"] {
panic("the model answered as another lane")
}
fmt.Println(reply["status"], reply["headline"])
if reply["task"] == "review" {
for _, f := range reply["findings"].([]any) {
m := f.(map[string]any)
fmt.Println("-", m["risk"], m["refs"], m["action"])
}
} else {
var md strings.Builder
for _, s := range reply["sections"].([]any) {
m := s.(map[string]any)
md.WriteString(fmt.Sprintf("## %v\n\n%v\n\n", m["heading"], m["body"]))
}
os.WriteFile("draft.md", []byte(md.String()), 0o644)
}
// With any JSON library, parse data.output.output (a string) into an object, then:
// reply.task must equal the task you sent;
// review -> reply.status, reply.item_responses[], reply.findings[].action
// draft -> reply.status, reply.sections[] (8), reply.register_entry, reply.to_fill[]
String out = job.replaceAll(
"(?s).*\"output\"\\s*:\\s*\\{\\s*\"output\"\\s*:\\s*(\".*?(?<!\\\\)\").*", "$1");
System.out.println(out.substring(0, Math.min(200, out.length())));
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["task"] == INPUT["task"]
puts reply["status"], reply["headline"]
if reply["task"] == "review"
reply["findings"].each { |f| puts "- #{f['risk']} #{f['refs']}: #{f['action']}" }
else
md = reply["sections"].map { |s| "## #{s['heading']}\n\n#{s['body']}\n" }.join("\n")
File.write("draft.md", md)
puts reply["to_fill"]
end
<?php
$reply = json_decode($job["output"]["output"], true);
if ($reply["task"] !== $input["task"]) {
throw new Exception("the model answered as another lane");
}
echo $reply["status"], " ", $reply["headline"], "\n";
if ($reply["task"] === "review") {
foreach ($reply["findings"] as $f) {
echo "- ", $f["risk"], " ", $f["refs"], " ", $f["action"], "\n";
}
} else {
$md = "";
foreach ($reply["sections"] as $s) {
$md .= "## " . $s["heading"] . "\n\n" . $s["body"] . "\n\n";
}
file_put_contents("draft.md", $md);
}
using var rdoc = JsonDocument.Parse(outputString); // data.output.output
var reply = rdoc.RootElement;
if (reply.GetProperty("task").GetString() != lane)
throw new Exception("the model answered as another lane");
Console.WriteLine($"{reply.GetProperty("status")} {reply.GetProperty("headline")}");
if (lane == "review")
{
foreach (var f in reply.GetProperty("findings").EnumerateArray())
Console.WriteLine($"- {f.GetProperty("refs")}: {f.GetProperty("action")}");
}
else
{
var md = new System.Text.StringBuilder();
foreach (var s in reply.GetProperty("sections").EnumerateArray())
md.Append($"## {s.GetProperty("heading")}\n\n{s.GetProperty("body")}\n\n");
File.WriteAllText("draft.md", md.ToString());
}
Costs
- The checks are free and run in the page; nothing is metered until you start a lane.
/estimateis free. It creates no job and returnshold_credits: a reservation held against your balance while the run executes, not the price.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold. - Sponsorship is off: every run is paid from the caller's own balance.
- Runs need a signed-in user token. A guest token can call
/meand/estimateonly; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key and its own charge.
Invariants worth asserting
- The reply is one JSON object whose
taskequals thetaskyou sent, with every key of that lane's contract present. - Review: every B and P item in
facts.itemsis answered once initem_responses, in id order, and no other id is; aBitem is onlyconfirmedorneeds_owner, never explained or dismissed. - Review: the status is no looser than the facts (any B item means
blocked; a G group, or a confirmed or needs_owner P item of medium or high severity, means at mostgaps_to_close); every G group and every confirmed or needs_owner item appears in therefsof a finding; the title starts "Draft evidence review for authorized human assessment". - Draft: the eight section headings in order - Purpose and scope, Roles and authority, Definitions, Procedure, Records, Interfaces and related documents, Change and training impact, Approval;
register_entryhas statusdraftand approvalpendingwith emptybyanddate;to_filllists every[to fill: ...]placeholder used;links_to_packrefs are ids from the facts. - Nothing claims compliance, conformity, certification, accreditation, audit readiness or readiness for inspection, and every number or date cited appears in
facts,records,context,questionorreview.