← IFC Desk / API
Tokens

Drive IFC Desk from your own code

Everything the web page does is available over HTTP. The difference from the page is where the IFC file gets read. In the browser, an ISO 10303-21 reader parses the .ifc locally and derives a compact text digest; only that digest and a set of prescan flags are sent to the model. Over the API the contract is the same: you send the digest, never the raw STEP file. The natural uses are a CDE hook that audits every incoming model issue and fails the acceptance gate when the posture regresses, a nightly job that regenerates the extraction schema for a live data warehouse, and a script that drafts the RFI straight from the audit it just ran.

The task field decides everything else

IFC Desk is one endpoint with four lanes. task is the router: it selects the system prompt, the output contract, the credit hold and the progress markers. It is the first field you should decide and the first field in every example on this page. An unrecognised or missing task does not fail the run — the model picks the closest lane, names it in lane, and says which it chose in the opening sentence of exec_summary — but you should never rely on that. Send one of the four ids.

taskthe question it answerskeys it adds to the common envelope
audit Is this model fit for handover, against the scope you named? Fourteen named checks, each decided in the browser first and then explained, plus the risks that follow. checks[] — exactly fourteen, in a fixed order — and risk_register[].
extract What tables should this model become? The CAD-to-data step, column by column, with the exact IFC attribute, property or quantity behind each column. tables[], join_keys[], blocked_columns[], export_notes[].
takeoff What can actually be measured from this model, and how? Which quantities are read straight from IfcElementQuantity, which must be derived, and which are simply absent. quantities[], gaps[], method_statement.
rfi What goes back to the model author? Prioritised information requests plus a sendable email. Runs best on an audit you already have, passed in source. requests[], email_draft, reissue_criteria[].

Every lane returns the same common envelopelane, title, posture, verdict, headline, exec_summary, findings, reconciliation, assumptions, open_questions, summary — and then adds its own keys on top. A lane never blends another lane's contract into its reply, so you can switch on lane and trust the shape.

Two lanes over the same model are two runs. They are priced separately, held separately and billed separately; the app's Idempotency-Key carries the lane id for exactly this reason. There is no combined call that returns all four.

One worked request per lane

These are the four bodies as the app itself submits them, with the digest abbreviated — the real model_digest runs to a few thousand characters and is capped at 22,000. The full input object is documented field by field in step 4.

audit — a stage 4 architectural issue arriving under an EIR:

{
  "task": "audit",
  "scope": "handover",
  "discipline": "all",
  "strictness": "contractual",
  "context": "Stage 4 issue from the architect. An EIR is in force and COBie follows in six weeks.",
  "model_digest": "## file\nbytes_parsed: 41883204\nschema: IFC4\nview_definition: none\n... (see step 4)",
  "prescan_facts": {
    "flags": [
      {"id": "F-MVD-MISSING", "severity": "medium",
       "title": "No ViewDefinition declared in the header",
       "detail": "FILE_DESCRIPTION carries no ViewDefinition, so the export scope is undeclared."},
      {"id": "F-QTO-MISSING", "severity": "high",
       "title": "No IfcElementQuantity to measure from",
       "detail": "0 of 18422 products carry a quantity set."}
    ],
    "checks": [
      {"id": "C-SCHEMA",  "name": "Schema and header",  "status": "pass",    "evidence": "IFC4, closed"},
      {"id": "C-MVD",     "name": "View definition",    "status": "fail",    "evidence": "none"},
      {"id": "C-SPATIAL", "name": "Spatial structure",  "status": "pass",    "evidence": "1/1/1/7"},
      {"id": "C-CONTAIN", "name": "Element containment","status": "partial", "evidence": "96.4%"},
      {"id": "C-STOREY",  "name": "Storey elevations",  "status": "pass",    "evidence": "7 distinct"},
      {"id": "C-GUID",    "name": "GlobalId integrity", "status": "pass",    "evidence": "0 invalid, 0 duplicate"},
      {"id": "C-REFS",    "name": "Reference integrity","status": "pass",    "evidence": "0 unresolved"},
      {"id": "C-NAMING",  "name": "Naming",             "status": "partial", "evidence": "88.1%"},
      {"id": "C-TYPES",   "name": "Type objects",       "status": "partial", "evidence": "61.0%"},
      {"id": "C-PSETS",   "name": "Property sets",      "status": "pass",    "evidence": "94.7%"},
      {"id": "C-QTO",     "name": "Quantities",         "status": "fail",    "evidence": "0.0%"},
      {"id": "C-MATERIAL","name": "Materials",          "status": "partial", "evidence": "72.3%"},
      {"id": "C-CLASS",   "name": "Classification",     "status": "fail",    "evidence": "0.0%"},
      {"id": "C-UNITS",   "name": "Units and georeferencing", "status": "partial", "evidence": "units complete, georef placement-only"}
    ],
    "stats": {
      "products": 18422, "storeys": 7, "spaces": 214,
      "placement_coverage": 96, "pset_coverage": 95, "qto_coverage": 0,
      "material_coverage": 72, "classification_coverage": 0, "type_coverage": 61,
      "schema": "IFC4", "length_unit": "MILLIMETRE", "georef": "placement-only"
    }
  }
}

