← ISO Desk / API
Tokens

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

tasksendyou get back
reviewstandard, facts, records; context and question optionaltitle (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.
draftstandard, facts, records, target; context, question and review optionalstatus (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.

fieldtyperequiredmeaning
taskstringyes"review" or "draft".
standardstringyesThe profile key: iso-13485, iso-14971, iso-17025 or iso-15189.
factsstringyesThe check results, numbered items and gap groups, as JSON text (below). Build it with json.dumps(facts) or JSON.stringify(facts).
recordsstringyesThe 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.
contextstringnoThe owner's notes, as written - roles, sites, what happened since the records were exported. Up to about 3,000 characters.
questionstringnoA question to answer in the reply. Up to about 1,500 characters.
targetobjectdraft onlydocument_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 "").
reviewstringnoDraft lane only: an earlier review of this pack, as text. Up to about 6,000 characters.
retry_notestringnoOnly 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:

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

statuscodewhat to do
400validation_errorA field is missing or the wrong type. facts must be a JSON-encoded string, not an object; target is the only object field.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe 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.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA 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
}

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"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

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.

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

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}

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"]))
'

Costs

Invariants worth asserting