← KiCad Desk / API
Get a token

Driving KiCad Desk from your own code

Everything the web app's AI lanes do is available over HTTP. Send a task, a .kicad_sch and a .kicad_pcb, get back one JSON object. The KiCad parser the browser runs for free — the symbol inventory, the annotation audit, the parsed component values, the BOM roll-up, the wire-level nets, the board measurements and the fab capability check — is not recomputed server-side. If you drive the API directly you should send your own prescan facts, because that object is what the model is held accountable to.

Base URL and headers

ThingValue
Base URLhttps://api.skillsafe.ai/v1/app-api
AuthAuthorization: Bearer <token>
BodyContent-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 identitycarried by the token. There is no X-App-Slug header
IdempotencyIdempotency-Key: <string> on /run and /run-stream

Handling somebody's design files

A .kicad_sch and a .kicad_pcb are frequently unreleased hardware. Three things follow. First, nothing in the browser app uploads them until a lane runs, and if you drive this API you are the one uploading, so decide deliberately. Second, the run is stateless: no file you send is retained as design data beyond the run that used it, and continuity between lanes is something you pass in via prior_review. Third, the free part — parsing, auditing, measuring, the fab check, the BOM and the position file — needs no API call at all: it is JavaScript in the page, so if all you want is the deterministic half, use the app and never send anything anywhere.

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": "insufficient_credits", "message": "...", "details": { ... }}}

Error codes

HTTPerror.codeWhat it means and what to do
400invalid_inputThe body was not a JSON object, or task was not one of the four lanes. Fix the body; a retry will not help.
401unauthorizedNo token, or a token that has expired. Mint a new one (step 2).
402insufficient_creditsThe balance cannot cover min_credits. Check /estimate against /me before submitting, which is what the app does so this never fires.
404not_foundA job id that does not exist, or one belonging to another subject.
409idempotency_conflictThe same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter.
429rate_limitedBack off and retry. Never tight-loop.
500internalRetry once with the same idempotency key, which is exactly what the key is for.

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.

Step 2 — get a token

The friendly route is the token page, which shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one. Programmatically, POST /guest is the whole story. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token from signing in.

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.

Step 4 — the input object

This is the whole contract on the way in. Both file fields are optional individually; at least one must be non-empty.

FieldTypeMeaning
taskstringDocument this first, because it selects everything else. One of decode, review, layout, fab. Absent or unrecognised, the model picks the closest lane, sets task_inferred and names the lane it chose rather than blending two contracts.
sch_textstringThe contents of a .kicad_sch. The app clips it on whole-line boundaries above 45,000 characters, keeping both ends.
pcb_textstringThe contents of a .kicad_pcb, same clipping rule.
fab_profilestringjlcpcb-2layer, jlcpcb-4layer, pcbway-2layer or unspecified.
design_notestringFree text, up to 400 characters. What the board is, or what a fab house said. The single most useful optional field.
prescanobjectThe measured facts. See below.
clip_notestringSend this when you clipped a file, saying what you clipped and that your measurements cover the whole file.
prior_reviewobjectOn a handoff: {verdict, basis, findings[]} from a previous review run, so the later lane builds on it.
retry_notestringOnly when a previous reply failed to parse. The app sends the exact reformat instruction here and reuses the same idempotency key so the retry is not a second billed run.

Sending your own prescan

The model is instructed that prescan wins over its own reading of the text on counts and measurements, and that it must return one reconciliation entry for every prescan.flags entry of high or medium severity. So prescan is not decoration: it is the mechanism that makes the reply auditable. If you send nothing, you get an opinion; if you send facts, you get an opinion that has to answer to them.

The full object the browser builds is large. The parts that carry weight:

{
  "ok": true,
  "kind": "both",                       // "sch" | "pcb" | "both"
  "fab_profile": "jlcpcb-2layer",
  "schematic": {
    "version": "20231120",
    "counts": {"placed_parts": 6, "power_symbols": 2, "wires": 5, "junctions": 1},
    "parts": {"shown": [{"ref": "R1", "value": "4k7",
                         "footprint": "Resistor_SMD:R_0603_1608Metric"}],
              "total": 6, "capped": false},
    "nets": {"shown": ["GND", "SDA", "SCL"], "total": 4, "capped": false},
    "net_caveat": "component pin positions are NOT resolved ...",
    "bom_lines": {"shown": [{"value": "4.7kR", "qty": 2, "refs": "R1 R1"}], "total": 5},
    "missing_part_numbers": {"shown": ["C1"], "total": 4}
  },
  "board": {
    "outline": {"present": true, "closed": false, "open_endpoints": 2,
                "width_mm": 40, "height_mm": 30},
    "copper_layers": 2,
    "min_track_mm": 0.1,
    "min_annular_mm": 0.1,
    "min_edge_clearance_mm": 0.075,
    "unrouted_nets": {"shown": ["VBUS_5V"], "total": 1},
    "fab_checks": [{"rule": "minimum track width", "required": 0.127,
                    "measured": 0.1, "unit": "mm", "status": "fail"}]
  },
  "cross_check": {"in_schematic_not_on_board": {"shown": ["C?"], "total": 3}},
  "flags": [{"id": "KD-B01", "severity": "high", "location": "Edge.Cuts",
             "label": "the outline is open: 2 endpoint(s) ..."}],
  "flags_total": 28
}