extract — the same model, aimed at a database rather than at a verdict:

{
  "task": "extract",
  "scope": "asset",
  "discipline": "mep",
  "strictness": "standard",
  "context": "Feeding an asset register in Maximo. One row per maintainable item; the FM team wants storey and space on every row.",
  "model_digest": "## file\nbytes_parsed: 41883204\nschema: IFC4\n... (see step 4)",
  "prescan_facts": {
    "flags": [
      {"id": "F-NO-CLASSIFICATION", "severity": "medium",
       "title": "No external classification is associated",
       "detail": "0 of 18422 products reference an IfcClassificationReference."}
    ],
    "stats": {
      "products": 18422, "storeys": 7, "spaces": 214,
      "placement_coverage": 96, "pset_coverage": 95, "qto_coverage": 0,
      "material_coverage": 72, "classification_coverage": 0, "type_coverage": 61,
      "schema": "IFC4", "length_unit": "MILLIMETRE", "georef": "placement-only"
    }
  }
}

takeoff — note that prescan_facts.checks is absent, because this is not the audit lane:

{
  "task": "takeoff",
  "scope": "takeoff",
  "discipline": "structure",
  "strictness": "pragmatic",
  "context": "Pricing the frame for a two-week tender. I need concrete volume and formwork area or an honest no.",
  "model_digest": "## file\nbytes_parsed: 41883204\nschema: IFC4\n... (see step 4)",
  "prescan_facts": {
    "flags": [
      {"id": "F-QTO-MISSING", "severity": "high",
       "title": "No IfcElementQuantity to measure from",
       "detail": "0 of 18422 products carry a quantity set."},
      {"id": "F-NO-SPACES", "severity": "medium",
       "title": "No IfcSpace, so nothing is measurable by room",
       "detail": "The model carries 0 IfcSpace instances."}
    ],
    "stats": {
      "products": 18422, "storeys": 7, "spaces": 0,
      "placement_coverage": 96, "pset_coverage": 95, "qto_coverage": 0,
      "material_coverage": 72, "classification_coverage": 0, "type_coverage": 61,
      "schema": "IFC4", "length_unit": "MILLIMETRE", "georef": "placement-only"
    }
  }
}

rfi — the only lane that normally carries source, holding the audit reply it is written from:

{
  "task": "rfi",
  "scope": "handover",
  "discipline": "all",
  "strictness": "contractual",
  "context": "Write to the architect's BIM coordinator. Next issue is due in three weeks.",
  "model_digest": "## file\nbytes_parsed: 41883204\nschema: IFC4\n... (see step 4)",
  "source": "{\"lane\":\"audit\",\"posture\":\"conditional\",\"findings\":[{\"id\":\"IFD-001\",\"severity\":\"critical\",\"title\":\"No element quantities anywhere in the model\", ...}], ...}",
  "prescan_facts": {
    "flags": [
      {"id": "F-QTO-MISSING", "severity": "high",
       "title": "No IfcElementQuantity to measure from",
       "detail": "0 of 18422 products carry a quantity set."}
    ],
    "stats": {
      "products": 18422, "storeys": 7, "spaces": 214,
      "placement_coverage": 96, "pset_coverage": 95, "qto_coverage": 0,
      "material_coverage": 72, "classification_coverage": 0, "type_coverage": 61,
      "schema": "IFC4", "length_unit": "MILLIMETRE", "georef": "placement-only"
    }
  }
}

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":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your token as Authorization: Bearer … on every call. The token is already scoped to this app — it is minted for ifc-desk and carries the slug with it — so there is no slug header to set. The one place the slug appears in a request is the body of the guest endpoint, {"slug": "ifc-desk"}.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits for the lane you asked for. Call /estimate for that same lane first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Mint the token against ifc-desk and sign in for a personal one.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. This is the one you hit when you change the lane but keep the key — put the lane id in the key, as the app does.
validation_error422The input object is missing a required field or a field is the wrong type. An empty model_digest is the usual cause. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that adds the headers, unwraps data and raises on error. Everything after this step is written in terms of it.

