← HL7 Desk / API
Get a token

Driving HL7 Desk from your own code

Everything the web app's AI lanes do is available over HTTP. Send one HL7 v2 message and a task, get back one JSON object. The HL7 parser the browser runs for free — the delimiters out of MSH-1 and MSH-2, the field and subcomponent split, the escape decoding, the calendar validation, the resolved message structure, the segment grammar, the code tables, the identifier inventory — is not recomputed server-side. If you drive the API directly you should send your own prescan facts, because that object is what the model is held accountable to.

Base URL and headers

https://api.skillsafe.ai/v1/app-api

One header on every request:

The token is app-scoped, so the slug is not a header. There is no X-App-Slug header — a token minted for this app addresses this app and nothing else. The slug appears in exactly one place: the body of POST /guest.

The body of /estimate, /run and /run-stream is the input object itself, not wrapped in an input key. A wrapped body returns 200 while hiding task from the model, which is the most confusing failure available here: you get an answer, and it is for the wrong lane.

Handling patient data

An HL7 v2 message off a real interface is patient data. Two things follow, and neither is optional if you are driving this API against production traffic:

The response envelope

Every response has the same two shapes. Branch on error.code, never on the message text — messages are for humans and will change.

// success
{"ok": true, "data": { ... }}

// failure
{"ok": false, "error": {"code": "validation_error", "message": "...", "details": { ... }}}

Error codes

codeHTTPWhat to do
unauthorized401The token is missing, malformed or expired. Mint a new one from the token page.
forbidden403A guest token tried to run. /me and /estimate work for guests; /run and /run-stream need a personal token.
payment_required402The balance is below min_credits for this lane. /estimate is free, so check it before submitting.
validation_error400The body is not the shape the app expects. Most often task is missing or is not one of the four lane ids, or the input was wrapped in an input key — it must be the input object itself.
not_found404The job id does not exist, or belongs to another subject.
rate_limited429Back off and retry. Do not tight-loop; the limit is shared.
internal_error500Retry once with the same Idempotency-Key. Reusing the key is what stops a retry becoming a second charge.

Step 1 — a tiny client

Everything below uses this one helper. It does the single thing that matters: it reads the envelope and raises on ok: false, because an error response is still HTTP-shaped JSON and ignoring it turns a 402 into a confusing KeyError three lines later.

Step 2 — get a token

The friendly route is the token page, which shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one. Programmatically, POST /guest is the whole story:

A guest token is enough for /me and /estimate. Running any of the four lanes is metered and needs a personal token, which comes from signing in on the token page.

Step 3 — who am I, and can I afford this

GET /me returns subject_type (user or guest) and, for a signed-in user, credits. Compare that against min_credits from step 5 before submitting: a 402 after submit is a failure of your client, not of the user.

Step 4 — the input object

task comes first, because it selects the lane and therefore the entire shape of body in the reply.

fieldtyperequiredmeaning
taskstringyesOne of decode, conformance, ack, fhir. If it is absent or unrecognised the prompt picks the closest lane and sets task_inferred: true in the reply rather than blending two contracts.
message_textstringyesThe whole HL7 v2 message. Segments separated by \r (the standard) or \n (what a log paste usually gives you) — both are accepted and the prescan reports which arrived.
profilestringyesThe conformance basis: hl7-v2-generic, ihe-pam, us-core-adt or unknown. Steers the conformance and ACK lanes.
receiver_notestringnoFree text — what the receiving system actually said. This is the single most useful field you can populate: "rejected with segment sequence error" turns a general review into a diagnosis.
deidentifiedbooleannoTrue when the message you are sending has had its identifiers replaced. Tells the prompt not to treat stable pseudonyms as data-quality defects.
prescanobjectyesThe parser facts. Send {"ok": false} if you have none — the lanes still work, they simply have nothing to be reconciled against. See below.
prior_conformanceobjectnoThe conformance lane's own verdict and findings, for the ACK and FHIR lanes. This is what the web app's handoff button sends.
clip_notestringnoSay so if you truncated the message, and cut on whole-segment boundaries. A half segment parses into wrong values rather than failing.

Sending your own prescan

The reply's reconciliation array is required to carry exactly one entry per distinct flag_id you send in prescan.flags. So the prescan is not decoration: it is the mechanism that stops the model quietly ignoring a defect. The shape the app sends is:

{
  "ok": true,
  "message":  {"code": "ADT", "trigger": "A01", "trigger_label": "...",
               "declared_structure": "ADT_A01", "resolved_structure": "ADT_A01",
               "version": "2.5", "control_id": "...", "processing_id": "P",
               "sending_application": "...", "receiving_application": "...",
               "timestamp": "20260318142530", "timestamp_reading": "..."},
  "encoding": {"field": "|", "component": "^", "repetition": "~",
               "escape": "\\", "subcomponent": "&",
               "is_default": true, "terminator": "CR"},
  "counts":   {"segments": 7, "populated_fields": 49, ...},
  "segments": [{"name": "MSH", "position": 1, "fields": 12,
                "known": true, "purpose": "..."}],
  "grammar":  {"structure": "ADT_A01", "checked": true,
               "required": ["MSH", "EVN", "PID", "PV1"],
               "missing_required": [], "out_of_order": ["AL1"]},
  "key_fields": [{"location": "PID-5", "label": "Patient Name",
                  "raw": "...", "reading": "..."}],
  "phi":      {"count": 11, "locations": ["PID-3", "PID-5", ...]},
  "flags":    [{"id": "HL7-TYPE-DATE", "severity": "high",
                "location": "PID-7", "label": "..."}]
}

key_fields[].reading is the decoded expansion and is empty whenever there is nothing to expand - a plain string like a sending application name has no expansion, so its reading is "" while its raw carries the value. An empty reading never means the field is empty; only an empty raw does.

If you are building your own prescan, the one thing worth copying is how the structure is resolved: HL7 groups many trigger events onto one message structure, so A04, A08 and A13 all use ADT_A01 and A28 and A31 use ADT_A05. Deriving it as message code plus trigger event invents names like ADT_A08 that no published grammar matches.

Step 5 — estimate, free

POST /estimate creates no job, spends nothing, and is the authoritative check that your input shape and the model binding are both right. hold_credits is a reservation sized for the full output cap, not the price; the charge is usually far lower. The hold differs per lane, so re-estimate when you change task.

If the balance sits between min_credits and hold_credits the run still executes with a reduced output cap and the done event carries "truncated": true. Surface that rather than presenting a clipped answer as complete.

Step 6 — run and poll

POST /run returns a job_id; poll GET /jobs/{id} until status is terminal. data.output.output is a string containing the one JSON object the prompt returns — parse it a second time.

Always send an Idempotency-Key. Hash the task, the message and an attempt counter. A network blip or an automatic reformat retry must reuse the key derived from the same input, or you pay twice for one answer.

Step 7 — run-stream, for progress

POST /run-stream is the same call over server-sent events. Accumulate the delta events' text, then parse the concatenation. Keep whatever arrived if the stream dies: the web app parses a truncated reply and renders the sections that made it, rather than discarding an answer the user paid for.

The output contract, lane by lane

Every lane returns the same outer envelope and differs only in body. The envelope:

{
  "task": "decode | conformance | ack | fhir",
  "task_inferred": false,
  "title": "...",
  "verdict": "conformant | minor-issues | non-conformant | unparseable",
  "summary": "...",
  "assumptions": [],
  "open_questions": [],
  "findings": [{"id": "HD-001", "severity": "critical | high | medium | low",
               "location": "PID-7", "title": "...", "why": "...", "fix": "..."}],
  "reconciliation": [{"flag_id": "...", "status": "confirmed | noted | set-aside | superseded",
                      "note": "..."}],
  "next_lane": {"lane": "...", "reason": "..."},
  "body": { ... }
}

verdict describes the message in every lane, including fhir — not the quality of the answer. That is what lets four lanes share one envelope and one history record.

Every location the model emits is an HL7 coordinate in the message you sent: a segment name, SEG-n, SEG-n.c, line N, or the literal message. The web app checks each one against the parsed message and marks any that does not exist; if you are consuming the API, do the same — a cited field the message does not contain is the shape an invented finding takes.

task: "decode" — Decode (Read stage)

What the message says, segment by segment.

Request:

{
  "task": "decode",
  "message_text": "<the whole HL7 v2 message>",
  "profile": "ihe-pam",
  "receiver_note": "",
  "deidentified": false,
  "prescan": { ... }
}

Reply:

