← ICE Card
API
Tokens

Run ICE Card from your own code

Information, not medical advice. ICE Card copies, orders and translates what a person wrote about their own health so a stranger can read it in an emergency. It never advises on treatment. The structure lane never adds anything the notes do not say - no medicine, dose, allergy, reaction, condition, implant, number or phone number that is not in the notes - and asks a question instead of guessing. A translation keeps every medicine name, dose and phone number exactly as written (and people's names, dates and numbers too). Check the printed card with your doctor or pharmacist.

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the card and checks it for free - it orders it the way the source skill asks (P0 severe allergies, high-alert medicines and implants that matter for scans or shocks; P1 the other medicines, conditions and implants; P2 the rest), picks the card type (child, adult, older adult, severe allergy) and checks what a first responder needs: a contact with a phone number, allergies with their reaction, medicines with a dose and how often, no error-prone abbreviations, no medicine from the class of a listed allergy, a recent update date. That is icscan.js, and every finding carries its rule id, line and fix. The run structures free notes into a clean card ("structure") or translates a finished card for a trip ("translate"); the page then holds the reply to the person's own notes (icreply.js): every rule answered, nothing on the card that the notes do not say, nothing changed in translation that must stay exact.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"ice-card"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, notes, facts) is missing or not a string. Send every field as a string; language may be "" for "structure".
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - "structure" (free notes or a rough sheet turned into a clean card the page can print: identity, allergies with reaction and severity, medicines with dose, how often and what for, conditions, implants, contacts and the person's own emergency instructions word for word, plus the questions to confirm before printing and anything left off) and "translate" (a finished card in the sheet format translated into language for a responder abroad, with the card's headings and one to three emergency phrases). The IC- rules belong to both. A missing or unknown task is answered as structure, with a caveat saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyes"structure" or "translate". The model answers that task and only that task.
notesyeswhat the person pasted: a card in the card sheet format or free notes in their own words. The page sends up to 12,000 characters - the medical summary, not whole letters. "translate" needs a card in the sheet format (run "structure" first to make one from free notes).
languagefor translatethe language to translate into, as a plain name ("French", "Spanish", "Chinese (Simplified)"); the page offers 17 and clips it to 40 characters. Send "" for "structure".
factsyesthe page's own reading of the same notes: every allergy (with its reaction and a severity level 1-4 when the words say it), medicine (name, dose, how often, what for, high-alert), condition, implant and contact, the top-of-card (P0) lines, and every finding as rule | severity | line | where | text. See the facts format.
retry_notenopresent only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise.

The card sheet format

notes is plain text. The sheet the page reads (and the one "structure" produces) has two parts:

Name: Margaret Ellis
Born: 1948-05-12
Blood type: A+
Doctor: Dr Okafor, Riverside Surgery, +44 20 7946 0321
Updated: 2024-02-10

Allergies:
- Penicillin: anaphylaxis

Medicines:
- Warfarin 3 mg once a day in the evening (atrial fibrillation)
- Metformin 500 mg twice a day with meals (type 2 diabetes)

Conditions:
- Atrial fibrillation

Contacts:
- David Ellis (son): +44 7700 900481

Free notes ("leo turner, he's 9 ... severe peanut allergy ... mum is Sara Turner 07700 900456") are fine for "structure": the page finds no sheet sections in them and the model builds the sheet. Every person, date and number on this page is invented for the samples.

The free checks

The page reads the sheet and checks it in the browser, for free, before any run:

The card is drawn as a wallet card (74 x 105 mm), an A4 sheet or large print, with a plain-text copy and a findings CSV - all free.

The facts format

facts is the page's own reading, written as plain text. The page builds it from the notes with icscan.js, and the model checks its reply against it: it answers every error and warn rule id it finds there with one prescan entry per id, keeps a class conflict on the card and raises it as a question, and the page then holds the reply to the same reading. So an API client should send its own facts in the page's reading line format - from icscan.js run under node (Build the input yourself), or written by your own code in the same form - not an empty string. The format, line by line:

<Name|Born|Sex|Blood type|Weight|Language|Doctor|School|Updated|Notes>: <as written - only the lines the sheet gives>
Read by the page: <n> allergies[ (no known allergies stated)], <n> medicines, <n> conditions, <n> implants, <n> contacts[; a severe (anaphylaxis-level) allergy - severe-allergy card].
- allergy | <allergen> | reaction: <reaction|-> | level <1-4|unknown>
- medicine | <name> | dose: <dose|-> | how often: <frequency|-> | for: <reason|->[ | high-alert]
- condition | <text>
- implant | <text>
- contact | <name> | <relation|-> | phone: <phone|->
Top of the card (P0): <Allergy: X - reaction; Takes Y; Implant: Z>.      (when there is one)
Translate into: <language>.                                              (translate only)
Findings (rule | severity | line | where | text):
- <rule id> | <error|warn|info> | line <n> | <item|-> | <title>[ (note)][ | the line as written]

Notes with no findings get the single line - none. The allergy level is 4 for anaphylaxis or life-threatening words (adrenaline, throat swelling), 3 for "severe", 2 for swelling or hives, 1 for a rash, itch or intolerance. A real one - the facts the page sends for its "older adult's card with problems" sample (Margaret Ellis, invented) on the "structure" lane:

Name: Margaret Ellis
Born: 1948-05-12
Blood type: A+
Language: English
Doctor: Dr Okafor, Riverside Surgery, +44 20 7946 0321
Updated: 2024-02-10
Read by the page: 2 allergies, 4 medicines, 2 conditions, 1 implant, 2 contacts; a severe (anaphylaxis-level) allergy - severe-allergy card.
- allergy | Penicillin | reaction: anaphylaxis | level 4
- allergy | Sticking plasters | reaction: - | level unknown
- medicine | Warfarin | dose: 3 mg | how often: once a day in the evening | for: atrial fibrillation | high-alert
- medicine | Metformin | dose: 500 mg | how often: twice a day with meals | for: type 2 diabetes
- medicine | Bisoprolol | dose: 5.0 mg | how often: qd | for: heart rate
- medicine | Amoxicillin | dose: 500 mg | how often: three times a day | for: chest infection, started this week
- condition | Atrial fibrillation
- condition | Type 2 diabetes
- implant | Pacemaker, fitted 2021
- contact | David Ellis | son | phone: +44 7700 900481
- contact | Ruth Barnes | neighbour | phone: -
Top of the card (P0): Allergy: Penicillin - anaphylaxis; Takes Warfarin; Implant: Pacemaker, fitted 2021.
Findings (rule | severity | line | where | text):
- IC-CLASS | error | line 16 | Amoxicillin | A listed medicine belongs to the class of a listed allergy (allergy: Penicillin (penicillin class)) | Amoxicillin 500 mg three times a day (chest infection, started this week)
- IC-NOPHONE | error | line 27 | Ruth Barnes | Contact without a phone number | Ruth Barnes (neighbour)
- IC-ALLERGYREACT | warn | line 10 | Sticking plasters | Allergy without its reaction | Sticking plasters
- IC-ABBREV | warn | line 15 | Bisoprolol | Error-prone abbreviation (qd) | Bisoprolol 5.0 mg qd (heart rate)
- IC-ABBREV | warn | line 15 | Bisoprolol | Error-prone abbreviation (trailing zero) | Bisoprolol 5.0 mg qd (heart rate)
- IC-HIGHALERT | info | line 13 | Warfarin | High-alert medicine (Warfarin) | Warfarin 3 mg once a day in the evening (atrial fibrillation)
- IC-IMPLANT | info | line 23 | Pacemaker, fitted 2021 | Implant that matters for scans and shocks (Pacemaker) | Pacemaker, fitted 2021

Rule ids the check uses, with their severity and area:

RuleSeverityAreaTitle
IC-NOCONTACTerrorcontactsNo emergency contact
IC-NOPHONEerrorcontactsContact without a phone number
IC-NONAMEwarnidentityNo name
IC-NOALLERGYwarnallergiesAllergies not stated
IC-ALLERGYREACTwarnallergiesAllergy without its reaction
IC-CLASSerrorallergiesA listed medicine belongs to the class of a listed allergy
IC-NODOSEwarnmedicinesMedicine without a dose
IC-NOFREQwarnmedicinesMedicine without how often
IC-ABBREVwarnmedicinesError-prone abbreviation
IC-DUPwarnmedicinesThe same medicine twice
IC-HIGHALERTinfomedicinesHigh-alert medicine
IC-IMPLANTinfoimplantsImplant that matters for scans and shocks
IC-NOBLOODinfoidentityBlood type not given
IC-STALEwarnidentityCard not updated for over a year
IC-NODATEinfoidentityNo update date
IC-LONGinfoidentityToo much for a wallet card

A point the check cannot see (a dose that reads oddly, a missing strength, a contact who may not be reachable abroad) comes back as a finding with the rule NEW.

The standard the model applies

The source skill - @huifer/emergency-card (huifer/wellally-health, MIT) - is the only standard:

House rules

The replies

Both lanes carry task, summary, card, findings (rule - an IC- id from the facts or NEW - severity high / medium / low, title, where, why, fix), prescan (rule, call, note) and caveats. The card has the same fields in both: name, born, sex, blood_type, weight, languages, doctor, school, updated, no_known_allergies (a boolean), allergies (each allergen, reaction, severity), medications (each name, dose, frequency, reason), conditions (strings), implants (each type, detail), contacts (each name, relation, phone) and notes. The rest differs by lane.

structure

The notes turned into a clean card the page can print. A field the notes do not give is left empty rather than guessed.

  1. summary - one or two sentences: who the card is for and what is at the top of it. No advice.
  2. card - severity is anaphylaxis, severe, moderate, mild or unknown; abbreviations are written out when their meaning is certain; updated is only a date the notes give; notes holds the person's own emergency instructions word for word.
  3. questions - what the person must confirm or add before printing (a missing phone number, an unclear dose, a class conflict, no update date), one question each.
  4. left_out - anything in the notes not put on the card, each item and why.
  5. findings, prescan and caveats.
{
  "task": "structure",
  "summary": "",
  "card": {"name": "", "born": "", "sex": "", "blood_type": "", "weight": "", "languages": "", "doctor": "", "school": "", "updated": "", "no_known_allergies": false, "allergies": [{"allergen": "", "reaction": "", "severity": "anaphylaxis"}], "medications": [{"name": "", "dose": "", "frequency": "", "reason": ""}], "conditions": [""], "implants": [{"type": "", "detail": ""}], "contacts": [{"name": "", "relation": "", "phone": ""}], "notes": ""},
  "questions": [""],
  "left_out": [{"item": "", "why": ""}],
  "findings": [],
  "prescan": [],
  "caveats": [""]
}

translate

The card in notes translated into language for a responder abroad.

  1. language and summary - one sentence in English: what was translated and anything that did not translate cleanly.
  2. card - reactions, frequencies, reasons, conditions, relationships, implant types and notes in the target language. Every medicine name stays exactly as written (with the local name in brackets only when the model is certain of it); every dose, number, date and phone number stays exactly; people's names stay exactly; the allergen is given in the target language with the original in brackets.
  3. labels - the card's headings in the target language: title (the "in case of emergency - medical information" heading), born, allergies, medicines, conditions, implants, contacts, blood_type, doctor, updated, no_known_allergies, notes, school.
  4. phrases - one to three short sentences a responder needs most, each text in the target language and english, built only from the card.
  5. findings, prescan and caveats (such as a term with no exact local equivalent).
{
  "task": "translate",
  "language": "",
  "summary": "",
  "card": {"name": "", "born": "", "sex": "", "blood_type": "", "weight": "", "languages": "", "doctor": "", "school": "", "updated": "", "no_known_allergies": false, "allergies": [{"allergen": "", "reaction": "", "severity": ""}], "medications": [{"name": "", "dose": "", "frequency": "", "reason": ""}], "conditions": [""], "implants": [{"type": "", "detail": ""}], "contacts": [{"name": "", "relation": "", "phone": ""}], "notes": ""},
  "labels": {"title": "", "born": "", "allergies": "", "medicines": "", "conditions": "", "implants": "", "contacts": "", "blood_type": "", "doctor": "", "updated": "", "no_known_allergies": "", "notes": "", "school": ""},
  "phrases": [{"text": "", "english": ""}],
  "findings": [],
  "prescan": [],
  "caveats": [""]
}

The page holds either reply to the person's own notes: every error and warning answered by rule id, no rule id the page did not report other than NEW, and a contact on the card when the notes give one. For "structure" it wants every medicine, allergen, condition, implant and contact name found in the notes, every number and phone number found in the notes, no "no known allergies" the notes never state, no advice, nothing from the notes dropped without a word in left_out, and the card re-checked: a class conflict or a contact without a phone number still on it must be raised in questions, and no error-prone abbreviation may be left. For "translate" it wants the language asked for, the same number of allergies, medicines, conditions, implants and contacts as the sheet, medicine names, doses, numbers, names, dates of birth and phone numbers kept exactly, the allergen kept in brackets, the text really translated (not more than half the phrases back unchanged), every heading in labels (notes and school only when the card has them), and at least one phrase with its English meaning. Each miss is shown next to the reply as a note.

Reading the reply

The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in icreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'translate' - no prose, no code fences." and a new attempt number in the key (see step 5). A reply's card prints as the emergency card: the page draws it as a wallet card, an A4 sheet or large print, with the P0 lines at the top, and a translated card uses the reply's labels as its headings. Step 5 prints the card, then the phrases (translate) or the questions and what was left out (structure). Have the person read every line before the card is printed or carried.

Structure, then translate

The two lanes chain. A "structure" reply's card is written back in the sheet format (IcScan.sheet(reply.card): the header lines, then Allergies:, Medicines:, Conditions:, Implants:, Contacts: and Notes:) and becomes the new notes. The page checks that sheet again for free; once the person has answered the questions, the same sheet goes to "translate" with a language and fresh facts built from it.

Worked sample - translating Sam's card into French

A clean card for travel: Sam Okoro (invented, like every person, doctor and phone number in the samples), type 1 diabetes on two insulins, no known allergies, two contacts. The check finds only the two IC-HIGHALERT notes (insulin goes to the top of the card), both info, so nothing needs a prescan answer. The input below is exactly what the page sends for the "translate" lane into French; for "structure" only task changes to "structure", language becomes "" and the Translate into: line leaves the facts. Input:

{
 "task": "translate",
 "notes": "Name: Sam Okoro\nBorn: 1991-08-30\nSex: male\nBlood type: O-\nLanguage: English\nDoctor: Dr Lindqvist, Harbour Health Centre, +44 131 496 0702\nUpdated: 2026-09-15\n\nAllergies:\n- No known allergies\n\nMedicines:\n- Insulin glargine 22 units once a day at bedtime (type 1 diabetes)\n- Insulin aspart by carbohydrate count with each meal (type 1 diabetes)\n\nConditions:\n- Type 1 diabetes since 2004 - may have a hypo: if confused or drowsy, check blood glucose\n\nContacts:\n- Ada Okoro (partner): +44 7700 900612\n- Chidi Okoro (brother): +44 7700 900147",
 "language": "French",
 "facts": "Name: Sam Okoro\nBorn: 1991-08-30\nSex: male\nBlood type: O-\nLanguage: English\nDoctor: Dr Lindqvist, Harbour Health Centre, +44 131 496 0702\nUpdated: 2026-09-15\nRead by the page: 0 allergies (no known allergies stated), 2 medicines, 1 condition, 0 implants, 2 contacts.\n- medicine | Insulin glargine | dose: 22 units | how often: once a day at bedtime | for: type 1 diabetes | high-alert\n- medicine | Insulin aspart | dose: by carbohydrate count | how often: with each meal | for: type 1 diabetes | high-alert\n- condition | Type 1 diabetes since 2004 - may have a hypo: if confused or drowsy, check blood glucose\n- contact | Ada Okoro | partner | phone: +44 7700 900612\n- contact | Chidi Okoro | brother | phone: +44 7700 900147\nTop of the card (P0): Takes Insulin glargine; Takes Insulin aspart.\nTranslate into: French.\nFindings (rule | severity | line | where | text):\n- IC-HIGHALERT | info | line 13 | Insulin glargine | High-alert medicine (Insulin) | Insulin glargine 22 units once a day at bedtime (type 1 diabetes)\n- IC-HIGHALERT | info | line 14 | Insulin aspart | High-alert medicine (Insulin) | Insulin aspart by carbohydrate count with each meal (type 1 diabetes)"
}

Reply - the real saved reply the page shows for this sample, abbreviated: long strings are cut and end in ..., and phrases keeps only its first two entries (the full reply has 3 phrases, 1 caveat, 2 medicines and 2 contacts). Text in French is shown with \u escapes, as JSON.stringify with ASCII-only output writes it (\u00e9 is é, \u00e8 is è, \u00e0 is à):

{
 "task": "translate",
 "language": "French",
 "summary": "Translated Sam Okoro's emergency card into French; medicine names, doses, dates, phone numbers and names are ...",
 "card": {
  "name": "Sam Okoro",
  "born": "1991-08-30",
  "sex": "masculin",
  "blood_type": "O-",
  "weight": "",
  "languages": "anglais",
  "doctor": "Dr Lindqvist, Harbour Health Centre, +44 131 496 0702",
  "school": "",
  "updated": "2026-09-15",
  "no_known_allergies": true,
  "allergies": [],
  "medications": [
   {
    "name": "Insulin glargine",
    "dose": "22 unit\u00e9s",
    "frequency": "une fois par jour, au coucher",
    "reason": "diab\u00e8te de type 1"
   },
   {
    "name": "Insulin aspart",
    "dose": "selon le comptage des glucides",
    "frequency": "\u00e0 chaque repas",
    "reason": "diab\u00e8te de type 1"
   }
  ],
  "conditions": [
   "Diab\u00e8te de type 1 depuis 2004 - risque d'hypoglyc\u00e9mie : en cas de ..."
  ],
  "implants": [],
  "contacts": [
   {
    "name": "Ada Okoro",
    "relation": "partenaire",
    "phone": "+44 7700 900612"
   },
   {
    "name": "Chidi Okoro",
    "relation": "fr\u00e8re",
    "phone": "+44 7700 900147"
   }
  ],
  "notes": "Sous insuline. Consigne personnelle : risque d'hypoglyc\u00e9mie : en cas ..."
 },
 "labels": {
  "title": "En cas d'urgence - informations m\u00e9dicales",
  "allergies": "Allergies",
  "medicines": "M\u00e9dicaments",
  "conditions": "Pathologies",
  "implants": "Implants",
  "contacts": "Personnes \u00e0 contacter",
  "blood_type": "Groupe sanguin",
  "doctor": "M\u00e9decin",
  "updated": "Mis \u00e0 jour le",
  "no_known_allergies": "Aucune allergie connue",
  "born": "Date de naissance",
  "notes": "Remarques",
  "school": "\u00c9cole"
 },
 "phrases": [
  {
   "text": "J'ai un diab\u00e8te de type 1 et je prends de l'insuline.",
   "english": "I have type 1 diabetes and take insulin."
  },
  {
   "text": "Si je suis confus ou somnolent, contr\u00f4lez ma glyc\u00e9mie.",
   "english": "If I am confused or drowsy, check my blood glucose."
  }
 ],
 "findings": [],
 "prescan": [],
 "caveats": [
  "\"Units\" is given as \"unit\u00e9s\" (insulin units); \"hypo\" is rendered as ..."
 ]
}

The insulin names, "22", the dates and both phone numbers come back exactly as on the sheet; the reasons, frequencies, relationships and the condition are in French ("diabète de type 1", "frère"), and the headings arrive in labels. The page's reply check finds nothing to note. findings and prescan are empty because the page reported only info findings.

Build the input yourself

The checker and the reply checker are plain scripts that run under node (global.window = global; require("./icscan.js"); require("./icreply.js")), then:

const S = IcScan, R = IcReply;
const st = { notes: fs.readFileSync("card.txt", "utf8"), language: "French" };
const scan = S.scan(st);                          // fields, findings, counts, verdict, type, p0, stats
console.log(scan.type, scan.p0, scan.findings.map(f => f.id)); // card type, top-of-card lines, rule ids
const b = S.buildInput(st, "translate");          // or "structure"; b.ok is false (with b.note) when the notes cannot run
const wire = S.toWire(b.input);                   // task, notes, language, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "translate");
// R.check(reply, b)                              // {notes, warn}: anything the reply added, dropped or changed
// S.sheet(reply.card)                            // a structure reply's card as a sheet - the notes for a translate run
// R.md("Sam", b, reply)                          // the card and the page findings as Markdown

Get a token for the steps below from /tokens.html.

Step 1 - a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://ice-card.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 - a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"ice-card"}' | jq -r '.data.token')

Step 3 - GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 - POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-sample input above as input.json (with task set to "structure" or "translate") to follow along.

# input.json: the run input object - {"task": "translate", "notes": "Name: ...\nAllergies:\n- ...\nMedicines:\n- ...\nContacts:\n- ...", "language": "French", "facts": "Name: ...\nRead by the page: ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'

Step 5 - POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses ice-card:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a structure and a translation of the same notes are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. The code below parses the JSON object out of the output and prints the card - the title, name, birth date and blood type, the allergies (or no known allergies), the medicines and the contacts, under the translated headings when there are labels - then, for "translate", the phrases with their English meaning, and for "structure", the questions to confirm and what was left off.

KEY="ice-card:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '(.labels // {}) as $l | .summary, "",
  ($l.title // "IN CASE OF EMERGENCY - MEDICAL INFORMATION"),
  "\(.card.name)  \(.card.born)  \($l.blood_type // "Blood type"): \(.card.blood_type)",
  "\($l.allergies // "Allergies"):",
  (if .card.no_known_allergies then "  \($l.no_known_allergies // "No known allergies")"
   else (.card.allergies[]? | "  ! \(.allergen): \(.reaction) (\(.severity))") end),
  "\($l.medicines // "Medicines"):", (.card.medications[]? | "  - \(.name) \(.dose) \(.frequency) (\(.reason))"),
  "\($l.contacts // "Contacts"):", (.card.contacts[]? | "  - \(.name) (\(.relation)): \(.phone)"),
  (if .task == "translate" then (.phrases[]? | "> \(.text)  [\(.english)]")
   else (.questions[]? | "? \(.)"), (.left_out[]? | "left out: \(.item) - \(.why)") end)' reply.json

Step 6 - POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: ice-card:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json