# 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="ifc-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://ifc-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. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. All four lanes are metered, so running one needs a personal token from signing in. The guest endpoint is the only request that names the slug, and it names it in the body.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://ifc-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; every lane is metered, so running one 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": "ifc-desk"}'
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_...","subject_type":"guest"}}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a lane but cannot run one — and credits is the wallet balance in credits. Compare it against min_credits from the next step, for the lane you are about to run, so a shortfall surfaces as your own clear message rather than a 402.

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

4. Price the lane — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
taskstring, requiredThe lane: audit, extract, takeoff or rfi. Decide this first — it selects the output contract and the credit hold.
scopestringWhat the model is for: handover (it must export cleanly), takeoff (it must be measurable), coordination (geometry and placement matter most), asset (it feeds an O&M system) or archive (long-term readability). This is what makes a failing check material or immaterial — a missing classification is fatal for an asset register and irrelevant for coordination.
disciplinestringall, architecture, structure or mep. Emphasis, not a filter: the digest still carries the whole model, and a critical finding in another discipline is never suppressed.
strictnessstringstandard (what a normal issue should meet), contractual (an EIR is in force, so hold the model to it) or pragmatic (tell me only what blocks me). It moves severity, not facts.
contextstring, optionalFree-form notes: who issued the model, what stage it is, what the deadline is, what has already been agreed with the author. Clipped at 8,000 characters, with a [... context truncated ...] marker appended when it is.
model_digeststring, requiredThe derived text digest — not the .ifc file. See below. An empty string is refused; the run has no evidence without it.
prescan_factsobject{flags: [...], checks: [...], stats: {...}}. checks is sent only for the audit lane. See below.
sourcestring, optionalA previous reply to work from. In practice this is the rfi lane carrying the audit lane's JSON, so the requests are written from findings that already exist rather than invented afresh. Clipped at 12,000 characters, keeping the head and the tail — an audit puts its verdict at the top and its reconciliation at the bottom, and a blind head slice would throw away exactly the part the RFI is written from.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

model_digest: what to send instead of the file

An IFC exchange file is mostly geometry. Sending the first N characters of one would send the header and a few thousand IfcCartesianPoint rows and nothing that matters, and sending the whole thing is neither affordable nor useful. So the browser parses it locally and derives a digest: the header facts, the units, the georeference, the spatial tree, per-type counts and coverage, the property-set and quantity vocabulary actually present, and a bounded, stratified sample of real element rows.

It is plain text with nine ## section headings, in this order. An API caller building a digest from their own reader — IfcOpenShell, a database, an existing exporter — should emit the same sections with the same key names; the model is prompted against these headings by name.

sectionwhat belongs in it
## filebytes_parsed, schema (or MISSING), view_definition, originating_system, preprocessor, time_stamp, author as name / organization, closed (whether END-ISO-10303-21 is present), and instances: N across M entity types.
## unitsassignment: present|MISSING, then length: … area: … volume: …, an optional missing_unit_types: list, and conversion_based: yes|no for imperial models.
## georeferencelevel (for example placement-only, site-coordinates, map-conversion), site, latitude/longitude/elevation, then map_conversion, projected_crs and true_north.
## spatial structureCounts of project, site, building, storey and space, then one indented line per storey: storey <name> elevation=<value or MISSING> elements=<n>, capped at 40 with an ... and N more storeys line.
## coverageThe population figures, each as n/total (pct%): products broken down by discipline, placed_in_spatial_structure, named, typed, property_sets, common_psets, quantities, materials, classification, proxies, and a relationships: line counting aggregates, contains, definesByProperties, definesByType, material, classification, voids and fills.
## element types presentUp to 60 indented lines of IfcWallStandardCase: 1842 discipline=architecture, largest first.
## property set vocabularyUp to 40 indented Pset_WallCommon: 1842 lines, or (none present).
## quantity set vocabularyUp to 30 indented Qto_WallBaseQuantities: 1842 lines, or (none present).
## sample element rowsHeaded ## sample element rows (stratified: one block per element type, largest types first). One [IfcWall] n=1842 block header per type, then a few indented rows of #id guid=… name=… storey=… type_object=… materials=… classification=… psets=… quantities=…, closing with sampled N of M elements.