{
  "task": "decode",
  "task_inferred": false,
  "title": "ADT^A01 admit from EPICADT to IEENGINE",
  "verdict": "non-conformant",
  "summary": "...",
  "assumptions": [],
  "open_questions": [],
  "findings": [
    {"id": "HD-001", "severity": "high", "location": "PID-7",
     "title": "...", "why": "...", "fix": "..."}
  ],
  "reconciliation": [
    {"flag_id": "HL7-TYPE-DATE", "status": "confirmed", "note": ""}
  ],
  "next_lane": {"lane": "conformance", "reason": "..."},
  "body": {
    "message_kind": "...",
    "narrative": "...",
    "segment_readings": [
      {"location": "MSH", "label": "Message Header", "reading": "..."}
    ],
    "notable_fields": [
      {"location": "PV1-2", "label": "Patient Class",
       "reading": "...", "significance": "..."}
    ],
    "downstream_note": "..."
  }
}

task: "conformance" — Conformance (Verify stage)

Nine check areas, plus a corrected message.

Request:

{
  "task": "conformance",
  "message_text": "<the whole HL7 v2 message>",
  "profile": "ihe-pam",
  "receiver_note": "the engine rejected this with 'segment sequence error'",
  "deidentified": false,
  "prescan": { ... }
}

Reply:

{
  "task": "conformance",
  ... the same envelope ...
  "body": {
    "standard_basis": "...",
    "checks": [
      {"area": "framing and delimiters", "status": "pass", "note": "..."},
      {"area": "message header",         "status": "pass", "note": "..."},
      {"area": "segment grammar",        "status": "fail", "note": "..."},
      {"area": "required fields",        "status": "pass", "note": "..."},
      {"area": "data types",             "status": "fail", "note": "..."},
      {"area": "code tables",            "status": "fail", "note": "..."},
      {"area": "cross-field consistency","status": "fail", "note": "..."},
      {"area": "identity and identifiers","status": "pass","note": "..."},
      {"area": "profile-specific",       "status": "warn", "note": "..."}
    ],
    "corrected_message": "MSH|^~\\&|...\nEVN|...\n...",
    "correction_notes": [
      {"location": "PID-8", "change": "..."}
    ]
  }
}

task: "ack" — ACK (Respond stage)

The acknowledgment the receiver should return.

Request:

{
  "task": "ack",
  "message_text": "<the whole HL7 v2 message>",
  "profile": "ihe-pam",
  "receiver_note": "",
  "deidentified": false,
  "prescan": { ... },
  "prior_conformance": {
    "verdict": "non-conformant",
    "standard_basis": "...",
    "findings": [{"id": "HD-001", "severity": "high",
                  "location": "PID-7", "title": "..."}]
  }
}

Reply:

{
  "task": "ack",
  ... the same envelope ...
  "body": {
    "ack_code": "AE",
    "ack_code_reason": "...",
    "ack_message": "MSH|^~\\&|IEENGINE|MERCYGEN|EPICADT|MERCYGEN|...||ACK|NEWID|P|2.5\nMSA|AE|MSG00001|...\nERR|...",
    "err_segments": [
      {"location": "PID-7", "hl7_error_code": "102",
       "hl7_error_text": "Data type error", "severity": "E",
       "user_message": "..."}
    ],
    "sender_action": "...",
    "receiver_action": "..."
  }
}

task: "fhir" — FHIR (Produce stage)

The same message as FHIR R4, with a mapping table.

Request:

{
  "task": "fhir",
  "message_text": "<the whole HL7 v2 message>",
  "profile": "us-core-adt",
  "receiver_note": "",
  "deidentified": true,
  "prescan": { ... }
}

Reply:

{
  "task": "fhir",
  ... the same envelope ...
  "body": {
    "bundle_type": "transaction",
    "resources": [
      {"resource_type": "Patient", "purpose": "...",
       "from_locations": ["PID-3", "PID-5", "PID-7", "PID-8"]}
    ],
    "mapping_rows": [
      {"hl7_location": "PID-3.1", "hl7_label": "Patient Identifier List",
       "fhir_path": "Patient.identifier.value",
       "transform": "direct", "note": ""}
    ],
    "bundle_json": "{\n  \"resourceType\": \"Bundle\", ...\n}",
    "unmapped": [
      {"location": "EVN-5", "why": "..."}
    ]
  }
}

Lane-specific rules worth knowing before you consume the output

What this API will not do