Driving DXF Desk from your own code
Everything the web app's four AI lanes do is available over HTTP. Send a task, the DXF text and the measurements you took of it, and get back one JSON object. The ASCII DXF reader the browser runs for free — the group-code tokeniser, the $INSUNITS and $MEASUREMENT unit resolution, the BLOCKS and INSERT expansion, the bulge-to-arc conversion, the contour stitching, the nesting by containment, every hole diameter, web, edge clearance, concave corner and flange, and the thickness-ratio rule table — is not recomputed server-side. If you drive the API directly you must send your own facts object, because that object is the only thing the model is held accountable to.
The one field that decides everything: task
Document this first because it selects the entire rest of the contract. task is one of four lanes, each with its own prompt, its own body shape and its own idea of what verdict means. They are meant to run in sequence over one file, and each one hands the next its conclusions.
task | The app's own label | The question it answers | body keys |
|---|---|---|---|
read | Read the file | What is this file, before anyone acts on it. Which layer means what, which contour is for what, what the units are, and which of the browser's readings are still guesses. | part_call, units_call, upload_ready, layer_roles, contour_notes, unresolved |
dfm | Check it will cut | Can this part be made, by the process, material and thickness_mm that were picked. The lane that stands between a user and a rejected upload. | process_note, rule_reviews, violations, secondary_ops, tolerance_note |
fix | Correct the part | Produce the change: an edit list a person applies in their own CAD tool, plus a parametric build123d script that regenerates the corrected part from named parameters. | edit_list, parameters, verification, residual |
order | Build the order packet | Turn the file and the order settings into what a shop needs, in the sequence they need it. | order_lines, service_sequence, hardware, cost_drivers, vendor_questions, upload_checklist |
Every lane's body also carries script and script_note. Absent or unrecognised, task is not an error: the model picks the closest lane, sets task_inferred to true, and sets task to the lane it actually answered, rather than blending two contracts into one reply. A reply naming no known lane at all is a parse failure, not a lane.
The request body is the input object
There is no {"input": ...} wrapper. Wrapping it returns 200 while hiding task from the model, which is the single most expensive mistake available here — you get a fluent answer to a question you did not ask. There is no X-App-Slug header either: app identity rides on the token, and the only place the slug dxf-desk appears is the body of POST /guest.
Worked request — task: "read"
A 120 by 60 mm plate on a layer called CUT, one 1 mm circle in it, and a line on a layer called BEND. Everything under facts was measured in the browser; the dxf_excerpt is the text those measurements came from.
POST /run
{
"task": "read",
"dxf_excerpt": "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\nCUT\n90\n4\n70\n1\n10\n0\n20\n0\n10\n120\n20\n0\n10\n120\n20\n60\n10\n0\n20\n60\n0\nCIRCLE\n8\nCUT\n10\n12\n20\n12\n40\n0.5\n0\nLINE\n8\nBEND\n10\n0\n20\n30\n11\n120\n21\n30\n0\nENDSEC\n0\nEOF",
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "Bracket for a rack shelf. The small circle is a pilot for a self-tapping screw.",
"facts": {
"readable": true,
"order": {"process": "laser_metal", "process_label": "Laser cut, sheet metal",
"material": "5052-H32 aluminium", "thickness_mm": 2,
"quantity": 5, "finish": "deburr", "finish_label": "Deburr and tumble only"},
"units": {"name": "millimetres", "mm_per_unit": 1,
"source": "$INSUNITS = 4 (millimetres)", "certain": true},
"file": {"sections": ["HEADER", "ENTITIES"], "entity_count": 3, "layer_count": 2,
"block_count": 0, "insert_count": 0, "acad_version": "(not stated)",
"chord_tolerance_mm": 0.02, "stitch_tolerance_mm": 0.01},
"part": {"width_mm": 120, "height_mm": 60, "area_mm2": 7199.21,
"outline_count": 1, "hole_count": 1, "island_count": 0,
"cut_length_mm": 363.14, "etch_length_mm": 0, "pierce_count": 2,
"entity_count": 3, "annotation_count": 0},
"layers": [
{"name": "CUT", "read_as": "cut", "confidence": "high",
"why": "the layer name matches cut naming", "declared_in_table": false,
"geometry_entities": 2, "annotation_entities": 0},
{"name": "BEND", "read_as": "bend", "confidence": "high",
"why": "the layer name matches bend naming", "declared_in_table": false,
"geometry_entities": 1, "annotation_entities": 0}
],
"contours": [
{"id": "C1", "role": "outline", "nesting_depth": 0, "inside": "",
"layers": "CUT", "entity_types": "LWPOLYLINE", "closed_by": "flag 70",
"circular": false, "area_mm2": 7200, "perimeter_mm": 360,
"bbox_mm": "120 x 60", "exact_circle_diameter_mm": null,
"min_width_mm": 60, "min_width_basis": "the shorter side of the bounding box",
"tightest_concave_radius_mm": null},
{"id": "C2", "role": "hole", "nesting_depth": 1, "inside": "C1",
"layers": "CUT", "entity_types": "CIRCLE", "closed_by": "CIRCLE",
"circular": true, "area_mm2": 0.79, "perimeter_mm": 3.14,
"centre_mm": "20, 30", "centre_basis": "exact CIRCLE centre",
"bbox_mm": "1 x 1", "bbox_min_mm": "19.5, 29.5", "bbox_max_mm": "20.5, 30.5",
"exact_circle_diameter_mm": 1,
"min_width_mm": 1, "min_width_basis": "an exact CIRCLE diameter from the file",
"tightest_concave_radius_mm": null}
],
"contours_omitted": 0,
"open_contours": [],
"open_contours_omitted": 0,
"bends": [
{"id": "B1", "layer": "BEND", "length_mm": 120, "line_angle_deg": 0,
"line_angle_note": "the bend line's orientation in the XY plane, NOT the fold angle",
"flange_a_mm": 30, "flange_b_mm": 30, "ends_short_of_outline_mm": 0,
"straight": true,
"nearest_openings": [{"id": "C2", "mm": 17.5}]}
],
"annotations": [],
"annotations_omitted": 0,
"closest_approaches": [
{"a": "C1", "b": "C2", "mm": 11.5, "kind": "edge", "at": {"x": 11.5, "y": 12}}
],
"measures": {"smallest_hole_mm": 1, "smallest_hole_id": "C2",
"smallest_hole_exact": true,
"min_web_mm": null, "min_web_at": null,
"min_hole_to_edge_mm": 11.5,
"min_hole_to_edge_at": {"a": "C1", "b": "C2", "mm": 11.5, "kind": "edge"},
"outline_min_width_mm": 60, "min_arc_radius_mm": 0.5,
"tightest_concave": null, "min_segment_mm": 60,
"min_segment_at": "C1", "duplicate_count": 0, "budget_hit": false},
"rules": [
{"id": "min_hole", "label": "Minimum hole diameter",
"basis": "1 x material thickness, with a 0.6 mm floor",
"limit_mm": 2, "measured_mm": 1, "pass": false, "where": "C2",
"note": "an exact CIRCLE diameter from the file"},
{"id": "hole_to_edge", "label": "Hole to part edge",
"basis": "1.5 x material thickness, with a 0.8 mm floor",
"limit_mm": 3, "measured_mm": 11.5, "pass": true, "where": "C1 to C2", "note": ""},
{"id": "min_feature", "label": "Minimum tab or finger width",
"basis": "1 x material thickness, with a 0.8 mm floor",
"limit_mm": 2, "measured_mm": 60, "pass": true, "where": "C1", "note": ""},
{"id": "min_flange", "label": "Minimum flange length",
"basis": "4 x material thickness, with a 4 mm floor",
"limit_mm": 8, "measured_mm": 30, "pass": true, "where": "B1", "note": ""},
{"id": "hole_to_bend", "label": "Hole to bend line",
"basis": "2.5 x material thickness, with a 1.5 mm floor",
"limit_mm": 5, "measured_mm": 17.5, "pass": true, "where": "B1/C2", "note": ""},
{"id": "bend_radius", "label": "Inside bend radius",
"basis": "1 x material thickness, with a 0.5 mm floor",
"limit_mm": 2, "measured_mm": null, "pass": null, "where": "",
"note": "an inside bend radius cannot be measured from a flat pattern"}
],
"rules_provenance": "These minimums are ordinary published sheet-metal guidance expressed as multiples of material thickness. They are NOT any particular vendor's catalogue, and this app cannot fetch one. Treat them as the conservative starting point and say so.",
"sheet_fit": {"fits": [{"sheet": "600 x 600 (small tray)", "fits": true},
{"sheet": "1220 x 610 (4ft x 2ft)", "fits": true}],
"smallest_fitting": {"sheet": "600 x 600 (small tray)", "fits": true}},
"approximations": [],
"parse_warnings": [],
"flags": [
{"id": "DX-001", "severity": "high", "code": "rule_min_hole", "at": "C2",
"label": "Minimum hole diameter: 1 mm measured against a 2 mm limit",
"detail": "1 x material thickness, with a 0.6 mm floor, at 2 mm thickness."}
]
}
}
Worked request — task: "dfm"
Same file, same measurements, different question. The one new field is prior_read: the previous read reply's own conclusions, handed forward so this lane builds on the reading instead of re-deriving it. Send the whole facts object again — runs are stateless and nothing is remembered between them.
POST /run
{
"task": "dfm",
"dxf_excerpt": "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n...",
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "Bracket for a rack shelf. The small circle is a pilot for a self-tapping screw.",
"facts": { "...": "the identical measured object shown above, verbatim" },
"prior_read": {
"verdict": "fix-first",
"part_call": "A 120 by 60 mm formed bracket: one rectangular blank, one bend across
the middle, one pilot hole in the lower flange.",
"units_call": "Millimetres, stated outright by $INSUNITS = 4. Not in doubt.",
"upload_ready": "with-fixes",
"layer_roles": [
{"layer": "CUT", "role": "cut", "agrees_with_prescan": true},
{"layer": "BEND", "role": "bend", "agrees_with_prescan": true}
],
"contour_actions": [
{"id": "C1", "action": "keep"},
{"id": "C2", "action": "resize"}
],
"findings": [
{"id": "DXR-001", "severity": "high", "location": "C2",
"title": "the 1 mm pilot is under the laser's minimum for 2 mm sheet"}
]
}
}
prior_read is exactly the subset the app forwards: verdict, part_call, units_call, upload_ready, a layer_roles list trimmed to {layer, role, agrees_with_prescan}, a contour_actions list trimmed to {id, action}, and a findings list trimmed to {id, severity, location, title}. Nothing else from the read reply travels.
Worked request — task: "fix"
The fix lane wants the failure it is being asked to correct, so the handoff matters more here than anywhere else. context_note is where you say what the part is for, because that is what decides whether a 1 mm hole gets enlarged, drilled after cutting, or defended.
POST /run
{
"task": "fix",
"dxf_excerpt": "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n...",
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "The pilot has to stay under 2.5 mm or the screw strips. If it cannot
be cut, I would rather drill it after cutting than move it.",
"facts": { "...": "the identical measured object shown above, verbatim" },
"prior_read": { "...": "the same trimmed read-lane object shown above" }
}
Worked request — task: "order"
The order lane is the one where quantity and finish stop being metadata and start changing the answer. At 1 off a hand deburr is fine; at 500 a tapping service and a fixture pay for themselves. finish: "anodise" on a steel material is a contradiction the lane is expected to catch and raise, not silently accept.
POST /run
{
"task": "order",
"dxf_excerpt": "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n...",
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 500,
"finish": "anodise",
"context_note": "Production run. The bracket bolts to an extrusion and the anodised
face is visible.",
"facts": { "...": "the identical measured object shown above, verbatim" },
"prior_read": { "...": "the same trimmed read-lane object shown above" }
}
Two fields not shown above appear only in specific circumstances, and both are documented in full in the input table further down: clip_note, which the app sends when the DXF text was too large to send whole, and retry_note, which it sends when a previous reply failed to parse.
Base URL and headers
| Thing | Value |
|---|---|
| Base URL | https://api.skillsafe.ai/v1/app-api |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model |
| App identity | carried by the token. There is no X-App-Slug header. The one place the slug appears is the body of POST /guest, which is what binds the minted token to this app |
| Idempotency | Idempotency-Key: <string> on /run and /run-stream |
| Call | Path | Costs |
|---|---|---|
| Mint a guest token | POST /v1/app-api/guest | free, and the only call whose body is not a lane input |
| Who am I | GET /v1/app-api/me | free |
| Price an input | POST /v1/app-api/estimate | free, creates no job |
| Run a lane | POST /v1/app-api/run | metered; reserves hold_credits |
| Run a lane, streamed | POST /v1/app-api/run-stream | metered; the same run as /run |
| Poll a job | GET /v1/app-api/jobs/{job_id} | free |
Whose drawing is this
Part geometry is commercial property. Three things follow, and they are different from the web app's situation because you are now the one assembling the request.
First, in the browser nothing leaves the tab until a lane runs. The reader, the contour stitching, the geometry, the rule table and all six exports are JavaScript in the page and need no account at all. If all you want is the measurement, use the app and send nothing anywhere.
Second, the run is stateless. No DXF you send is retained beyond the run that used it, and continuity between lanes is something you pass in via prior_read.
Third, the excerpt is text you chose. The browser clips dxf_excerpt at 26,000 characters on whole group-code pair boundaries, taking entities out of the middle and keeping both ends, and it sends clip_note when it does. A title block full of a customer's name is text you sent; nothing here strips it for you.
The response envelope
Every response, success or failure, is the same shape. Read ok before you touch data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "payment_required", "message": "...", "details": { ... }}}
Error codes
| HTTP | error.code | What it means and what to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The body was not a JSON object, or a field was the wrong type — thickness_mm as a string, facts as an array, quantity as null. Fix the body; a retry will not help. Note what this is not: an unknown task is not a validation error, because the model infers a lane and reports task_inferred. |
| 400 | invalid_request | POST /guest without a slug. The slug is what binds the token to this app. |
| 401 | unauthorized | No token, a malformed one, or one that has expired. Mint a new guest token (step 2), or sign in on the token page for a personal one. |
| 402 | payment_required | The balance cannot cover min_credits. Check /estimate against /me before submitting, which is exactly what the app does so this never fires: its run button is disabled with the shortfall named rather than submitting into a 402. |
| 404 | not_found | A job id that does not exist, one belonging to another subject, or a slug on /guest that is not a deployed app. |
| 409 | idempotency_conflict | The same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter. |
| 429 | rate_limited | Back off and retry. Never tight-loop. |
| 500 | internal | Retry once with the same idempotency key, which is exactly what the key is for. |
truncated: true is not an error. It comes back on a successful job, alongside charged_credits, and it means the run started with a reduced output cap because the balance covered min_credits but not hold_credits. The lane ran, it was charged, and the reply is real but cut short: the JSON may end mid-document, and if it does, the client's own parse fails and it retries once with a reformat instruction in retry_note. Treat truncated: true as "this answer is incomplete, say so to your user" — never as "this answer is finished". A fix lane reply whose script stops halfway through a BuildSketch block is the failure mode to expect.
Step 1 — a tiny client
Everything below uses this one helper. It does the single thing that matters: it reads the envelope and raises on ok: false, because an error response is still HTTP-shaped JSON and ignoring it turns a 402 into a confusing null three lines later. Tokens come from the token page — it shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one — or from POST /guest in step 2.
# The whole client is two variables and curl.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN # from https://dxf-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
}
# Every response is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# Check ok before you read data, or a 402 becomes a confusing null three lines on.
check() { python3 -c '
import json,sys
p = json.load(sys.stdin)
if not p.get("ok"):
e = p.get("error") or {}
sys.exit(str(e.get("code")) + ": " + str(e.get("message")))
print(json.dumps(p["data"], indent=2))
'; }
import json
import urllib.error
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://dxf-desk.skillsafe.ai/tokens.html
class ApiError(Exception):
def __init__(self, code, message, details=None):
super().__init__("%s: %s" % (code, message))
self.code, self.message, self.details = code, message, details or {}
def call(path, body=None, token=None, extra_headers=None):
"""POST when there is a body, GET when there is not. Raises on ok:false."""
headers = {"Authorization": "Bearer " + (token or TOKEN)}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
headers.update(extra_headers or {})
req = urllib.request.Request(BASE + path, data=data, headers=headers)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e) # errors are JSON too - read them
if not payload.get("ok"):
err = payload.get("error") or {}
raise ApiError(err.get("code", "unknown"), err.get("message", ""), err.get("details"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://dxf-desk.skillsafe.ai/tokens.html
class ApiError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details || {};
}
}
async function call(path, body, token, extraHeaders) {
const headers = { Authorization: `Bearer ${token || TOKEN}`, ...(extraHeaders || {}) };
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json(); // an error response is JSON as well
if (!payload.ok) {
throw new ApiError(payload.error?.code, payload.error?.message, payload.error?.details);
}
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Read it from the environment with os.Getenv, or paste one from
// https://dxf-desk.skillsafe.ai/tokens.html
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
// call POSTs when body is non-nil and GETs when it is nil. It returns the raw
// data member so each step can unmarshal into whatever shape it needs.
func call(path string, body any, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
method := http.MethodGet
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
method = http.MethodPost
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
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.*;
import java.util.Map;
public final class DxfDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // dxf-desk.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static class ApiException extends RuntimeException {
ApiException(String m) { super(m); }
}
/** POST when body is non-null, GET otherwise. Throws on ok:false. */
static String call(String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String body = res.body();
// Any real client parses this with Jackson or Gson; the point here is
// only that ok:false must be read before data is touched.
if (body.contains("\"ok\":false")) throw new ApiException(body);
return body;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://dxf-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code = code
@details = details
end
end
# POST when a body is given, GET when it is not. Raises on ok:false.
def call(path, body = nil, extra = {})
uri = URI(BASE + path)
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
extra.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)
unless payload["ok"]
e = payload["error"] || {}
raise ApiError.new(e["code"], e["message"], e["details"])
end
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://dxf-desk.skillsafe.ai/tokens.html
class ApiError extends Exception {
public $apiCode;
public $details;
public function __construct($code, $message, $details = []) {
parent::__construct("$code: $message");
$this->apiCode = $code;
$this->details = $details;
}
}
/** POST when $body is given, GET when it is null. Throws on ok:false. */
function call(string $path, $body = null, array $extra = []) {
$headers = array_merge(["Authorization: Bearer " . TOKEN], $extra);
$opts = ["http" => [
"method" => $body === null ? "GET" : "POST",
"ignore_errors" => true, // read the JSON body of a 4xx too
]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
$e = $payload["error"] ?? [];
throw new ApiError($e["code"] ?? "unknown", $e["message"] ?? "", $e["details"] ?? []);
}
return $payload["data"];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class DxfDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // dxf-desk.skillsafe.ai/tokens.html
static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public string Code;
public ApiException(string code, string message) : base(code + ": " + message)
=> Code = code;
}
/// POST when body is non-null, GET otherwise. Throws on ok:false.
public static async Task<JsonElement> Call(
string path, object body = null, Dictionary<string, string> extra = null)
{
var req = new HttpRequestMessage(
body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (extra != null)
foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
if (body != null)
req.Content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new ApiException(e.GetProperty("code").GetString(),
e.GetProperty("message").GetString());
}
return payload.GetProperty("data");
}
}
Step 2 — get a token
The friendly route is the token page. Programmatically, POST /guest is the whole story, and it is the one call whose body is not a lane input: it takes {"slug": "dxf-desk"}, and that slug is what binds the returned token to this app. Omit it and you get a 400 invalid_request; misspell it and you get a 404 not_found. Everything after this reads the app identity off the token, which is why no later call carries a slug and there is no X-App-Slug header anywhere.
What a guest token can do: GET /me, POST /estimate, and GET /jobs/{id} for jobs the guest itself created. That is enough to validate an input shape, learn which model the app is bound to and price a lane, all without an account. What it cannot do: run a metered lane. POST /run and POST /run-stream need a personal token — a guest wallet has no credits, so the run fails the balance check rather than the auth check, which is why a guest attempt usually surfaces as 402 payment_required and not 401. The exception is a sponsored app: /estimate returns sponsor_enabled, and when that is true the app's owner is paying and a guest can run. The web app checks exactly that before it decides whether to ask you to sign in. A guest token expires roughly a month out, at data.expires_at.
# mint a guest token - free, and enough for /me and /estimate
call /guest '{"slug": "dxf-desk"}' | check
# data.token is the bearer, data.guest_id identifies the wallet, and
# data.expires_at is roughly a month out. Put the token in TOKEN and carry on.
# mint a guest token - free, and enough for /me and /estimate
data = call("/guest", {"slug": "dxf-desk"})
print(data["token"], data["guest_id"], data["expires_at"])
# Running a lane is metered: swap in a personal token from
# https://dxf-desk.skillsafe.ai/tokens.html before step 5.
TOKEN = data["token"]
// mint a guest token - free, and enough for /me and /estimate
const guest = await call("/guest", { slug: "dxf-desk" });
console.log(guest.token, guest.guest_id, guest.expires_at);
// Running a lane is metered: use a personal token from
// https://dxf-desk.skillsafe.ai/tokens.html before step 5.
// mint a guest token - free, and enough for /me and /estimate
raw, err := call("/guest", map[string]any{"slug": "dxf-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
}
json.Unmarshal(raw, &guest)
fmt.Println(guest.Token, guest.GuestID, guest.ExpiresAt)
// mint a guest token - free, and enough for /me and /estimate
String data = DxfDesk.call("/guest", "{\"slug\": \"dxf-desk\"}", null);
System.out.println(data); // token, guest_id, expires_at
// Running a lane is metered: use a personal token from
// https://dxf-desk.skillsafe.ai/tokens.html before step 5.
# mint a guest token - free, and enough for /me and /estimate
guest = call("/guest", { "slug" => "dxf-desk" })
puts guest["token"], guest["guest_id"], guest["expires_at"]
<?php
// mint a guest token - free, and enough for /me and /estimate
$guest = call("/guest", ["slug" => "dxf-desk"]);
echo $guest["token"], " ", $guest["guest_id"], " ", $guest["expires_at"], "\n";
// mint a guest token - free, and enough for /me and /estimate
var guest = await DxfDesk.Call("/guest", new { slug = "dxf-desk" });
Console.WriteLine(guest.GetProperty("token").GetString());
Console.WriteLine(guest.GetProperty("expires_at").GetString());
Step 3 — who am I, and can I afford this
GET /me returns the subject and the balance. Comparing it against /estimate before you submit is what turns a 402 from an error your user sees into a button you disabled. It is also how you tell a guest token from a personal one: subject_type reads guest on the first and user on the second, and only the second can run a metered lane on an unsponsored app. credits is the spendable balance in the same unit hold_credits and min_credits are quoted in, so the comparison is a plain integer comparison and nothing needs converting.
# subject_type, username, credits
call /me | check
# subject_type, username, credits
me = call("/me")
print(me["subject_type"], me.get("username"), me.get("credits"))
# The app's own preflight, in one line: never submit into a 402.
can_run = me["subject_type"] == "user" and me.get("credits", 0) >= est["min_credits"]
// subject_type, username, credits
const me = await call("/me");
console.log(me.subject_type, me.username, me.credits);
// The app's own preflight: disable the button, name the shortfall, never 402.
const canRun = me.subject_type === "user" && (me.credits || 0) >= est.min_credits;
// subject_type, username, credits
raw, err := call("/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Username string `json:"username"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Username, me.Credits)
// subject_type, username, credits
String data = DxfDesk.call("/me", null, null);
System.out.println(data);
# subject_type, username, credits
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
// subject_type, username, credits
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"] ?? 0, "\n";
// subject_type, username, credits
var me = await DxfDesk.Call("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
The input object, field by field
This is the whole contract on the way in. It is what readForm() in the app's own app.js builds, field for field, so what the browser sends and what you send are the same object. All lengths everywhere are millimetres.
| Field | Type | Meaning |
|---|---|---|
task | string, required | Documented first, above, because it selects everything else. One of read, dfm, fix, order. Absent or unrecognised, the model picks the closest lane, sets task_inferred to true and sets task to the lane it actually answered, rather than blending two contracts into one reply. |
dxf_excerpt | string, required | The DXF group-code text: alternating code and value lines, two lines per pair. ASCII DXF only — a binary DXF or a DWG is refused by name rather than parsed into nonsense, and there is no server-side reader to feed either to. The browser clips this at 26,000 characters on whole entity boundaries, taking pairs out of the middle and keeping both ends, and inserts a 999 comment pair where the cut fell; when it clips, it also sends clip_note. |
process | string, required | One of exactly five: laser_metal (fibre laser, sheet metal — bending, tapping and countersinking are normal secondary operations), laser_acrylic (CO2 laser through acrylic, plywood or MDF — nothing bends, kerf is wide, engraving is a real operation), waterjet (abrasive, no heat-affected zone, any thickness, but the stream tapers and minimum features are larger than a laser's), router_alu (a spinning end mill — every internal corner carries the tool radius and the tool diameter is the floor on any slot), plasma (thicker plate, widest kerf, loosest tolerance). This picks the rule set, so it changes which limits exist at all: internal_radius only exists on router_alu; bend_radius, min_flange and hole_to_bend only on laser_metal; and only laser_metal, waterjet and plasma can form material at all. |
material | string | Free text, and it may be empty — "5052-H32 aluminium", "304 stainless, 2B", "cast acrylic". It is not parsed and it does not change a limit; it changes the advice. An anodise finish on a steel material is a contradiction the order lane is expected to raise. |
thickness_mm | number, required | Material thickness in millimetres. Every rule limit is computed from it: a limit is max(ratio × thickness_mm, floor_mm). Get this wrong and every limit in the reply is wrong with it. Each process also has a max_thickness_mm beyond which it raises a flag: 12 for laser_metal, 25 for laser_acrylic and router_alu, 40 for plasma, 150 for waterjet. |
quantity | integer, required | How many parts. It changes the advice, never the geometry. At 1 off a hand deburr is fine and a jig is absurd; at 500 a tapping service, a fixture and nesting all start to pay. The order lane leans on this hardest. |
finish | string, required | One of exactly seven: none (as cut), deburr (deburr and tumble only), anodise (aluminium only), powder_coat, plate (zinc or nickel), brush (brushed or polished), paint (wet paint). It drives the masking questions and, more importantly, the sequence in service_sequence: anodising before tapping changes the thread fit. |
context_note | string, optional | Free text. What the user says the part is, or what went wrong with it. The single most useful optional field: it outranks the model's assumptions about intent, and it is what turns a flagged 0.8 mm slot into a deliberate flexure that still cannot be cut. It never outranks a measurement. |
facts | object, optional but the point of the whole thing | Everything the browser measured. See below — this is the field that decides whether you get an audited answer or an unaudited one. |
prior_read | object, optional | The handoff, sent by the three later lanes when the user came from read. Exactly {verdict, part_call, units_call, upload_ready, layer_roles[], contour_actions[], findings[]}, where layer_roles[] entries are trimmed to {layer, role, agrees_with_prescan}, contour_actions[] to {id, action}, and findings[] to {id, severity, location, title}. With it present, the later lane agrees with, refines or explicitly disagrees with the reading instead of re-deriving it; without it, the four lanes read as four unrelated tools over one file. |
clip_note | string, optional | Present only when dxf_excerpt was clipped. It says what was cut and states that every number in facts was measured over the complete file, so the model treats facts as authoritative about counts and geometry and the excerpt as a sample it may quote from. Without it, a model that sees 40 entities in the text and 900 in facts has no way to know which to believe. |
retry_note | string, optional | Only when a previous reply failed to parse. The app puts the exact reformat instruction here — name the envelope, every array present even when empty, findings ids sequential from DXR-001, one reconciliation entry per critical and high flag, the lane's body exactly as specified, no prose and no code fence — and it bumps the attempt counter in the idempotency key, so the retry is a deliberate second run rather than an accidental duplicate. It retries once and once only. |
Sending your own facts
Be clear about what this object is, because it is easy to mistake it for decoration. facts is what a real ASCII DXF reader running in the user's own tab measured: the millimetres-per-unit resolved from $INSUNITS and $MEASUREMENT and whether that resolution is certain; the sections, layer table, blocks and inserts present; the part's size, net area, cut length, etch length and pierce count; one row per closed contour with its role, its nesting, its area, its bounding box, its exact circle diameter where the file drew a real CIRCLE, the narrowest place on it and the basis of that number; one row per chain that did not close with the gap size and both endpoints; one row per bend line with its length, angle, flanges and nearest openings; the annotations and what layer they sit on; the tightest contour-to-contour approaches labelled web or edge; the headline measures; one row per checked rule with its ratio, its limit, the measured value and pass true, false or null; which stock sheets the part fits; every place a number is a bound rather than a value; and a numbered flags array.
Three consequences follow, and they are the reason to send it:
- The measurements are the ground truth and the model is told so. House rule one: never contradict a number in
facts. The model may say a measurement is irrelevant, or that it is a bound rather than a value whereapproximationssays so, but it may not restate it differently. Iffacts.measures.smallest_hole_mmis 1, the smallest opening is 1 mm and nothing in the reply may say otherwise. - Every
criticalandhighflag must be answered, by id. The model owes exactly onereconciliationentry per required flag — no more, no fewer — naming the flag's own id. A required flag with no entry is displayed as unaccounted for, which is what makes a reply auditable rather than merely fluent.mediumandlowflags may be answered but need not be. - Every id the model cites is checked back against it. A
location,at,rule_idorlayerthat is not a real contour, open contour, bend, annotation, rule, flag or layer from yourfactsis kept and marked ungrounded, never silently dropped. So an invented hole is visible on screen to your users, not only to you. Same for the returned script: every layer name it reads is checked, in all four ezdxf idioms.
So: omit facts and the model works from your raw DXF text alone. It will still answer, and the answer will read exactly as confident. But nothing holds it to a number, no reconciliation is possible, no citation can be grounded, and the app's own honesty machinery — the ungrounded-id marking, the unanswered-flag count, the uncovered-layer list on read, the unreviewed-rule list on dfm — has nothing to compare against. That is a much weaker answer, and it is weaker in a way that is invisible unless you know to look for it. A model asked to eyeball a group-code stream will happily tell you a hole is 3 mm.
And fabricating a facts object is worse than omitting it. You would not be decorating a prompt; you would be lying to a model that has been instructed to trust you over its own reading of the file, about the numbers a part gets cut to. If you did not measure it, do not assert it — use null, which the contract already understands as "not measured", and which produces pass: null rather than a false pass. If you measured over a clipped excerpt, say so in clip_note. If the input did not parse at all, send the honest failure shape, {"readable": false, "reason": "..."}, and the model returns verdict: "unreadable" instead of inventing a part.
A minimal hand-written facts that is already worth sending
You do not need the whole object. Everything below is either measurable with a few lines of ezdxf or already known to you, and it is enough for the model to be held to a number, for a rule to be reviewed and for a flag to be reconciled. Omit any key you did not measure rather than guessing at it; a measured_mm of null gives pass: null, which the lanes handle explicitly.
{
"readable": true,
"units": {"name": "millimetres", "mm_per_unit": 1,
"source": "$INSUNITS = 4 (millimetres)", "certain": true},
"part": {"width_mm": 120, "height_mm": 60, "area_mm2": 7199.21,
"outline_count": 1, "hole_count": 1, "island_count": 0,
"cut_length_mm": 363.14, "pierce_count": 2},
"layers": [
{"name": "CUT", "read_as": "cut", "confidence": "high", "geometry_entities": 2},
{"name": "BEND", "read_as": "bend", "confidence": "high", "geometry_entities": 1}
],
"contours": [
{"id": "C1", "role": "outline", "area_mm2": 7200, "bbox_mm": "120 x 60",
"min_width_mm": 60},
{"id": "C2", "role": "hole", "inside": "C1", "exact_circle_diameter_mm": 1,
"min_width_mm": 1, "min_width_basis": "an exact CIRCLE diameter from the file"}
],
"open_contours": [],
"measures": {"smallest_hole_mm": 1, "smallest_hole_id": "C2",
"min_hole_to_edge_mm": 11.5, "min_web_mm": null},
"rules": [
{"id": "min_hole", "label": "Minimum hole diameter",
"basis": "1 x material thickness, with a 0.6 mm floor",
"limit_mm": 2, "measured_mm": 1, "pass": false, "where": "C2"}
],
"flags": [
{"id": "DX-001", "severity": "high", "code": "rule_min_hole", "at": "C2",
"label": "Minimum hole diameter: 1 mm measured against a 2 mm limit",
"detail": "1 x material thickness, with a 0.6 mm floor, at 2 mm thickness."}
]
}
Flag ids are DX-001, DX-002, ... in the order they were raised, and they are stable for a given input, which is what lets a reconciliation entry point at one. Any id scheme works as long as your own reconciliation matching agrees with it. severity is critical, high, medium or low; only the first two require an answer. Contour ids run C1, C2, ...; open contours O1, O2, ...; bends B1, B2, ...; annotations A1, A2, ....
One more rule about citation, because it governs the output too. The strings a reply is allowed to point at are: a contour id, an open-contour id, a bend id, a bend-and-opening pair written B1/C5, an annotation id, a rule id (with or without a leading rule ), a flag id, a layer name (with or without a leading layer ), a closest-approach pair written C1 to C2, the literals $INSUNITS and $MEASUREMENT, or the bare word part. Anything else is kept and shown marked. The one exception is order_lines[].source, which is provenance prose rather than a pointer: it counts as grounded when it contains a real id, or when it says measured, derived, computed, browser, prescan, facts, stated, the form, this app or order form, because "measured: 421.3 mm of cut length" is the right answer to "where did this come from" and demanding a bare id there would force something less useful.
Step 4 — estimate, free
/estimate creates no job and charges nothing. It is also the authoritative check that your input shape is valid and that the app is bound to the model you think it is: the reply carries model, model_alias and markup_bps alongside hold_credits, min_credits, sponsor_enabled and byok. Estimate per lane and per input: the four lanes have different prompts and different output caps, so one lane's hold is not another lane's price, and a fix reply carrying a full parametric script is not priced like a read reply.
| Field | What it means |
|---|---|
hold_credits | The worst case that gets reserved when the run starts. Show it as RESERVED, never as the price — it prices the full output cap, and almost no run reaches it. |
min_credits | The floor below which the run will not start at all. Below this you get 402 payment_required. Between min_credits and hold_credits the run executes with a reduced cap and comes back truncated: true. |
model | The exact model id the run will be executed on. This is what "bound to" means: it is not a preference, it is what you will be charged for. |
model_alias | The friendlier name for the same thing, suitable for showing a user. |
markup_bps | The app's markup over raw model cost, in basis points. 0 means none. |
sponsor_enabled | True when the app owner is paying, which is the one case where a guest subject can run a metered lane. The web app calls /estimate specifically to read this before deciding whether to prompt for sign-in. |
byok | True when the run will use your own provider key rather than metered credits. |
INPUT='{
"task": "dfm",
"dxf_excerpt": "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nCIRCLE\n8\nCUT\n10\n12\n20\n12\n40\n0.5\n0\nENDSEC\n0\nEOF",
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "Bracket for a rack shelf.",
"facts": {"readable": true,
"measures": {"smallest_hole_mm": 1, "smallest_hole_id": "C2"},
"...": "the rest of the measured object goes here"}
}'
# Free. No job is created and nothing is charged.
call /estimate "$INPUT" | check
# hold_credits - reserved worst case. Show it as RESERVED, never as the price.
# min_credits - below this the run will not start at all.
# model, model_alias, markup_bps - what you are actually bound to.
# sponsor_enabled - when true, a guest subject may run this app.
input_obj = {
"task": "dfm",
"dxf_excerpt": open("bracket.dxf").read(),
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "Bracket for a rack shelf.",
# facts is what the model is held accountable to. Send yours - see above.
"facts": {
"readable": True,
"units": {"name": "millimetres", "mm_per_unit": 1, "certain": True},
"part": {"width_mm": 120, "height_mm": 60, "cut_length_mm": 363.14},
"contours": [], # C1, C2, ... with roles, areas and narrowest widths
"open_contours": [], # O1, ... the ones that did not close
"rules": [], # one row per checked minimum, with pass true/false/null
"flags": [], # each critical/high one must come back reconciled
},
}
est = call("/estimate", input_obj) # free: no job, no charge
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
# The hold prices the full output cap. Between min_credits and hold_credits the
# run still executes with a reduced cap and comes back "truncated": true.
import { readFileSync } from "node:fs";
const inputObj = {
task: "dfm",
dxf_excerpt: readFileSync("bracket.dxf", "utf8"),
process: "laser_metal",
material: "5052-H32 aluminium",
thickness_mm: 2,
quantity: 5,
finish: "deburr",
context_note: "Bracket for a rack shelf.",
// facts is what the model is held accountable to. Send yours - see above.
facts: {
readable: true,
units: { name: "millimetres", mm_per_unit: 1, certain: true },
part: { width_mm: 120, height_mm: 60, cut_length_mm: 363.14 },
contours: [],
open_contours: [],
rules: [],
flags: [],
},
};
const est = await call("/estimate", inputObj); // free: no job, no charge
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved:", est.hold_credits, "minimum:", est.min_credits);
dxf, _ := os.ReadFile("bracket.dxf")
inputObj := map[string]any{
"task": "dfm",
"dxf_excerpt": string(dxf),
"process": "laser_metal",
"material": "5052-H32 aluminium",
"thickness_mm": 2,
"quantity": 5,
"finish": "deburr",
"context_note": "Bracket for a rack shelf.",
// facts is what the model is held accountable to - see above.
"facts": map[string]any{
"readable": true,
"units": map[string]any{"name": "millimetres", "mm_per_unit": 1, "certain": true},
"part": map[string]any{"width_mm": 120, "height_mm": 60, "cut_length_mm": 363.14},
"contours": []any{},
"rules": []any{},
"flags": []any{},
},
}
raw, err := call("/estimate", inputObj, nil) // free: no job, no charge
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
Sponsored bool `json:"sponsor_enabled"`
}
json.Unmarshal(raw, &est)
fmt.Printf("%s (%s) reserve %d min %d\n",
est.Model, est.ModelAlias, est.HoldCredits, est.MinCredits)
String dxf = Files.readString(Path.of("bracket.dxf"));
// Build this with Jackson in real code; a map literal is shown so the shape of
// the object is readable on one screen.
String inputJson = new ObjectMapper().writeValueAsString(Map.of(
"task", "dfm",
"dxf_excerpt", dxf,
"process", "laser_metal",
"material", "5052-H32 aluminium",
"thickness_mm", 2,
"quantity", 5,
"finish", "deburr",
"context_note", "Bracket for a rack shelf.",
"facts", Map.of("readable", true,
"units", Map.of("name", "millimetres", "certain", true),
"contours", List.of(),
"rules", List.of(),
"flags", List.of())));
String est = DxfDesk.call("/estimate", inputJson, null); // free
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
input_obj = {
"task" => "dfm",
"dxf_excerpt" => File.read("bracket.dxf"),
"process" => "laser_metal",
"material" => "5052-H32 aluminium",
"thickness_mm" => 2,
"quantity" => 5,
"finish" => "deburr",
"context_note" => "Bracket for a rack shelf.",
# facts is what the model is held accountable to - see above.
"facts" => { "readable" => true,
"units" => { "name" => "millimetres", "certain" => true },
"contours" => [], "open_contours" => [],
"rules" => [], "flags" => [] }
}
est = call("/estimate", input_obj) # free: no job, no charge
puts "#{est["model"]} (#{est["model_alias"]}) reserve #{est["hold_credits"]}"
<?php
$inputObj = [
"task" => "dfm",
"dxf_excerpt" => file_get_contents("bracket.dxf"),
"process" => "laser_metal",
"material" => "5052-H32 aluminium",
"thickness_mm" => 2,
"quantity" => 5,
"finish" => "deburr",
"context_note" => "Bracket for a rack shelf.",
// facts is what the model is held accountable to - see above.
"facts" => [
"readable" => true,
"units" => ["name" => "millimetres", "certain" => true],
"contours" => [],
"open_contours" => [],
"rules" => [],
"flags" => [],
],
];
$est = call("/estimate", $inputObj); // free: no job, no charge
echo $est["model"], " reserve ", $est["hold_credits"], " min ", $est["min_credits"], "\n";
var inputObj = new Dictionary<string, object>
{
["task"] = "dfm",
["dxf_excerpt"] = File.ReadAllText("bracket.dxf"),
["process"] = "laser_metal",
["material"] = "5052-H32 aluminium",
["thickness_mm"] = 2,
["quantity"] = 5,
["finish"] = "deburr",
["context_note"] = "Bracket for a rack shelf.",
// facts is what the model is held accountable to - see above.
["facts"] = new Dictionary<string, object>
{
["readable"] = true,
["units"] = new Dictionary<string, object>
{
["name"] = "millimetres", ["mm_per_unit"] = 1, ["certain"] = true
},
["contours"] = new object[0],
["rules"] = new object[0],
["flags"] = new object[0],
},
};
var est = await DxfDesk.Call("/estimate", inputObj); // free
Console.WriteLine($"{est.GetProperty("model")} reserve {est.GetProperty("hold_credits")}");
Step 5 — run and poll
Submit, then poll the job until it is terminal. data.output.output is a string holding the JSON object — parse it a second time. charged_credits is the real cost and is usually far below the hold, which priced the full output cap. truncated comes back on the same object; see the error section for what it obliges you to tell your user.
The Idempotency-Key is not optional in practice. The app derives it from the lane, the DXF text, the process, the material, the thickness, the quantity, the finish, the note and the attempt number, as dxf-desk:<lane>:<hash>:a<attempt>, so four lanes over one file are four runs that cannot collide on one key, changing the thickness is a genuinely new run, and a retried POST of the same lane is free rather than a second charge. Any deterministic derivation works; a counter does not, because reusing one key with a different body is a 409.
# 1. submit. Attempt 1 of this lane over this file at this thickness.
KEY="dxf-desk:dfm:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-32):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll until terminal
while :; do
OUT=$(call "/jobs/$JOB")
ST=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
sleep 2
done
printf '%s' "$OUT" | python3 -c '
import json,sys
d = json.load(sys.stdin)["data"]
print("charged", d.get("charged_credits"), "truncated", d.get("truncated"))
print(d["output"]["output"]) # the JSON object your renderer parses
'
import hashlib
import time
# The key covers the lane AND the DXF text AND every order setting AND the
# attempt: read and dfm over one file are two runs, and the same file at 3 mm
# instead of 2 mm is a third, because every rule limit moved.
raw = json.dumps([input_obj["task"], input_obj["dxf_excerpt"], input_obj["process"],
input_obj.get("material", ""), input_obj["thickness_mm"],
input_obj["quantity"], input_obj["finish"],
input_obj.get("context_note", "")])
key = "dxf-desk:%s:%s:a1" % (input_obj["task"],
hashlib.sha256(raw.encode()).hexdigest()[:32])
job = call("/run", input_obj, extra_headers={"Idempotency-Key": key})
while True:
st = call("/jobs/" + job["job_id"])
if st["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if st["status"] == "failed":
raise SystemExit(st.get("error"))
print("charged", st.get("charged_credits"), "truncated", st.get("truncated"))
result = json.loads(st["output"]["output"]) # the one JSON object
print(result["verdict"], len(result["findings"]), "findings")
# Answer every required flag or you have not audited anything: one reconciliation
# entry per critical/high flag id is the contract.
answered = {r["flag_id"] for r in result["reconciliation"]}
required = {f["id"] for f in input_obj["facts"].get("flags", [])
if f["severity"] in ("critical", "high")}
print("unanswered flags:", sorted(required - answered))
# And check the citations, because an invented contour id reads exactly like a
# real one until you compare it against what you measured.
real = {c["id"] for c in input_obj["facts"].get("contours", [])}
real |= {o["id"] for o in input_obj["facts"].get("open_contours", [])}
print("ungrounded:", [f["location"] for f in result["findings"]
if f["location"] and f["location"] not in real
and f["location"] != "part"])
import { createHash } from "node:crypto";
const raw = JSON.stringify([inputObj.task, inputObj.dxf_excerpt, inputObj.process,
inputObj.material || "", inputObj.thickness_mm,
inputObj.quantity, inputObj.finish,
inputObj.context_note || ""]);
const key = `dxf-desk:${inputObj.task}:${createHash("sha256").update(raw)
.digest("hex").slice(0, 32)}:a1`;
const job = await call("/run", inputObj, undefined, { "Idempotency-Key": key });
let st;
for (;;) {
st = await call(`/jobs/${job.job_id}`);
if (st.status === "succeeded" || st.status === "failed") break;
await new Promise((r) => setTimeout(r, 2000));
}
if (st.status === "failed") throw new Error(JSON.stringify(st.error));
if (st.truncated) console.warn("reply was cut short by a low balance");
const result = JSON.parse(st.output.output);
console.log(result.verdict, result.findings.length, "findings");
// Which critical/high flags went unanswered - the audit that makes facts worth
// sending in the first place.
const answered = new Set(result.reconciliation.map((r) => r.flag_id));
const missed = (inputObj.facts.flags || [])
.filter((f) => f.severity === "critical" || f.severity === "high")
.filter((f) => !answered.has(f.id))
.map((f) => f.id);
console.log("unanswered flags:", missed);
b, _ := json.Marshal([]any{inputObj["task"], inputObj["dxf_excerpt"],
inputObj["process"], inputObj["material"], inputObj["thickness_mm"],
inputObj["quantity"], inputObj["finish"], inputObj["context_note"]})
sum := sha256.Sum256(b)
key := fmt.Sprintf("dxf-desk:%s:%x:a1", inputObj["task"], sum[:16])
raw, err := call("/run", inputObj, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct{ JobID string `json:"job_id"` }
json.Unmarshal(raw, &job)
var st struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
raw, err = call("/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
json.Unmarshal(raw, &st)
if st.Status == "succeeded" || st.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println("charged", st.Charged, "truncated", st.Truncated)
fmt.Println(st.Output.Output) // a string holding the JSON - unmarshal it again
String raw = inputJson; // hash the same fields app.js hashes
String key = "dxf-desk:dfm:"
+ java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes())).substring(0, 32)
+ ":a1";
String job = DxfDesk.call("/run", inputJson, Map.of("Idempotency-Key", key));
String jobId = job.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");
String st;
while (true) {
st = DxfDesk.call("/jobs/" + jobId, null, null);
if (st.contains("\"succeeded\"") || st.contains("\"failed\"")) break;
Thread.sleep(2000);
}
System.out.println(st); // data.output.output holds the one JSON object
require "digest"
raw = JSON.generate([input_obj["task"], input_obj["dxf_excerpt"], input_obj["process"],
input_obj["material"], input_obj["thickness_mm"],
input_obj["quantity"], input_obj["finish"],
input_obj["context_note"]])
key = "dxf-desk:#{input_obj["task"]}:#{Digest::SHA256.hexdigest(raw)[0, 32]}:a1"
job = call("/run", input_obj, { "Idempotency-Key" => key })
st = nil
loop do
st = call("/jobs/#{job["job_id"]}")
break if %w[succeeded failed].include?(st["status"])
sleep 2
end
abort(st["error"].to_s) if st["status"] == "failed"
warn "reply was cut short by a low balance" if st["truncated"]
result = JSON.parse(st["output"]["output"])
puts "#{result["verdict"]} #{result["findings"].length} findings"
<?php
$raw = json_encode([$inputObj["task"], $inputObj["dxf_excerpt"], $inputObj["process"],
$inputObj["material"], $inputObj["thickness_mm"],
$inputObj["quantity"], $inputObj["finish"],
$inputObj["context_note"]]);
$key = "dxf-desk:" . $inputObj["task"] . ":" . substr(hash("sha256", $raw), 0, 32) . ":a1";
$job = call("/run", $inputObj, ["Idempotency-Key: $key"]);
do {
sleep(2);
$st = call("/jobs/" . $job["job_id"]);
} while (!in_array($st["status"], ["succeeded", "failed"], true));
if ($st["status"] === "failed") { exit(1); }
$result = json_decode($st["output"]["output"], true);
echo $result["verdict"], " ", count($result["findings"]), " findings\n";
using System.Security.Cryptography;
var raw = JsonSerializer.Serialize(new object[] {
inputObj["task"], inputObj["dxf_excerpt"], inputObj["process"],
inputObj["material"], inputObj["thickness_mm"], inputObj["quantity"],
inputObj["finish"], inputObj["context_note"] });
var key = "dxf-desk:" + inputObj["task"] + ":" +
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
.Substring(0, 32).ToLowerInvariant() + ":a1";
var job = await DxfDesk.Call("/run", inputObj,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
JsonElement st;
while (true)
{
st = await DxfDesk.Call("/jobs/" + job.GetProperty("job_id").GetString());
var status = st.GetProperty("status").GetString();
if (status == "succeeded" || status == "failed") break;
await Task.Delay(2000);
}
var result = JsonDocument.Parse(
st.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
Step 6 — run-stream, for progress
The same run, reported as it happens, as server-sent events. This is what the web app uses, and the reason its progress card can name a real stage rather than spin: the appearance of "findings", then "body", then the lane's own keys in the accumulated delta text is a real signal about where the model is. Accumulate the deltas and parse once at the end, because a partial JSON document is not parseable.
| Lane | The five markers the app watches, in order |
|---|---|
read | "findings", "body", "layer_roles", "unresolved", "script" |
dfm | "findings", "body", "rule_reviews", "secondary_ops", "script" |
fix | "findings", "body", "edit_list", "parameters", "script" |
order | "findings", "body", "order_lines", "cost_drivers", "upload_checklist" |
The events worth handling are job (accepted, the run is now billed against the hold), delta (a chunk of the JSON document, in order, as {"text": "..."}), done (terminal, with charged_credits and truncated) and error (terminal failure). A stream that dies mid-document leaves you holding unparseable text; that is exactly the case retry_note exists for, and the app retries once with the reformat instruction before it gives up and shows the raw text.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# Server-sent events. The ones worth handling:
# event: job - accepted; the run is now billed against the hold
# event: delta - a chunk of the JSON document, in order
# event: done - terminal, with charged_credits and truncated
# event: error - terminal failure
# The app drives its progress card off the delta text: "findings", then "body",
# then the lane's own keys, is what advances a stage.
# The stream is the same run, reported as it happens. Accumulate the deltas and
# parse ONCE at the end - a partial JSON document is not parseable, and the app's
# reformat retry exists exactly because a stream can die mid-document.
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(input_obj).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
MARKERS = ['"findings"', '"body"', '"rule_reviews"', '"secondary_ops"', '"script"']
buf = ""
with urllib.request.urlopen(req) as r:
event = None
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
buf += payload.get("text", "")
stage = sum(1 for m in MARKERS if m in buf)
# advance your progress display to `stage` here
elif event == "done":
print("charged", payload.get("charged_credits"),
"truncated", payload.get("truncated"))
elif event == "error":
raise SystemExit(payload)
result = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(inputObj),
});
const MARKERS = ['"findings"', '"body"', '"rule_reviews"', '"secondary_ops"', '"script"'];
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", frame = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
frame += dec.decode(value, { stream: true });
const lines = frame.split("\n");
frame = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const p = JSON.parse(line.slice(6));
if (event === "delta") {
buf += p.text || "";
const stage = MARKERS.filter((m) => buf.includes(m)).length;
// paint `stage` - throttle it, the deltas are small and frequent
} else if (event === "done") console.log("charged", p.charged_credits);
else if (event === "error") throw new Error(JSON.stringify(p));
}
}
}
const result = JSON.parse(buf);
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream",
bytes.NewReader(func() []byte { b, _ := json.Marshal(inputObj); return b }()))
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 buf strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var p struct {
Text string `json:"text"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
if event == "delta" {
buf.WriteString(p.Text)
} else if event == "done" {
fmt.Println("charged", p.Charged, "truncated", p.Truncated)
}
}
}
fmt.Println(buf.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(DxfDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + DxfDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder buf = new StringBuilder();
String[] event = { null };
DxfDesk.HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ") && "delta".equals(event[0])) {
// parse {"text": "..."} with your JSON library and append it
buf.append(extractText(line.substring(6)));
}
});
System.out.println(buf); // parse ONCE, here, not per delta
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.body = JSON.generate(input_obj)
MARKERS = ['"findings"', '"body"', '"rule_reviews"', '"secondary_ops"', '"script"']
buf = ""
event = nil
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: ")
p = JSON.parse(line[6..])
if event == "delta"
buf << (p["text"] || "")
stage = MARKERS.count { |m| buf.include?(m) }
end
puts "charged #{p["charged_credits"]}" if event == "done"
end
end
end
end
end
result = JSON.parse(buf)
<?php
$ctx = stream_context_create(["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: $key",
]),
"content" => json_encode($inputObj),
]]);
$fh = fopen(BASE . "/run-stream", "r", false, $ctx);
$buf = "";
$event = null;
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$p = json_decode(substr($line, 6), true);
if ($event === "delta") { $buf .= $p["text"] ?? ""; }
if ($event === "done") { echo "charged ", $p["charged_credits"], "\n"; }
}
}
fclose($fh);
$result = json_decode($buf, true); // parse ONCE, here
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(inputObj),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var markers = new[] { "\"findings\"", "\"body\"", "\"rule_reviews\"",
"\"secondary_ops\"", "\"script\"" };
var buf = new StringBuilder();
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line.Substring(7);
else if (line.StartsWith("data: "))
{
var p = JsonDocument.Parse(line.Substring(6)).RootElement;
if (ev == "delta" && p.TryGetProperty("text", out var t))
{
buf.Append(t.GetString());
var stage = markers.Count(m => buf.ToString().Contains(m));
}
else if (ev == "done")
Console.WriteLine("charged " + p.GetProperty("charged_credits"));
}
}
var result = JsonDocument.Parse(buf.ToString()).RootElement;
The output contract, lane by lane
One JSON object, and nothing else — no prose before it, no code fence around it. The envelope is identical across all four lanes, which is what lets one renderer, one history writer and one export path serve every lane; only body differs. Every key is present on every reply, and every array is present even when it is empty: an omitted key never means "none".
{
"task": "dfm", // one of read | dfm | fix | order
"task_inferred": false, // true when the lane was chosen, not given
"title": "a specific one-line title naming the part and the lane",
"verdict": "ready | fix-first | blocked | unreadable",
"summary": "two to four sentences: the answer, the reason, the next move",
"assumptions": ["what had to be assumed - and nothing that did not"],
"open_questions": ["what only the user or the shop can answer"],
"findings": [
{"id": "DXR-001",
"severity": "critical | high | medium | low",
"location": "C5",
"title": "short imperative title",
"why": "what happens at the machine if this is left alone",
"fix": "the specific move"}
],
"reconciliation": [
{"flag_id": "DX-003",
"status": "confirmed | noted | set-aside | superseded",
"note": "one sentence"}
],
"next_lane": {"lane": "fix", "reason": "one sentence"},
"body": { ... } // the lane-specific half, below
}
| Envelope key | Meaning |
|---|---|
task | The lane that was actually answered. Compare it to what you sent. |
task_inferred | true only when task was missing or unrecognised and the lane was chosen. summary then says which and why. Two lanes are never blended. |
title | A specific one-line title naming the part and the lane. Defaults to Untitled part if the model returns nothing. |
verdict | Always about this lane's question. ready = nothing found here blocks the order. fix-first = orderable once the findings are dealt with. blocked = cannot be made correctly as drawn. unreadable = the file could not be read as cut geometry at all. Anything unrecognised normalizes to unreadable. |
summary | Two to four sentences: the answer, the reason, the next move. |
assumptions | What had to be assumed because facts did not give it. Empty is a legitimate answer and is commoner than you would think. |
open_questions | What only the user or the shop can settle. A vendor's actual minimums live here, never in a finding. |
findings | Ids run DXR-001, DXR-002, ..., and the array is re-sorted critical first. location must be a citable id; an unrecognised severity normalizes to medium. |
reconciliation | Exactly one entry per critical and high flag you sent in facts.flags, keyed by that flag's own id. An entry with no flag_id is dropped; an unrecognised status normalizes to confirmed. |
next_lane | {lane, reason}, the honest next step along read → dfm → fix → order. Empty when there is genuinely nothing to hand off, and cleared outright if it names the lane that just ran. |
reconciliation[].status is the audit vocabulary and each word means something specific: confirmed agrees the flag is a real problem; noted agrees it is real but says it is not material to this lane; set-aside says the context_note or the order settings make it a non-issue, and must say why; superseded says a different finding in the same reply replaces it.
Two things the client does to this object before it renders, worth knowing if you are writing your own consumer. It normalizes, as the table above describes. And it grounds: every location, id, rule_id, at, layer and source is checked against the ids your facts actually contained, and a companion boolean (location_ungrounded, id_ungrounded, rule_ungrounded, at_ungrounded, layer_ungrounded, source_ungrounded) is added next to it. It also adds script_layer_count and script_unknown_layers to every body, from checking every layer name the returned script reads — in all four ezdxf idioms, layer="X", [layer=="X"], .layers.get("X") and ["layer"]=="X" — while deliberately ignoring layers the script itself creates with .layers.new(). Those keys are client-side additions, not model output: do not expect them on the wire, and do add your own equivalent if you are reusing this contract, because an invented contour that nobody marks is an invented contour nobody notices.
task: "read" — Read the file (what is this, before anyone acts on it)
The browser has read the geometry; this lane resolves what it means. A layer called L3 carrying eleven circles, a DIMENSION sitting on the cut layer, a drawing with no $INSUNITS — this is where the ambiguity gets settled.
body key | Type | Meaning |
|---|---|---|
part_call | string | What this part appears to be, from its geometry and the user's note. |
units_call | string | What the units are and how sure that is — and if not sure, what would settle it. When facts.units.certain is false this is the first-order issue in the whole reply: a part read in the wrong units is wrong by a factor of 25.4. |
upload_ready | string | yes, no or with-fixes. If the units are uncertain it cannot be yes. |
layer_roles[] | array | {layer, role, agrees_with_prescan, why}. role is one of cut, bend, etch, annotation, reference, hardware, unclear; anything else normalizes to unclear. agrees_with_prescan defaults to true and is false only on a genuine disagreement with the browser's reading, which must then be justified in why. Must cover every layer in facts.layers with geometry_entities > 0, because a layer nobody reads is cut by default and that is the expensive direction to be wrong in. |
uncovered_layers[] | array of string (client-added) | The layers carrying geometry that layer_roles did not mention. Displayed as a hole in the reading. |
contour_notes[] | array | {id, reading, action}. action is one of keep, remove, close, move-to-bend, move-to-etch, resize, investigate; anything else normalizes to investigate. Expected to cover the outline, every open contour and every unusual opening — not one row per identical bolt hole; those get grouped by naming their ids once. |
unresolved[] | array | {question, why_it_matters, how_to_settle}. What the file itself cannot answer. |
script, script_note | string | Python + ezdxf that prints the layer inventory and per-layer entity counts. Reads a path from sys.argv, never hard-coded; never overwrites the input. |
task: "dfm" — Check it will cut (can this part be made)
The lane that stands between a user and a rejected upload. It runs against the rule set that process selected, at the limits thickness_mm produced.
body key | Type | Meaning |
|---|---|---|
process_note | string | What this process does to this part: kerf, heat, taper, tool radius, and what it cannot do at all. |
rule_reviews[] | array | {rule_id, agree, comment}. Must contain an entry for every rule in facts.rules whose pass is false. agree defaults to true; false means the limit does not apply here — a hole that will be drilled after cutting, an opening that is hardware clearance rather than a cut feature — and must say what the user has to tell the shop for that to hold. Reviewing passing rules is optional; reviewing a pass: null rule is useful when it can say what would make it measurable. |
unreviewed_rules[] | array of string (client-added) | The failing rule ids the review passed over in silence. A violation nobody mentions looks exactly like a violation that does not exist. |
violations[] | array | {at, measured_mm, limit_mm, consequence, remedy}. Both numbers are copied from facts unchanged — that is checkable, and you should check it. |
secondary_ops[] | array | {operation, needed, detail} where operation is deburr, bend, tap, countersink, hardware insertion, finish, inspect or other, and needed is yes, no or maybe (anything else normalizes to maybe). Driven by what the geometry implies, not by a menu: bend lines imply forming, a hardware-role layer implies inserts, thick material implies deburring. |
tolerance_note | string | What tolerance this part can honestly hold on this process, and where it is tightest. |
script, script_note | string | Python + ezdxf that re-checks the failing rules on the user's own file. |
If the process cannot form material — laser_acrylic or router_alu — and bend lines are present, the verdict is blocked unless the context_note explains it.
Worked response — the dfm request from further up
{
"task": "dfm",
"task_inferred": false,
"title": "120 x 60 mm formed bracket, 2 mm 5052-H32, laser cut",
"verdict": "fix-first",
"summary": "The blank, the bend and the flanges are all comfortably inside the
limits for 2 mm aluminium on a fibre laser. One thing fails: the
1 mm pilot at C2 is half the 2 mm minimum hole diameter for this
thickness. Either open it to 2 mm and use a smaller screw, or leave
it off the cut file and drill it after cutting.",
"assumptions": ["That the bend at B1 is a single 90 degree form; the flat pattern
cannot say what angle it is bent to."],
"open_questions": ["Whether the shop will drill a 1 mm hole as a secondary
operation at quantity 5, and what they charge to set it up."],
"findings": [
{"id": "DXR-001", "severity": "high", "location": "C2",
"title": "the 1 mm pilot is half the minimum hole diameter for 2 mm sheet",
"why": "A fibre laser piercing a hole narrower than the material thickness
burns a tapered, recast-lined hole or fails to pierce at all. At
1 mm in 2 mm aluminium the entry is molten and the exit is smaller
than the entry, so the screw does not start straight.",
"fix": "Open C2 to 2 mm diameter, or delete it from the cut file and drill
it after cutting. A drilled 1 mm hole in 2 mm aluminium is routine;
a cut one is not."},
{"id": "DXR-002", "severity": "low", "location": "B1",
"title": "no inside bend radius is stated anywhere",
"why": "The flat pattern cannot carry it - a radius is a tooling choice, and
the bend deduction that sets the flat length depends on it.",
"fix": "Ask the shop what tooling they will form 2 mm 5052 on and confirm
the flat length was developed for that radius."}
],
"reconciliation": [
{"flag_id": "DX-001", "status": "confirmed",
"note": "1 mm measured against a 2 mm limit, and the measurement is an exact
CIRCLE diameter from the file rather than an approximation."}
],
"next_lane": {"lane": "fix",
"reason": "One dimensional change clears the only failure; the fix
lane can produce it parametrically."},
"body": {
"process_note": "A fibre laser cuts 2 mm 5052 with a kerf of roughly 0.15 mm
and a small heat-affected zone. It cannot form: B1 is a
separate press-brake operation after cutting. The floor on
any pierced feature is the material thickness, which is what
C2 falls under.",
"rule_reviews": [
{"rule_id": "min_hole", "agree": true,
"comment": "1 mm in 2 mm sheet is under the one-thickness floor and the
file draws it as a real CIRCLE, so there is no measurement
doubt to fall back on."}
],
"unreviewed_rules": [],
"violations": [
{"at": "C2", "measured_mm": 1, "limit_mm": 2,
"consequence": "The pierce either fails or leaves a tapered, recast hole
that a self-tapping screw will not start straight in.",
"remedy": "Open C2 to 2 mm, or remove it from the cut file and drill it
after cutting."}
],
"secondary_ops": [
{"operation": "bend", "needed": "yes",
"detail": "B1 runs the full 120 mm width with 30 mm of flange either side,
well over the 8 mm minimum flange for this thickness."},
{"operation": "deburr", "needed": "yes",
"detail": "The order already specifies deburr and tumble, which is the
right call before anodising anything."},
{"operation": "inspect", "needed": "maybe",
"detail": "At quantity 5 a first-article check of the flat length after
forming is cheaper than five wrong brackets."}
],
"tolerance_note": "Cut features hold about +/- 0.1 mm on this process. The
loosest dimension is anything measured across the bend,
where the flat development and the tooling radius dominate;
expect +/- 0.5 mm there unless the shop says otherwise.",
"script": "import sys\nimport ezdxf\n\nMIN_HOLE_MM = 2.0 # 1 x 2 mm thickness\n\ndef main(path):\n doc = ezdxf.readfile(path)\n msp = doc.modelspace()\n for e in msp.query('CIRCLE[layer==\"CUT\"]'):\n d = e.dxf.radius * 2\n print(f\"{e.dxf.handle} circle d={d:.3f} mm \"\n f\"{'FAIL' if d < MIN_HOLE_MM else 'ok'}\")\n\nif __name__ == \"__main__\":\n main(sys.argv[1])\n",
"script_note": "It re-checks every circular opening on the CUT layer against
the same 2 mm limit. It does not check non-circular openings,
webs or edge clearances, and it writes nothing.",
"script_layer_count": 1,
"script_unknown_layers": []
}
}
task: "fix" — Correct the part (produce the change)
Two halves: the edit list a person applies in their own CAD tool, and a parametric build123d script that regenerates the corrected part from named parameters — so the fix is repeatable and the next revision is a parameter change rather than another round of nudging.
body key | Type | Meaning |
|---|---|---|
edit_list[] | array | {at, change, from, to, why}. The specific changes, each pointing at a real id. If nothing is failing this is empty — the lane does not manufacture corrections to look useful, it says so in summary and still gives the parametric script for the part as it stands. |
parameters[] | array | {name, value, why}. Every constant here appears in the script and every named constant in the script appears here. value is a string, so "2.5", not 2.5. |
verification[] | array | {check, expected}. How the user knows the script did what it claims: a diameter to measure, a hole count, a bounding box, an area. |
residual[] | array of string | What this does not fix, and why. The honest half of a fix. |
script, script_note | string | Python + build123d: named constants at the top, geometry built from them, no magic numbers buried in the body, export_step and export_dxf at the end. Built from the measured geometry in facts — the real outline size, the real hole positions where they are given — not a generic rectangle. Nothing is imported from cadquery. |
task: "order" — Build the order packet (what a shop needs, in the order they need it)
body key | Type | Meaning |
|---|---|---|
order_lines[] | array | {field, value, source}. Covers at minimum material, thickness, quantity, finish, part size, and the file format and units to upload in. source is provenance prose rather than a bare id — see the citation note above for exactly what counts as grounded there. |
service_sequence[] | array | {service, detail}, in the order the operations actually happen: cut, deburr, form, tap, insert hardware, finish. Getting this wrong is a real error, not a presentation quibble — anodising before tapping changes the thread fit, and bending after inserting hardware crushes it. |
hardware[] | array | {item, qty, note}. qty is a string so it can say "4 per part". |
cost_drivers[] | array | {driver, measured, effect}. The measured quantities a quote is computed from — cut length, pierce count, part area, sheet utilisation, secondary operations, quantity — and which way each one pushes. Never a price. |
vendor_questions[] | array of string | What to ask before paying. |
upload_checklist[] | array | {item, state, note} where state is done, todo or na (anything else normalizes to todo). Must have a todo for every unresolved critical and high flag in facts.flags. na is for items the order settings make irrelevant, with the reason in note. If any contour is open, the checklist starts there and verdict cannot be ready. |
script, script_note | string | Python + ezdxf that writes the upload-ready DXF and prints the order summary, to a new file alongside the input, never over it. |
The honesty rules a caller should know about
These are not style guidance. They are enforced by the prompt, checked by the client, and they are the reason the output is worth consuming programmatically at all. If you build your own renderer, reproduce them, because each one exists to make a specific failure visible instead of invisible.
- Reconciliation is keyed to
facts.flagsids, one entry per required flag, no more and no fewer. Every flag you send whoseseverityiscriticalorhighmust come back with exactly onereconciliationentry naming that flag's own id. A required flag with no entry is a defect the reply passed over in silence, and silence reads exactly like "there was nothing there". Compute the difference yourself — it is four lines — and show it.mediumandlowflags may be reconciled but do not have to be. - Citations are checked against the file, and a bad one is kept and marked, never dropped. Every
location,at,rule_idandlayermust be exactly one of the ids yourfactscontained. A contour id that does not exist is not silently removed — it is displayed with an ungrounded marker, so "the model named a hole this file does not have" is a thing your users can see. The same check runs over the returned script's layer references. Dropping a bad citation would hide the one signal that tells you the answer drifted. - No prices, no lead times, no vendor catalogue numbers, anywhere, ever. Neither this app nor the model has access to any shop's rates, and the app has no network access to fetch one. What comes back instead is the quantities a quote is computed from — cut length, pierce count, part area, sheet utilisation, secondary operations, quantity — and where each one pushes. "About $40" would be fabrication in the bad sense. If your product needs a number, put your own rate card next to
cost_drivers; do not ask this API for one. - The rule limits are published thickness ratios, not anyone's catalogue. Every limit is
max(ratio × thickness_mm, floor_mm)from ordinary published sheet-metal guidance, and the ratio, the thickness and the arithmetic travel with it infacts.rules[].basisso it can be checked rather than believed.facts.rules_provenancesays this outright and the model is told to describe them that way and to tell the user to confirm them against the shop's own published specification. A limit that disagrees with your vendor's spec is not a bug in the reply; it is the reply doing what it said it would. - The measurements win, and the model may not restate them. It can say a measurement is irrelevant, or that it is a bound rather than a value where
facts.approximationssays so. It may not give the same quantity a different number. Iffacts.measures.smallest_hole_mmis 1, every sentence in the reply that touches the smallest opening says 1. - What cannot be measured is not asserted. An inside bend radius cannot be measured from a flat pattern — it is a tooling choice, and the corresponding rule comes back
pass: null. A spline's length is an upper bound. A gap measured between discretised points carries the chord tolerance infacts.file.chord_tolerance_mm. Each of those is said where it matters rather than presented as a value. - Open contours outrank everything. If
facts.open_contoursis non-empty, that is the headline of every lane regardless of what else is right about the file. A file with an open contour has no inside, so a cutter cannot tell material from scrap, and it is not orderable. - Uncertain units are the first-order problem. When
facts.units.certainis false, nothing else matters until it is settled — a part read in the wrong units is wrong by a factor of 25.4 — andupload_readycannot beyes.
What this API will not do
- It does not read your DXF. There is no server-side DXF reader. Everything in
factsis something you measured, and the quality of the answer is bounded by it. The browser app will do the measuring for free if you would rather not write it. - It does not return a file. Every lane returns a plan and a script for you to run and review yourself, on your own copy, in your own environment. Nothing here rewrites a drawing.
- It does not quote. No prices, no lead times, no shop's catalogue. See above; this one is absolute.
- It is not a substitute for the shop's own specification. The limits are published ratios applied to your stated thickness. Confirm them against whoever is actually cutting the part before you order.
- It does not fetch anything. No material database, no fastener catalogue, no vendor lookup, no reference data of any kind. Everything in a reply comes from what you sent.
- It does not remember. Each run is independent. Continuity between lanes is something you pass in via
prior_read, and history is stored against your own account by the app, not by the model.