Two rules the model is held to, which are worth honouring when you build a digest by hand. Counts and coverage are authoritative — they are computed over the whole parsed file, and the model may total from them. The sample rows are a sample — the model must never total a quantity from them or imply they are an inventory, which is why the closing sampled N of M line matters. The digest is capped at 22,000 characters; when it is clipped, the app appends a marker saying so and reaffirming that every count above it is complete.

Truncated to its shape, a real digest looks like this:

## file
bytes_parsed: 41883204
schema: IFC4
view_definition: none
originating_system: Autodesk Revit 2024 (ENU)
preprocessor: IFC4 Add2
time_stamp: 2026-07-14T09:22:31
author: R. Aldarra / Northgate Architects
closed: yes
instances: 1204882 across 214 entity types

## units
assignment: present
length: MILLIMETRE  area: SQUARE_METRE  volume: CUBIC_METRE
conversion_based: no

## georeference
level: placement-only
site: Northgate Phase 2
latitude: none  longitude: none  elevation: none
map_conversion: no  projected_crs: none  true_north: yes

## spatial structure
project: 1  site: 1  building: 1  storey: 7  space: 214
  storey B01 elevation=-3600 elements=1204
  storey L00 elevation=0 elements=3891
  storey L01 elevation=4200 elements=3402

## coverage
products: 18422 (architecture 11208, structure 4611, mep 2103, other 500)
placed_in_spatial_structure: 17758/18422 (96%)
named: 16230/18422 (88%)
typed: 11237/18422 (61%) via 412 type objects
property_sets: 17444/18422 (95%), 51882 pset instances
common_psets: 15102/16880
quantities: 0/18422 (0%), 0 quantity sets
materials: 13319/18422 (72%)
classification: 0/18422 (0%)
proxies: 500 (3%)
relationships: aggregates=9 contains=41 definesByProperties=51882 definesByType=11237 material=13319 classification=0 voids=884 fills=871

## element types present
  IfcWallStandardCase: 1842  discipline=architecture
  IfcDoor: 903  discipline=architecture
  IfcBeam: 1204  discipline=structure

## property set vocabulary
  Pset_WallCommon: 1842
  Pset_DoorCommon: 903

## quantity set vocabulary
  (none present)

## sample element rows (stratified: one block per element type, largest types first)
  [IfcWallStandardCase] n=1842
  #10422 guid=2O2Fr$t4X7Zf8NOew3FLOH name="Basic Wall:SFS-140:341882" storey=L01 type_object=#8801 materials=Gypsum/Steel classification=NONE psets=Pset_WallCommon{IsExternal=.F., LoadBearing=.F., FireRating=EI60} quantities=NONE
sampled 96 of 18422 elements

prescan_facts, honestly

In the browser, prescan_facts is what the free local reader already established before the paid run started. It has three parts:

keyshapenotes
flags[{id, severity, title, detail}]The deterministic checks that fired. id is a F-… constant; severity is critical, high, medium or low. Always sent, possibly empty.
checks[{id, name, status, evidence}]Sent only when task is audit. All fourteen C-… ids with a locally computed status of pass, partial, fail or unknown. Omit the key entirely on the other three lanes, as the app does.
stats{products, storeys, spaces, placement_coverage, pset_coverage, qto_coverage, material_coverage, classification_coverage, type_coverage, schema, length_unit, georef}The headline figures, repeated outside the digest text so they cannot be missed. Coverages are integer percentages.

An API caller does not have to reproduce all of it. Sending {"flags": []} is legitimate and the lane still works; the model reads model_digest either way. What makes flags worth sending is the reconciliation contract: every id you send in flags must come back exactly once in reconciliation, with a status of confirmed, refined, not_material or disputed. That turns a fact your own tooling already established into something the reply is held to. not_material is a correct answer — the model setting a flag aside for this scope, with the reason in note — and is a different thing from silence. A flag that never appears at all is a failed run, not a passing one, and ids you did not send should not appear either.

These are the flag ids most worth raising by hand if you are building the prescan yourself:

idfires when
F-NOT-IFCThe input does not read as an IFC exchange file at all. The lane then returns posture: "blocked", one finding, empty lane arrays, and this flag reconciled.
F-TRUNCATEDOnly the leading part of the file was parsed, so counts describe a prefix.
F-NO-ENDThe file does not close with END-ISO-10303-21.
F-SCHEMA-MISSING / F-SCHEMA-UNKNOWN / F-SCHEMA-LEGACYNo FILE_SCHEMA, an unrecognised one, or still on IFC2X3.
F-MVD-MISSINGNo ViewDefinition in FILE_DESCRIPTION, so the export scope is undeclared.
F-NO-PROJECT / F-NO-SITE / F-NO-BUILDING / F-NO-STOREYA rung of the spatial hierarchy is missing.
F-ORPHANSElements are in no spatial structure, so they belong to no storey.
F-EMPTY-STOREY / F-STOREY-ELEV-MISSING / F-STOREY-ELEV-DUPA storey holds nothing, has no elevation, or shares an elevation with another.
F-GUID-INVALID / F-GUID-DUPGlobalIds outside the 22-character IFC alphabet, or repeated.
F-UNRESOLVED / F-DUP-INSTANCE / F-MALFORMEDReferences to instances not in the file, an instance id defined twice, or statements in DATA that are not instance definitions.
F-PROXY-HEAVYA large share of elements are IfcBuildingElementProxy, so their type carries no meaning.
F-NO-TYPESOccurrences are not linked to type objects by IfcRelDefinesByType.
F-PSET-THIN / F-COMMON-PSET-THINProperty-set coverage is thin, or elements are missing their Pset_*Common set.
F-QTO-MISSINGNo IfcElementQuantity, so nothing can be read as a measure. The single most consequential flag for the takeoff lane.
F-NO-MATERIAL / F-NO-CLASSIFICATION / F-UNNAMEDNo material association, no external classification for cost coding, or no readable Name.
F-NO-SPACESNo IfcSpace, so nothing is measurable by room and no asset register can be located.
F-UNIT-MISSINGThe unit assignment is absent or incomplete, which makes every dimension in the model ambiguous.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower. Budget against hold_credits, report against charged_credits.

The hold differs per lane, because the lanes compose different prompt sections and carry different output caps: an extract reply that specifies every column of every table is a much larger object than a takeoff reply. So estimate the lane you intend to run. The web app keeps one estimate per lane and discards the other lane's number when you switch, rather than showing lane A's price for lane B, and an API caller should do the same. The digest dominates the input side of the price, so re-estimating after only a context edit is rarely worth the round trip.

# model_digest is the DERIVED digest, never the .ifc file. Produce it with your
# own ISO 10303-21 reader, using the nine section headings above, and leave it
# in digest.txt. jq --rawfile keeps the newlines intact.
LANE="audit"

INPUT=$(jq -n --rawfile digest digest.txt --arg task "$LANE" '{
  task: $task,
  scope: "handover",
  discipline: "all",
  strictness: "contractual",
  context: "Stage 4 issue from the architect. An EIR is in force and COBie follows in six weeks.",
  model_digest: $digest,
  prescan_facts: {
    flags: [
      {id: "F-MVD-MISSING", severity: "medium",
       title: "No ViewDefinition declared in the header",
       detail: "FILE_DESCRIPTION carries no ViewDefinition."},
      {id: "F-QTO-MISSING", severity: "high",
       title: "No IfcElementQuantity to measure from",
       detail: "0 of 18422 products carry a quantity set."}
    ],
    # checks belongs to the audit lane ONLY - drop this key on the other three.
    checks: [
      {id: "C-SCHEMA", name: "Schema and header", status: "pass", evidence: "IFC4, closed"},
      {id: "C-MVD", name: "View definition", status: "fail", evidence: "none"}
    ],
    stats: {products: 18422, storeys: 7, spaces: 214,
            placement_coverage: 96, pset_coverage: 95, qto_coverage: 0,
            material_coverage: 72, classification_coverage: 0, type_coverage: 61,
            schema: "IFC4", length_unit: "MILLIMETRE", georef: "placement-only"}
  }
}')

# Free. No job is created and nothing is charged.
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-terra","model_alias":"gpt-terra","markup_bps":0,
#   "hold_credits":812,"min_credits":140,"sponsor_enabled":false}}
#
# Re-run this for each lane you intend to use. The hold is per lane, and the
# extract lane's hold is the largest of the four.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The reply JSON is the string at data.output.output. The terminal job also carries charged_credits — the real price — and the truncated flag.

