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.
| task | the question it answers | keys 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 envelope — lane,
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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits for the lane you asked for. Call /estimate for that same lane first and top up. |
forbidden | 403 | The 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_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The 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_error | 422 | The 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "ifc-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://ifc-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "ifc-desk";
const TOKEN = "YOUR_TOKEN"; // from https://ifc-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data; // the unwrapped `data`
}
package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "ifc-desk"
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://ifc-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call returns the raw `data` object, or an error carrying the API code.
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr *bytes.Reader
if body != nil {
method = http.MethodPost
buf, _ := json.Marshal(body)
rdr = bytes.NewReader(buf)
} else {
rdr = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class IfcDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "ifc-desk";
// from https://ifc-desk.skillsafe.ai/tokens.html
static final String TOKEN =
System.getenv("SKILLSAFE_TOKEN") == null ? "YOUR_TOKEN" : System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the raw response body. Parse it with the JSON library you already use. */
static String call(String path, String jsonBody) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b = b.GET();
} else {
b = b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"ok\":false")) throw new RuntimeException(res.body());
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "ifc-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://ifc-desk.skillsafe.ai/tokens.html
# Returns the unwrapped `data`, or raises with the API error code.
def call(path, body = nil, extra_headers = {})
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
extra_headers.each { |k, v| req[k] = v }
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "ifc-desk";
// from https://ifc-desk.skillsafe.ai/tokens.html
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
/** Returns the unwrapped `data`, or throws with the API error code. */
function call(string $path, ?array $body = null, array $extraHeaders = []): array {
global $TOKEN;
$headers = array_merge(["Authorization: Bearer {$TOKEN}"], $extraHeaders);
$ch = curl_init(BASE . "/" . $path);
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
static class IfcDesk {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "ifc-desk";
// from https://ifc-desk.skillsafe.ai/tokens.html
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
/// <summary>Returns the unwrapped `data`, or throws with the API error code.</summary>
public static async Task<JsonElement> Call(string path, object? body = null,
(string, string)? extraHeader = null) {
var method = body is null ? HttpMethod.Get : HttpMethod.Post;
var req = new HttpRequestMessage(method, $"{Base}/{path}");
req.Headers.Add("Authorization", "Bearer " + Token);
if (extraHeader is { } h) req.Headers.Add(h.Item1, h.Item2);
if (body is not null) {
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean()) {
var err = payload.GetProperty("error");
var code = err.GetProperty("code").GetString();
var message = err.GetProperty("message").GetString();
throw new Exception(code + ": " + message);
}
return payload.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. 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"}}
# Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered lane.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "ifc-desk"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "ifc-desk" }),
});
const guestToken = (await res.json()).data.token;
// Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
guestBody, _ := json.Marshal(map[string]string{"slug": slug})
guestReq, _ := http.NewRequest(http.MethodPost,
base+"/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
var guestReq = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"ifc-desk\"}"))
.build();
HttpResponse<String> guest = HTTP.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"sk_guest_..."}}
# Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered lane.
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: SLUG })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
guest_token = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"], PHP_EOL;
// Open https://ifc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered lane.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\": \"ifc-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await IfcDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
4. Price the lane — free
The input object is exactly what the app's own form submits:
| field | type | meaning |
|---|---|---|
task | string, required | The lane: audit, extract, takeoff or rfi. Decide this first — it selects the output contract and the credit hold. |
scope | string | What 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. |
discipline | string | all, 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. |
strictness | string | standard (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. |
context | string, optional | Free-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_digest | string, required | The derived text digest — not the .ifc file. See below. An empty string is refused; the run has no evidence without it. |
prescan_facts | object | {flags: [...], checks: [...], stats: {...}}. checks is sent only for the audit lane. See below. |
source | string, optional | A 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_note | string, optional | Send 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.
| section | what belongs in it |
|---|---|
## file | bytes_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. |
## units | assignment: present|MISSING, then length: … area: … volume: …, an optional missing_unit_types: list, and conversion_based: yes|no for imperial models. |
## georeference | level (for example placement-only, site-coordinates, map-conversion), site, latitude/longitude/elevation, then map_conversion, projected_crs and true_north. |
## spatial structure | Counts 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. |
## coverage | The 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 present | Up to 60 indented lines of IfcWallStandardCase: 1842 discipline=architecture, largest first. |
## property set vocabulary | Up to 40 indented Pset_WallCommon: 1842 lines, or (none present). |
## quantity set vocabulary | Up to 30 indented Qto_WallBaseQuantities: 1842 lines, or (none present). |
## sample element rows | Headed ## 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:
| key | shape | notes |
|---|---|---|
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:
| id | fires when |
|---|---|
F-NOT-IFC | The 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-TRUNCATED | Only the leading part of the file was parsed, so counts describe a prefix. |
F-NO-END | The file does not close with END-ISO-10303-21. |
F-SCHEMA-MISSING / F-SCHEMA-UNKNOWN / F-SCHEMA-LEGACY | No FILE_SCHEMA, an unrecognised one, or still on IFC2X3. |
F-MVD-MISSING | No ViewDefinition in FILE_DESCRIPTION, so the export scope is undeclared. |
F-NO-PROJECT / F-NO-SITE / F-NO-BUILDING / F-NO-STOREY | A rung of the spatial hierarchy is missing. |
F-ORPHANS | Elements are in no spatial structure, so they belong to no storey. |
F-EMPTY-STOREY / F-STOREY-ELEV-MISSING / F-STOREY-ELEV-DUP | A storey holds nothing, has no elevation, or shares an elevation with another. |
F-GUID-INVALID / F-GUID-DUP | GlobalIds outside the 22-character IFC alphabet, or repeated. |
F-UNRESOLVED / F-DUP-INSTANCE / F-MALFORMED | References to instances not in the file, an instance id defined twice, or statements in DATA that are not instance definitions. |
F-PROXY-HEAVY | A large share of elements are IfcBuildingElementProxy, so their type carries no meaning. |
F-NO-TYPES | Occurrences are not linked to type objects by IfcRelDefinesByType. |
F-PSET-THIN / F-COMMON-PSET-THIN | Property-set coverage is thin, or elements are missing their Pset_*Common set. |
F-QTO-MISSING | No 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-UNNAMED | No material association, no external classification for cost coding, or no readable Name. |
F-NO-SPACES | No IfcSpace, so nothing is measurable by room and no asset register can be located. |
F-UNIT-MISSING | The 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.
# 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.
LANE = "audit"
with open("digest.txt", encoding="utf-8") as f:
digest = f.read()
INPUT = {
"task": LANE,
"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."},
],
"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",
},
},
}
# `checks` belongs to the audit lane only - the other three lanes omit the key.
if LANE == "audit":
INPUT["prescan_facts"]["checks"] = [
{"id": "C-SCHEMA", "name": "Schema and header", "status": "pass", "evidence": "IFC4, closed"},
{"id": "C-MVD", "name": "View definition", "status": "fail", "evidence": "none"},
]
# Free: no job is created and nothing is charged. The hold is per lane, so
# estimate the lane you are about to run, not the one you ran last time.
est = call("estimate", INPUT)
print(est["model"], est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
import { readFileSync } from "node:fs";
// 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.
const LANE = "audit";
const digest = readFileSync("digest.txt", "utf8");
const INPUT = {
task: LANE,
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." },
],
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",
},
},
};
// `checks` belongs to the audit lane only - the other three lanes omit the key.
if (LANE === "audit") {
INPUT.prescan_facts.checks = [
{ id: "C-SCHEMA", name: "Schema and header", status: "pass", evidence: "IFC4, closed" },
{ id: "C-MVD", name: "View definition", status: "fail", evidence: "none" },
];
}
// Free: no job is created and nothing is charged. The hold is per lane.
const est = await call("estimate", INPUT);
console.log(est.model, est.hold_credits, est.min_credits, est.sponsor_enabled);
// 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.
lane := "audit"
digestBytes, err := os.ReadFile("digest.txt")
if err != nil {
panic(err)
}
flag := map[string]string{
"id": "F-QTO-MISSING", "severity": "high",
"title": "No IfcElementQuantity to measure from",
"detail": "0 of 18422 products carry a quantity set.",
}
prescan := map[string]any{
"flags": []any{flag},
"stats": map[string]any{
"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",
},
}
// `checks` belongs to the audit lane only - the other three lanes omit the key.
if lane == "audit" {
prescan["checks"] = []any{
map[string]string{"id": "C-SCHEMA", "name": "Schema and header",
"status": "pass", "evidence": "IFC4, closed"},
map[string]string{"id": "C-MVD", "name": "View definition",
"status": "fail", "evidence": "none"},
}
}
input := map[string]any{
"task": lane,
"scope": "handover",
"discipline": "all",
"strictness": "contractual",
"context": "Stage 4 issue from the architect. An EIR is in force.",
"model_digest": string(digestBytes),
"prescan_facts": prescan,
}
// Free: no job is created and nothing is charged. The hold is per lane.
raw, err := call("estimate", input)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
Hold int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
_ = json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.Hold, est.MinCredits)
// 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. Build the body
// with whichever JSON library you already use; this sample composes it directly.
String lane = "audit";
String digest = Files.readString(Path.of("digest.txt"));
String flags = """
[{"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 - the other three lanes omit the key.
String checks = lane.equals("audit")
? ",\"checks\":[{\"id\":\"C-SCHEMA\",\"name\":\"Schema and header\","
+ "\"status\":\"pass\",\"evidence\":\"IFC4, closed\"}]"
: "";
String 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"}""";
String INPUT = "{\"task\":\"" + lane + "\",\"scope\":\"handover\","
+ "\"discipline\":\"all\",\"strictness\":\"contractual\","
+ "\"context\":\"Stage 4 issue from the architect. An EIR is in force.\","
+ "\"model_digest\":" + jsonString(digest) + "," // your own escaper
+ "\"prescan_facts\":{\"flags\":" + flags + checks + ",\"stats\":" + stats + "}}";
// Free: no job is created and nothing is charged. The hold is per lane.
System.out.println(call("estimate", INPUT));
// {"ok":true,"data":{"model":"gpt-terra","hold_credits":812,"min_credits":140,...}}
# 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.
LANE = "audit"
digest = File.read("digest.txt")
prescan = {
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." }
],
stats: {
products: 18_422, 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"
}
}
# `checks` belongs to the audit lane only - the other three lanes omit the key.
if LANE == "audit"
prescan[:checks] = [
{ id: "C-SCHEMA", name: "Schema and header", status: "pass", evidence: "IFC4, closed" },
{ id: "C-MVD", name: "View definition", status: "fail", evidence: "none" }
]
end
INPUT = {
task: LANE, scope: "handover", discipline: "all", strictness: "contractual",
context: "Stage 4 issue from the architect. An EIR is in force.",
model_digest: digest, prescan_facts: prescan
}
# Free: no job is created and nothing is charged. The hold is per lane.
est = call("estimate", INPUT)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
<?php
// 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.
$lane = "audit";
$digest = file_get_contents("digest.txt");
$prescan = [
"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",
],
];
// `checks` belongs to the audit lane only - the other three lanes omit the key.
if ($lane === "audit") {
$prescan["checks"] = [
["id" => "C-SCHEMA", "name" => "Schema and header",
"status" => "pass", "evidence" => "IFC4, closed"],
];
}
$INPUT = [
"task" => $lane,
"scope" => "handover",
"discipline" => "all",
"strictness" => "contractual",
"context" => "Stage 4 issue from the architect. An EIR is in force.",
"model_digest" => $digest,
"prescan_facts" => $prescan,
];
// Free: no job is created and nothing is charged. The hold is per lane.
$est = call("estimate", $INPUT);
echo $est["model"], " hold=", $est["hold_credits"], " min=", $est["min_credits"], PHP_EOL;
// 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.
var lane = "audit";
var digest = await File.ReadAllTextAsync("digest.txt");
var prescan = new Dictionary<string, object> {
["flags"] = new object[] {
new { id = "F-QTO-MISSING", severity = "high",
title = "No IfcElementQuantity to measure from",
detail = "0 of 18422 products carry a quantity set." },
},
["stats"] = new {
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",
},
};
// `checks` belongs to the audit lane only - the other three lanes omit the key.
if (lane == "audit") {
prescan["checks"] = new object[] {
new { id = "C-SCHEMA", name = "Schema and header", status = "pass", evidence = "IFC4, closed" },
new { id = "C-MVD", name = "View definition", status = "fail", evidence = "none" },
};
}
var input = new {
task = lane,
scope = "handover",
discipline = "all",
strictness = "contractual",
context = "Stage 4 issue from the architect. An EIR is in force.",
model_digest = digest,
prescan_facts = prescan,
};
// Free: no job is created and nothing is charged. The hold is per lane.
var est = await IfcDesk.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("min_credits").GetInt32());
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)'
import hashlib, time
# The key carries the LANE, so two lanes over one model are two jobs, not a 409.
signal = "".join([INPUT["task"], INPUT["scope"], INPUT["discipline"], INPUT["strictness"],
INPUT["model_digest"], INPUT["context"], INPUT.get("source", "")])
digest16 = hashlib.sha256(signal.encode()).hexdigest()[:16]
key = f"{SLUG}:{LANE}:{digest16}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
review = json.loads(job["output"]["output"])
print(review["lane"], review["posture"], review["headline"]["value"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
// The key carries the LANE, so two lanes over one model are two jobs, not a 409.
const signal = [INPUT.task, INPUT.scope, INPUT.discipline, INPUT.strictness,
INPUT.model_digest, INPUT.context, INPUT.source ?? ""].join("");
const digest16 = createHash("sha256").update(signal).digest("hex").slice(0, 16);
const key = `${SLUG}:${LANE}:${digest16}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const review = JSON.parse(job.output.output);
console.log(review.lane, review.posture, review.headline.value, job.charged_credits);
// The key carries the LANE, so two lanes over one model are two jobs, not a 409.
body, _ := json.Marshal(input)
signal := lane + "handover" + "all" + "contractual" + string(digestBytes)
sum := sha256.Sum256([]byte(signal))
key := fmt.Sprintf("%s:%s:%x:a1", slug, lane, sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
var job struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
var review struct {
Lane string `json:"lane"`
Posture string `json:"posture"`
}
_ = json.Unmarshal([]byte(job.Output.Output), &review)
fmt.Println(review.Lane, review.Posture, job.Charged)
// The key carries the LANE, so two lanes over one model are two jobs, not a 409.
var md = java.security.MessageDigest.getInstance("SHA-256");
var sum = md.digest((lane + "handover" + "all" + "contractual" + digest)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var hex = new StringBuilder();
for (int i = 0; i < 8; i++) hex.append(String.format("%02x", sum[i]));
String key = SLUG + ":" + lane + ":" + hex + ":a1";
var runReq = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(INPUT))
.build();
HttpResponse<String> started = HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
String jobId = extract(started.body(), "job_id"); // your own JSON reader
String job;
while (true) {
job = call("jobs/" + jobId, null);
String status = extract(job, "status");
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(2000);
}
// data.output.output holds the review JSON as a string; charged_credits is the real price.
System.out.println(job);
require "digest"
# The key carries the LANE, so two lanes over one model are two jobs, not a 409.
signal = [INPUT[:task], INPUT[:scope], INPUT[:discipline], INPUT[:strictness],
INPUT[:model_digest], INPUT[:context], INPUT[:source].to_s].join
key = "#{SLUG}:#{LANE}:#{Digest::SHA256.hexdigest(signal)[0, 16]}:a1"
started = call("run", INPUT, { "Idempotency-Key" => key })
job_id = started["job_id"]
job = nil
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
raise job["error"].to_s if job["status"] == "failed"
review = JSON.parse(job["output"]["output"])
puts "#{review['lane']} #{review['posture']} #{review['headline']['value']}"
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
<?php
// The key carries the LANE, so two lanes over one model are two jobs, not a 409.
$signal = $INPUT["task"] . $INPUT["scope"] . $INPUT["discipline"] . $INPUT["strictness"]
. $INPUT["model_digest"] . $INPUT["context"] . ($INPUT["source"] ?? "");
$key = SLUG . ":" . $lane . ":" . substr(hash("sha256", $signal), 0, 16) . ":a1";
$started = call("run", $INPUT, ["Idempotency-Key: {$key}"]);
$jobId = $started["job_id"];
do {
sleep(2);
$job = call("jobs/{$jobId}");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") {
throw new RuntimeException(json_encode($job["error"]));
}
$review = json_decode($job["output"]["output"], true);
echo $review["lane"], " ", $review["posture"], " ", $review["headline"]["value"], PHP_EOL;
echo "charged ", $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
// The key carries the LANE, so two lanes over one model are two jobs, not a 409.
var signal = lane + "handover" + "all" + "contractual" + digest;
var sum = SHA256.HashData(Encoding.UTF8.GetBytes(signal));
var key = $"ifc-desk:{lane}:{Convert.ToHexString(sum)[..16].ToLowerInvariant()}:a1";
var started = await IfcDesk.Call("run", input, ("Idempotency-Key", key));
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true) {
job = await IfcDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
var reviewJson = job.GetProperty("output").GetProperty("output").GetString()!;
using var review = JsonDocument.Parse(reviewJson);
Console.WriteLine(review.RootElement.GetProperty("lane").GetString());
Console.WriteLine(review.RootElement.GetProperty("posture").GetString());
Console.WriteLine(job.GetProperty("charged_credits").GetInt32());
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:
| lane | markers, 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}
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
MARKERS = {
"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"'],
}
raw, done, event = "", {}, None
stage = "reading the digest"
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
for i, marker in enumerate(MARKERS[LANE]):
if marker in raw:
stage = f"step {i + 1} of {len(MARKERS[LANE])}: {marker}"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
review = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, review["lane"], review["posture"], done.get("charged_credits"))
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
const MARKERS = {
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"'],
};
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
// An idempotent replay answers with plain JSON, not a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const replay = (await res.json()).data;
console.log("replayed job", replay.job_id);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "", event = null, stage = "reading the digest";
let done = {};
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text || "";
MARKERS[LANE].forEach((m, i) => { if (raw.includes(m)) stage = `step ${i + 1}: ${m}`; });
} else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const review = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, review.lane, review.posture, done.charged_credits);
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
streamReq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
streamReq.Header.Set("Authorization", "Bearer "+token)
streamReq.Header.Set("Content-Type", "application/json")
streamReq.Header.Set("Idempotency-Key", key)
streamReq.Header.Set("Accept", "text/event-stream")
streamRes, err := http.DefaultClient.Do(streamReq)
if err != nil {
panic(err)
}
defer streamRes.Body.Close()
markers := map[string][]string{
"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"`},
}
scanner := bufio.NewScanner(streamRes.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
var raw strings.Builder
event, stage := "", "reading the digest"
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
for i, m := range markers[lane] {
if strings.Contains(raw.String(), m) {
stage = fmt.Sprintf("step %d: %s", i+1, m)
}
}
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(stage, line[6:])
}
}
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var streamReq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(INPUT))
.build();
var raw = new StringBuilder();
var event = new String[] {""};
var stage = new String[] {"reading the digest"};
String[] markers = {"\"checks\"", "\"findings\"", "\"risk_register\"",
"\"reconciliation\"", "\"summary\""}; // the audit lane
HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7).trim();
} else if (line.startsWith("data: ") && event[0].equals("delta")) {
raw.append(textOf(line.substring(6))); // your own JSON reader
for (int i = 0; i < markers.length; i++) {
if (raw.indexOf(markers[i]) >= 0) stage[0] = "step " + (i + 1) + ": " + markers[i];
}
} else if (line.startsWith("data: ") && event[0].equals("done")) {
System.out.println(stage[0] + " " + line.substring(6));
}
});
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
MARKERS = {
"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"']
}.freeze
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(INPUT)
raw = +""
event = nil
stage = "reading the digest"
done = {}
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
MARKERS[LANE].each_with_index { |m, i| stage = "step #{i + 1}: #{m}" if raw.include?(m) }
elsif line.start_with?("data: ") && event == "done"
done = JSON.parse(line[6..])
end
end
end
end
end
review = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{stage} #{review['lane']} #{review['posture']} #{done['charged_credits']}"
<?php
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
$markers = [
"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"'],
];
$raw = "";
$event = "";
$stage = "reading the digest";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($INPUT));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer {$TOKEN}",
"Content-Type: application/json",
"Idempotency-Key: {$key}",
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event, &$stage, $markers, $lane) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line, "\r");
if (str_starts_with($line, "event: ")) {
$event = trim(substr($line, 7));
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
foreach ($markers[$lane] as $i => $m) {
if (str_contains($raw, $m)) { $stage = "step " . ($i + 1) . ": " . $m; }
}
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$review = json_decode(substr($raw, strpos($raw, "{"), strrpos($raw, "}") - strpos($raw, "{") + 1), true);
echo $stage, " ", $review["lane"], " ", $review["posture"], PHP_EOL;
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var markers = new Dictionary<string, string[]> {
["audit"] = new[] { "\"checks\"", "\"findings\"", "\"risk_register\"", "\"reconciliation\"", "\"summary\"" },
["extract"] = new[] { "\"tables\"", "\"columns\"", "\"join_keys\"", "\"blocked_columns\"", "\"export_notes\"" },
["takeoff"] = new[] { "\"quantities\"", "\"gaps\"", "\"method_statement\"", "\"reconciliation\"", "\"summary\"" },
["rfi"] = new[] { "\"requests\"", "\"acceptance\"", "\"email_draft\"", "\"reissue_criteria\"", "\"summary\"" },
};
var streamReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Add("Authorization", "Bearer " + token);
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
using var http2 = new HttpClient();
var streamRes = await http2.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string ev = "", stage = "reading the digest";
while (await reader.ReadLineAsync() is { } line) {
if (line.StartsWith("event: ")) {
ev = line[7..].Trim();
} else if (line.StartsWith("data: ") && ev == "delta") {
using var d = JsonDocument.Parse(line[6..]);
raw.Append(d.RootElement.GetProperty("text").GetString());
for (var i = 0; i < markers[lane].Length; i++) {
if (raw.ToString().Contains(markers[lane][i])) stage = $"step {i + 1}: {markers[lane][i]}";
}
} else if (line.StartsWith("data: ") && ev == "done") {
Console.WriteLine(stage + " " + line[6..]);
}
}
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.
| key | type | meaning |
|---|---|---|
lane | string | The 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. |
title | string | A short name for this review, naming the model. |
posture | enum | ready 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. |
verdict | string | One 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_summary | string | Two 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. |
summary | string | One 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
| field | values |
|---|---|
posture | ready, conditional, blocked |
findings[].severity | critical, high, medium, low |
reconciliation[].status | confirmed, refined, not_material, disputed |
checks[].status | pass, partial, fail, unknown |
risk_register[].likelihood, .impact, quantities[].confidence | high, medium, low |
columns[].type | string, number, boolean, date |
quantities[].measure | area, volume, length, count, weight |
quantities[].basis | read, derive, impossible |
requests[].priority | blocker, 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.
| # | id | name | the requirement it tests |
|---|---|---|---|
| 1 | C-SCHEMA | Schema and header | FILE_SCHEMA names a current IFC schema and the file closes properly. |
| 2 | C-MVD | View definition | FILE_DESCRIPTION declares the ViewDefinition the export was made for. |
| 3 | C-SPATIAL | Spatial structure | IfcProject aggregates a Site, a Building and at least one Storey. |
| 4 | C-CONTAIN | Element containment | Every element is contained in a spatial structure element. |
| 5 | C-STOREY | Storey elevations | Every storey has a distinct Elevation and holds elements. |
| 6 | C-GUID | GlobalId integrity | Every GlobalId is a valid, unique 22-character IFC id. |
| 7 | C-REFS | Reference integrity | Every #n reference resolves to an instance in this file. |
| 8 | C-NAMING | Naming | Elements carry a Name a human can read in a schedule. |
| 9 | C-TYPES | Type objects | Occurrences are linked to type objects by IfcRelDefinesByType. |
| 10 | C-PSETS | Property sets | Elements carry property sets, including their Pset_*Common set. |
| 11 | C-QTO | Quantities | Elements carry IfcElementQuantity so measures can be read, not derived. |
| 12 | C-MATERIAL | Materials | Elements are associated with a material or a layer set. |
| 13 | C-CLASS | Classification | Elements reference an external classification for cost coding. |
| 14 | C-UNITS | Units and georeferencing | A 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:
laneequals thetaskyou sent — otherwise the model picked a lane for itself.reconciliationcovers every flag id you sent, exactly once, and introduces none.- The lane's own required array is present and non-empty:
checksforaudit(all fourteen ids),tablesforextract,quantitiesorgapsfortakeoff,requestsforrfi. truncatedisfalseon the finished job.postureis what your gate accepts — normallyready, orreadyandconditionalwhile 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
CHECK_IDS = ["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"]
def assert_contract(review, sent_input, job):
# 1. The lane that was answered is the lane that was asked for.
assert review["lane"] == sent_input["task"], f"lane mismatch: {review['lane']}"
# 2. Every flag id sent came back exactly once, and none were invented.
sent = [f["id"] for f in sent_input["prescan_facts"]["flags"]]
back = [r["flag_id"] for r in review["reconciliation"]]
assert sorted(back) == sorted(sent), f"reconciliation {sorted(back)} != flags {sorted(sent)}"
assert len(set(back)) == len(back), "a flag was reconciled more than once"
# 3. The lane's own required array is present, and audit returns all fourteen.
if review["lane"] == "audit":
assert [c["id"] for c in review["checks"]] == CHECK_IDS, "checks are not the fourteen, in order"
elif review["lane"] == "extract":
assert review["tables"], "no tables"
elif review["lane"] == "takeoff":
assert review["quantities"] or review["gaps"], "neither quantities nor gaps"
elif review["lane"] == "rfi":
assert review["requests"], "no requests"
for r in review["requests"]:
assert r["subject"].lower() in review["email_draft"].lower(), \
f"the email draft never mentions {r['id']}"
# 4 and 5. Nothing was cut, and the posture clears the gate.
assert not job.get("truncated"), "the reply was truncated - retry with a retry_note"
return review["posture"] in ("ready", "conditional")
if not assert_contract(review, INPUT, job):
raise SystemExit(f"blocked: {review['verdict']}")
const CHECK_IDS = ["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"];
function assertContract(review, sentInput, job) {
const fail = (m) => { throw new Error(m); };
// 1. The lane that was answered is the lane that was asked for.
if (review.lane !== sentInput.task) fail(`lane mismatch: ${review.lane}`);
// 2. Every flag id sent came back exactly once, and none were invented.
const sent = sentInput.prescan_facts.flags.map((f) => f.id).sort();
const back = review.reconciliation.map((r) => r.flag_id).sort();
if (back.join() !== sent.join()) fail(`reconciliation ${back} != flags ${sent}`);
if (new Set(back).size !== back.length) fail("a flag was reconciled more than once");
// 3. The lane's own required array is present, and audit returns all fourteen.
if (review.lane === "audit") {
if (review.checks.map((c) => c.id).join() !== CHECK_IDS.join()) {
fail("checks are not the fourteen, in order");
}
} else if (review.lane === "extract" && !review.tables.length) fail("no tables");
else if (review.lane === "takeoff" && !review.quantities.length && !review.gaps.length) {
fail("neither quantities nor gaps");
} else if (review.lane === "rfi") {
if (!review.requests.length) fail("no requests");
for (const r of review.requests) {
if (!review.email_draft.toLowerCase().includes(r.subject.toLowerCase())) {
fail(`the email draft never mentions ${r.id}`);
}
}
}
// 4 and 5. Nothing was cut, and the posture clears the gate.
if (job.truncated) fail("the reply was truncated - retry with a retry_note");
return review.posture === "ready" || review.posture === "conditional";
}
if (!assertContract(review, INPUT, job)) {
throw new Error(`blocked: ${review.verdict}`);
}
var checkIDs = []string{"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"}
type reviewShape struct {
Lane string `json:"lane"`
Posture string `json:"posture"`
Verdict string `json:"verdict"`
Reconciliation []struct {
FlagID string `json:"flag_id"`
} `json:"reconciliation"`
Checks []struct {
ID string `json:"id"`
} `json:"checks"`
}
func assertContract(r reviewShape, sentFlagIDs []string, lane string, truncated bool) error {
// 1. The lane that was answered is the lane that was asked for.
if r.Lane != lane {
return fmt.Errorf("lane mismatch: %s", r.Lane)
}
// 2. Every flag id sent came back exactly once, and none were invented.
seen := map[string]int{}
for _, rec := range r.Reconciliation {
seen[rec.FlagID]++
}
for _, id := range sentFlagIDs {
if seen[id] != 1 {
return fmt.Errorf("flag %s reconciled %d times", id, seen[id])
}
delete(seen, id)
}
if len(seen) != 0 {
return fmt.Errorf("reconciliation invented %d ids", len(seen))
}
// 3. The audit lane returns all fourteen checks, in order.
if lane == "audit" {
if len(r.Checks) != len(checkIDs) {
return fmt.Errorf("got %d checks, want 14", len(r.Checks))
}
for i, c := range r.Checks {
if c.ID != checkIDs[i] {
return fmt.Errorf("check %d is %s, want %s", i, c.ID, checkIDs[i])
}
}
}
// 4 and 5. Nothing was cut, and the posture clears the gate.
if truncated {
return fmt.Errorf("the reply was truncated - retry with a retry_note")
}
if r.Posture != "ready" && r.Posture != "conditional" {
return fmt.Errorf("blocked: %s", r.Verdict)
}
return nil
}
static final List<String> CHECK_IDS = List.of(
"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");
/** Throws on any contract violation; returns true when the posture clears the gate. */
static boolean assertContract(Review review, String lane, List<String> sentFlagIds,
boolean truncated) {
// 1. The lane that was answered is the lane that was asked for.
if (!review.lane().equals(lane)) {
throw new IllegalStateException("lane mismatch: " + review.lane());
}
// 2. Every flag id sent came back exactly once, and none were invented.
var back = review.reconciliation().stream().map(Reconciliation::flagId).toList();
if (!List.copyOf(new java.util.TreeSet<>(back)).equals(
List.copyOf(new java.util.TreeSet<>(sentFlagIds)))) {
throw new IllegalStateException("reconciliation does not match the flags sent");
}
if (back.size() != new java.util.HashSet<>(back).size()) {
throw new IllegalStateException("a flag was reconciled more than once");
}
// 3. The audit lane returns all fourteen checks, in order.
if (review.lane().equals("audit")
&& !review.checks().stream().map(Check::id).toList().equals(CHECK_IDS)) {
throw new IllegalStateException("checks are not the fourteen, in order");
}
// 4 and 5. Nothing was cut, and the posture clears the gate.
if (truncated) throw new IllegalStateException("truncated - retry with a retry_note");
return review.posture().equals("ready") || review.posture().equals("conditional");
}
CHECK_IDS = %w[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].freeze
def assert_contract!(review, sent_input, job)
# 1. The lane that was answered is the lane that was asked for.
raise "lane mismatch: #{review['lane']}" unless review["lane"] == sent_input[:task]
# 2. Every flag id sent came back exactly once, and none were invented.
sent = sent_input[:prescan_facts][:flags].map { |f| f[:id] }.sort
back = review["reconciliation"].map { |r| r["flag_id"] }
raise "reconciliation #{back.sort} != flags #{sent}" unless back.sort == sent
raise "a flag was reconciled more than once" unless back.uniq.size == back.size
# 3. The lane's own required array is present, and audit returns all fourteen.
case review["lane"]
when "audit"
raise "checks are not the fourteen, in order" unless review["checks"].map { |c| c["id"] } == CHECK_IDS
when "extract"
raise "no tables" if review["tables"].empty?
when "takeoff"
raise "neither quantities nor gaps" if review["quantities"].empty? && review["gaps"].empty?
when "rfi"
raise "no requests" if review["requests"].empty?
review["requests"].each do |r|
next if review["email_draft"].downcase.include?(r["subject"].downcase)
raise "the email draft never mentions #{r['id']}"
end
end
# 4 and 5. Nothing was cut, and the posture clears the gate.
raise "truncated - retry with a retry_note" if job["truncated"]
%w[ready conditional].include?(review["posture"])
end
abort("blocked: #{review['verdict']}") unless assert_contract!(review, INPUT, job)
<?php
const CHECK_IDS = ["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"];
function assertContract(array $review, array $sentInput, array $job): bool {
// 1. The lane that was answered is the lane that was asked for.
if ($review["lane"] !== $sentInput["task"]) {
throw new RuntimeException("lane mismatch: " . $review["lane"]);
}
// 2. Every flag id sent came back exactly once, and none were invented.
$sent = array_column($sentInput["prescan_facts"]["flags"], "id");
$back = array_column($review["reconciliation"], "flag_id");
sort($sent);
$sortedBack = $back;
sort($sortedBack);
if ($sent !== $sortedBack) {
throw new RuntimeException("reconciliation does not match the flags sent");
}
if (count(array_unique($back)) !== count($back)) {
throw new RuntimeException("a flag was reconciled more than once");
}
// 3. The lane's own required array is present, and audit returns all fourteen.
switch ($review["lane"]) {
case "audit":
if (array_column($review["checks"], "id") !== CHECK_IDS) {
throw new RuntimeException("checks are not the fourteen, in order");
}
break;
case "extract":
if (empty($review["tables"])) { throw new RuntimeException("no tables"); }
break;
case "takeoff":
if (empty($review["quantities"]) && empty($review["gaps"])) {
throw new RuntimeException("neither quantities nor gaps");
}
break;
case "rfi":
if (empty($review["requests"])) { throw new RuntimeException("no requests"); }
break;
}
// 4 and 5. Nothing was cut, and the posture clears the gate.
if (!empty($job["truncated"])) {
throw new RuntimeException("truncated - retry with a retry_note");
}
return in_array($review["posture"], ["ready", "conditional"], true);
}
if (!assertContract($review, $INPUT, $job)) {
fwrite(STDERR, "blocked: " . $review["verdict"] . PHP_EOL);
exit(1);
}
static readonly string[] CheckIds = {
"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",
};
/// <summary>Throws on a contract violation; true when the posture clears the gate.</summary>
static bool AssertContract(JsonElement review, string lane, string[] sentFlagIds, bool truncated) {
// 1. The lane that was answered is the lane that was asked for.
var answered = review.GetProperty("lane").GetString();
if (answered != lane) throw new Exception($"lane mismatch: {answered}");
// 2. Every flag id sent came back exactly once, and none were invented.
var back = review.GetProperty("reconciliation").EnumerateArray()
.Select(r => r.GetProperty("flag_id").GetString()!).ToList();
if (back.Count != back.Distinct().Count()) throw new Exception("a flag was reconciled twice");
if (!back.OrderBy(x => x).SequenceEqual(sentFlagIds.OrderBy(x => x))) {
throw new Exception("reconciliation does not match the flags sent");
}
// 3. The audit lane returns all fourteen checks, in order.
if (lane == "audit") {
var ids = review.GetProperty("checks").EnumerateArray()
.Select(c => c.GetProperty("id").GetString()!).ToArray();
if (!ids.SequenceEqual(CheckIds)) throw new Exception("checks are not the fourteen, in order");
}
// 4 and 5. Nothing was cut, and the posture clears the gate.
if (truncated) throw new Exception("truncated - retry with a retry_note");
var posture = review.GetProperty("posture").GetString();
return posture is "ready" or "conditional";
}
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.