Flag ids are stable per file and namespaced by where they came from: KD-S.. from the schematic, KD-B.. from the board, KD-X.. from the cross-check. Any id scheme works as long as your reconciliation matching agrees with it.

Step 5 — 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: model reads gpt-5.6-terra, model_alias reads gpt-terra, markup_bps reads 1000.

Step 6 — 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.

Step 7 — 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: the appearance of "findings", then "body", then the lane's own key in the delta text is a real signal about where the model is. Accumulate the deltas and parse once at the end.

The output contract, lane by lane

One JSON object. 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.

{
  "task": "review",
  "task_inferred": false,
  "title": "...",
  "verdict": "order-ready | fix-first | not-ready | unreadable",
  "summary": "...",
  "assumptions": ["..."],
  "open_questions": ["..."],
  "findings": [{"id": "KD-001", "severity": "critical|high|medium|low",
                "location": "R1", "title": "...", "why": "...", "fix": "..."}],
  "reconciliation": [{"flag_id": "KD-S02", "status": "confirmed|noted|set-aside|superseded",
                      "note": "..."}],
  "next_lane": {"lane": "layout", "reason": "..."},
  "body": { ... }
}

verdict is always about readiness to order, judged from what the lane saw. Every location is checked client-side against the designators, nets, layers and library ids the pasted files actually contain; one that does not exist is displayed, marked, rather than dropped — so an invented designator is visible to your users too if you reuse this contract.

task: "decode" — Read (Read stage)

"body": {
  "file_kind": "a KiCad 8 schematic (kicad_sch, version 20231120) and a KiCad 8 board",
  "design_summary": "...",
  "sections": [{"construct": "(lib_symbols ...)", "what_it_is": "...",
                "what_this_one_says": "..."}],
  "notable_items": [{"location": "R1", "label": "...", "reading": "...",
                     "significance": "..."}],
  "next_step_note": "..."
}

sections[].construct is a KiCad construct name, not a design location, so it is the one string in the contract that is not grounded against the files.

task: "review" — Schematic (Check the schematic stage)

"body": {
  "basis": "...",
  "checks": [{"area": "annotation and designators", "status": "pass|warn|fail", "note": "..."}],
  "corrected_fragment": "(property \"Value\" \"100nF\" (at 193.04 97.79 0) ...)",
  "fragment_note": "where this goes and what it replaces",
  "correction_notes": [{"location": "C1", "change": "..."}]
}

The nine checks areas, in order: annotation and designators, symbol libraries, footprint assignment, component values, net naming and labels, power and ground, hierarchy, sourcing fields, documentation and title block.

The app re-parses corrected_fragment with its own S-expression reader before displaying it and adds fragment_parses and fragment_error to the body client-side. Those two keys are not model output — do not expect them on the wire.

task: "layout" — Board (Check the board stage)

"body": {
  "basis": "...",
  "checks": [{"area": "board outline and mechanical", "status": "pass|warn|fail", "note": "..."}],
  "stackup_note": "...",
  "rule_advice": [{"rule": "minimum track width", "suggested": "0.2 mm", "why": "..."}],
  "routing_notes": [{"location": "GND", "observation": "...", "action": "..."}]
}

The nine areas, in order: board outline and mechanical, stackup and layer use, track widths and current, clearance and creepage, vias and drills, copper pours and return paths, thermal and power dissipation, silkscreen and documentation, testability and assembly.

task: "fab" — Order (Order stage)

"body": {
  "order_state": "ready | hold | blocked",
  "gates": [{"gate": "outline and dimensions", "status": "pass|warn|fail", "note": "..."}],
  "bom_actions": [{"location": "C1", "action": "...", "why": "..."}],
  "order_checklist": ["one action per entry, in order"],
  "cost_drivers": [{"driver": "two-sided assembly", "effect": "..."}],
  "quote_note": "..."
}

The eight gates, in order: outline and dimensions, stackup and thickness, design rules against the profile, BOM completeness, position file completeness, assembly side and part availability, documentation and revision, export set. A gate over a file you did not send comes back warn, never pass.

Lane-specific rules worth knowing before you consume the output

What this API will not do