Always send an Idempotency-Key, and put the lane id in it. The key is not formally required by the endpoint, and it is required in practice. The web app builds it as ifc-desk:<lane>:<hash>:a<attempt>, where the hash is over the input fields that decide the answer — task, scope, discipline, strictness, model_digest, context and source. The lane segment is what keeps an audit and a takeoff over the same model from colliding: they are two distinct runs and each must get its own job. A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a CI retry safe after a network blip. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed.

# The key carries the LANE, so two lanes over one model are two jobs, not a 409.
HASH=$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16)
KEY="$SLUG:$LANE:$HASH:a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | jq -r '.data.job_id')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"audit\",\"title\":\"Northgate Phase 2 - handover audit\", ...}"},
#   "charged_credits":274,"truncated":false}}
printf '%s' "$OUT" | jq -r '.data.output.output' | jq '.lane, .posture, (.checks | length)'

6. Or stream it

POST /run-stream is the same call over server-sent events, with the same Idempotency-Key discipline. A job event arrives first with the job_id; each delta event carries {"text": "..."}, a chunk of the reply JSON; the final done event carries status, charged_credits — the real price, normally a fraction of the hold — and the truncated flag. An error event ends the stream instead, and a pending event stands in for done when the job is still settling.

One caveat worth coding for: an idempotent replay does not stream. If the key has already produced a job, the server answers with a normal JSON envelope rather than text/event-stream, so branch on the response Content-Type before you start reading events.

The practical tip: the web app does not parse the partial JSON to drive its progress display, it watches for key names arriving in the accumulating text. Substring matching on the quoted key name is enough and costs nothing. Each lane has its own ordered markers, matching the order the contract asks the keys to be written in:

lanemarkers, in arrival order
audit"checks", "findings", "risk_register", "reconciliation", "summary"
extract"tables", "columns", "join_keys", "blocked_columns", "export_notes"
takeoff"quantities", "gaps", "method_statement", "reconciliation", "summary"
rfi"requests", "acceptance", "email_draft", "reissue_criteria", "summary"
# Server-sent events. Each `delta` carries a chunk of the reply JSON; the final
# `done` event carries the status, charged_credits and the truncated flag.
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":"{\"lane\":\"audit\",\"title\":\"Northgate"}
# event: delta  {"text":" Phase 2 - handover audit\","}
# event: done   {"status":"succeeded","charged_credits":274,"truncated":false}

The output contract

data.output.output is a string holding exactly one JSON object — no prose, no code fences. Every lane returns the same keys in the same order and then adds its own. Parse the outermost braces rather than trusting the whole string to be clean, as the app does; a model that adds a stray character before the object should not cost you the run.

keytypemeaning
lanestringThe lane that was actually answered. Normally equal to the task you sent. If it is not, the model chose a lane for itself — the app records that as a mismatch and says so — so compare it and decide whether to accept the reply.
titlestringA short name for this review, naming the model.
postureenumready when the model can be used for the stated scope as delivered, conditional when it can be used with named workarounds, blocked when it cannot. This is the field a CI gate should switch on.
verdictstringOne sentence: the thing that decides the posture.
headline{label, value}The one number worth putting on a dashboard, with what it measures. value is a string and carries its unit — for the audit lane it is normally the count of failing checks or the placement coverage.
exec_summarystringTwo to four sentences of plain prose for someone who will not read further.
findings[]{id, severity, area, title, detail, evidence, action}Ids run IFD-001, IFD-002, … in severity order, highest first. evidence names the digest figure or IFC entity that proves the finding; action is the specific next step, addressed to whoever must do it. An empty array is legitimate and means every check passed — there is never a placeholder finding.
reconciliation[]{flag_id, status, note}Exactly one entry per prescan_facts.flags id you sent, no more and no fewer, and no ids you did not send. status is confirmed, refined, not_material or disputed. This is the cheapest assertion you can write against a run.
assumptions[]string[]What had to be assumed because the digest is silent. Possibly empty, never null.
open_questions[]string[]What the reviewer would ask the model author. Possibly empty, never null.
summarystringOne closing paragraph.

Then, per lane

audit adds checks[] and risk_register[]:

"checks": [
  {"id": "C-SCHEMA", "name": "Schema and header", "status": "pass|partial|fail|unknown",
   "evidence": "<from the digest>", "comment": "<what it means for this user>"}
],
"risk_register": [
  {"id": "R-1", "risk": "<what could go wrong downstream>",
   "likelihood": "high|medium|low", "impact": "high|medium|low",
   "mitigation": "<what to do now>"}
]

extract adds tables[], join_keys[], blocked_columns[] and export_notes[]:

"tables": [
  {"name": "<snake_case>", "grain": "<one row per what>",
   "entity_filter": "<the IFC types this table draws from>",
   "row_estimate": 18422,
   "columns": [
     {"name": "<snake_case>",
      "source": "<the exact IFC attribute, Pset property or Qto quantity>",
      "type": "string|number|boolean|date", "unit": "<unit or empty>",
      "coverage": "<what share of rows will be populated>",
      "required": true, "note": "<only when something is off>"}
   ]}
],
"join_keys": [{"key": "<column>", "why": "<what it joins to, across which tables>"}],
"blocked_columns": [
  {"column": "<the column someone will ask for>",
   "reason": "<why it cannot be produced from this model>",
   "unblock": "<what the author must add>"}
],
"export_notes": ["<units, encoding, nulls, proxy rows, sampled fields>"]

Every column's source names something the digest shows is actually present — the honestly impossible ones go in blocked_columns, which is the part of this lane most worth reading. row_estimate comes from the per-type counts. There is never a geometry table, because the digest carries no geometry.

takeoff adds quantities[], gaps[] and method_statement:

"quantities": [
  {"id": "Q-1", "trade": "<trade or discipline>", "item": "<what is being measured>",
   "measure": "area|volume|length|count|weight", "unit": "<the model's unit>",
   "basis": "read|derive|impossible",
   "source": "<the Qto quantity, or what it would be derived from>",
   "elements": "<which IFC types and how many>",
   "confidence": "high|medium|low", "note": "<what limits it>"}
],
"gaps": [{"item": "<what cannot be measured>", "why": "<the missing IFC construct>",
          "request": "<the exact thing to ask the author for>"}],
"method_statement": "<a short paragraph a QS could put in front of a bill of quantities>"

basis: "read" appears only when the digest shows an IfcElementQuantity with that quantity name actually present; derive means it could be computed from geometry or from a property the model does carry, and says which. This lane deliberately never states a total quantity figure unless the digest gives it for the whole population — the sample rows are not a total, and refusing to add them up is the point of the lane.

rfi adds requests[], email_draft and reissue_criteria[]:

"requests": [
  {"id": "RFI-01", "priority": "blocker|high|medium|low",
   "subject": "<short subject line>",
   "ask": "<the specific change, in the author's own vocabulary>",
   "why": "<the downstream consequence of not doing it>",
   "evidence": "<the digest figure that justifies the ask>",
   "acceptance": "<how you will verify it on the next issue>",
   "effort": "<a rough sense of how much work this is for the author>"}
],
"email_draft": "<a complete, sendable message: greeting, one-paragraph context, the numbered asks in priority order, the re-issue request, sign-off. Plain text, no markdown headings.>",
"reissue_criteria": ["<what must be true of the next issue for it to be accepted>"]

priority: "blocker" is reserved for things that stop the stated scope entirely, and the email_draft must be consistent with requests — it may not introduce an ask that is not in the array. The app checks that every request's subject appears in the draft and warns when one does not; an API caller can do the same in three lines.

The enums

fieldvalues
postureready, conditional, blocked
findings[].severitycritical, high, medium, low
reconciliation[].statusconfirmed, refined, not_material, disputed
checks[].statuspass, partial, fail, unknown
risk_register[].likelihood, .impact, quantities[].confidencehigh, medium, low
columns[].typestring, number, boolean, date
quantities[].measurearea, volume, length, count, weight
quantities[].basisread, derive, impossible
requests[].priorityblocker, high, medium, low

The fourteen checks

The audit lane returns checks[] with exactly these fourteen ids, in this order. The browser computes a status for each locally and sends it in prescan_facts.checks; the model starts from those statuses and changes one only when it can say why in comment — a fail on C-CLASS is not material for a model whose declared scope is coordination only. A missing id in the reply is a contract violation, and worth asserting.

#idnamethe requirement it tests
1C-SCHEMASchema and headerFILE_SCHEMA names a current IFC schema and the file closes properly.
2C-MVDView definitionFILE_DESCRIPTION declares the ViewDefinition the export was made for.
3C-SPATIALSpatial structureIfcProject aggregates a Site, a Building and at least one Storey.
4C-CONTAINElement containmentEvery element is contained in a spatial structure element.
5C-STOREYStorey elevationsEvery storey has a distinct Elevation and holds elements.
6C-GUIDGlobalId integrityEvery GlobalId is a valid, unique 22-character IFC id.
7C-REFSReference integrityEvery #n reference resolves to an instance in this file.
8C-NAMINGNamingElements carry a Name a human can read in a schedule.
9C-TYPESType objectsOccurrences are linked to type objects by IfcRelDefinesByType.
10C-PSETSProperty setsElements carry property sets, including their Pset_*Common set.
11C-QTOQuantitiesElements carry IfcElementQuantity so measures can be read, not derived.
12C-MATERIALMaterialsElements are associated with a material or a layer set.
13C-CLASSClassificationElements reference an external classification for cost coding.
14C-UNITSUnits and georeferencingA complete unit assignment, and a georeference a surveyor can use.

When the file did not parse as IFC

If the digest says the input was not recoverable as IFC, every lane behaves the same way: posture is blocked, lane is still the task you asked for, there is one finding explaining that no IFC data was recoverable, the F-NOT-IFC flag is reconciled, and the lane-specific arrays come back empty. Nothing is speculated about what the file might have contained. Treat an empty checks or tables array together with posture: "blocked" as this case, not as a malformed reply.

7. Assert the contract, then gate on it

The reply is generated, so the useful thing to do with it in an automated job is not to read it but to check it. Five assertions cover almost everything that can go wrong, and each one maps to a rule the app itself enforces before it will render a result:

  1. lane equals the task you sent — otherwise the model picked a lane for itself.
  2. reconciliation covers every flag id you sent, exactly once, and introduces none.
  3. The lane's own required array is present and non-empty: checks for audit (all fourteen ids), tables for extract, quantities or gaps for takeoff, requests for rfi.
  4. truncated is false on the finished job.
  5. posture is what your gate accepts — normally ready, or ready and conditional while a project is still in design.
# REVIEW holds data.output.output, already extracted in step 5.
REVIEW=$(printf '%s' "$OUT" | jq -r '.data.output.output')

# 1. The lane that was answered is the lane that was asked for.
[ "$(printf '%s' "$REVIEW" | jq -r '.lane')" = "$LANE" ] || { echo "lane mismatch"; exit 1; }

# 2. Every flag id sent came back exactly once in reconciliation.
SENT=$(printf '%s' "$INPUT"  | jq -r '.prescan_facts.flags[].id' | sort)
BACK=$(printf '%s' "$REVIEW" | jq -r '.reconciliation[].flag_id'  | sort)
[ "$SENT" = "$BACK" ] || { echo "reconciliation does not match the flags sent"; exit 1; }

# 3. The audit lane returns all fourteen checks, in order.
if [ "$LANE" = "audit" ]; then
  WANT="C-SCHEMA C-MVD C-SPATIAL C-CONTAIN C-STOREY C-GUID C-REFS C-NAMING C-TYPES C-PSETS C-QTO C-MATERIAL C-CLASS C-UNITS"
  GOT=$(printf '%s' "$REVIEW" | jq -r '[.checks[].id] | join(" ")')
  [ "$GOT" = "$WANT" ] || { echo "checks are not the fourteen, in order: $GOT"; exit 1; }
fi

# 4 and 5. Nothing was cut, and the posture clears the gate.
[ "$(printf '%s' "$OUT" | jq -r '.data.truncated')" = "false" ] || { echo "truncated"; exit 1; }
case "$(printf '%s' "$REVIEW" | jq -r '.posture')" in
  ready|conditional) echo "accepted" ;;
  *) echo "model is blocked for this scope"; exit 1 ;;
esac

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the reply, not the reply — the fourteen checks may be complete while risk_register, reconciliation and summary are missing or cut mid-string. The extract lane is the one that hits this most often, because a full column specification is the largest object any lane produces.

Check the flag before you treat a reply as complete. The right response is a retry, not a repair: resubmit with a retry_note asking for a denser answer — fewer, wider tables; the findings merged; the email draft shortened — and with the attempt suffix on the Idempotency-Key incremented so the new body is not a replay of the old key. Repairing truncated JSON by appending closing braces produces something that parses and is not what the model meant, and in this app that means a quantity surveyor prices a table that was never finished.

A reply that comes back in the wrong shape entirely — prose, a code fence, two objects — gets one automatic retry in the web app, carrying a retry_note that names the parse error and restates the contract for that specific lane. It is worth copying: one retry, then surface the raw text rather than guessing at it.