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
| 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 |
| Idempotency | Idempotency-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
| HTTP | error.code | What it means and what to do |
|---|---|---|
| 400 | invalid_input | The body was not a JSON object, or task was not one of the four lanes. Fix the body; a retry will not help. |
| 401 | unauthorized | No token, or a token that has expired. Mint a new one (step 2). |
| 402 | insufficient_credits | The balance cannot cover min_credits. Check /estimate against /me before submitting, which is what the app does so this never fires. |
| 404 | not_found | A job id that does not exist, or one belonging to another subject. |
| 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. |
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.
# The whole client is two variables and curl.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN
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.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://kicad-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://kicad-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"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from https://kicad-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
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 KicadDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // kicad-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://kicad-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://kicad-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 KicadDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // kicad-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, 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.
# mint a guest token - free, and enough for /me and /estimate
call /guest '{}' | check
# mint a guest token - free, and enough for /me and /estimate
data = call("/guest", {})
print(data)
// mint a guest token - free, and enough for /me and /estimate
const data = await call("/guest", {});
console.log(data);
// mint a guest token - free, and enough for /me and /estimate
raw, err := call("/guest", map[string]any{}, nil)
if err != nil {
panic(err)
}
fmt.Println(string(raw))
// mint a guest token - free, and enough for /me and /estimate
String data = KicadDesk.call("/guest", "{}", null);
System.out.println(data);
# mint a guest token - free, and enough for /me and /estimate
data = call("/guest", {})
puts data
<?php
// mint a guest token - free, and enough for /me and /estimate
$data = call("/guest", []);
print_r($data);
// mint a guest token - free, and enough for /me and /estimate
var data = await KicadDesk.Call("/guest", new {});
Console.WriteLine(data);
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.
# subject_type, username, credits
call /me | check
# subject_type, username, credits
data = call("/me")
print(data)
// subject_type, username, credits
const data = await call("/me");
console.log(data);
// subject_type, username, credits
raw, err := call("/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(string(raw))
// subject_type, username, credits
String data = KicadDesk.call("/me", null, null);
System.out.println(data);
# subject_type, username, credits
data = call("/me")
puts data
<?php
// subject_type, username, credits
$data = call("/me");
print_r($data);
// subject_type, username, credits
var data = await KicadDesk.Call("/me");
Console.WriteLine(data);
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.
| Field | Type | Meaning |
|---|---|---|
task | string | Document 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_text | string | The contents of a .kicad_sch. The app clips it on whole-line boundaries above 45,000 characters, keeping both ends. |
pcb_text | string | The contents of a .kicad_pcb, same clipping rule. |
fab_profile | string | jlcpcb-2layer, jlcpcb-4layer, pcbway-2layer or unspecified. |
design_note | string | Free text, up to 400 characters. What the board is, or what a fab house said. The single most useful optional field. |
prescan | object | The measured facts. See below. |
clip_note | string | Send this when you clipped a file, saying what you clipped and that your measurements cover the whole file. |
prior_review | object | On a handoff: {verdict, basis, findings[]} from a previous review run, so the later lane builds on it. |
retry_note | string | Only 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.
INPUT='{
"task": "review",
"sch_text": "(kicad_sch (version 20231120) (generator \"eeschema\") ... )",
"pcb_text": "(kicad_pcb (version 20240108) (generator \"pcbnew\") ... )",
"fab_profile": "jlcpcb-2layer",
"design_note": "a 3V3 sensor node; the fab put the order on hold",
"prescan": { "ok": true, "kind": "both", "...": "see below" }
}'
# Free. No job is created and nothing is charged.
call /estimate "$INPUT" | check
# The three numbers that matter:
# hold_credits - reserved worst case. Show it as RESERVED, never as the price.
# min_credits - below this the run will not start at all.
# charged_credits comes back later, from the run, and is usually far lower.
input_obj = {
"task": "review",
"sch_text": open("board.kicad_sch").read(),
"pcb_text": open("board.kicad_pcb").read(),
"fab_profile": "jlcpcb-2layer",
"design_note": "a 3V3 sensor node; the fab put the order on hold",
# prescan is what the model is held accountable to. Send yours - see step 4.
"prescan": {"ok": True, "kind": "both", "flags": []},
}
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: "review",
sch_text: readFileSync("board.kicad_sch", "utf8"),
pcb_text: readFileSync("board.kicad_pcb", "utf8"),
fab_profile: "jlcpcb-2layer",
design_note: "a 3V3 sensor node; the fab put the order on hold",
// prescan is what the model is held accountable to. Send yours - see step 4.
prescan: { ok: true, kind: "both", 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);
sch, _ := os.ReadFile("board.kicad_sch")
pcb, _ := os.ReadFile("board.kicad_pcb")
inputObj := map[string]any{
"task": "review",
"sch_text": string(sch),
"pcb_text": string(pcb),
"fab_profile": "jlcpcb-2layer",
"design_note": "a 3V3 sensor node; the fab put the order on hold",
// prescan is what the model is held accountable to - see step 4.
"prescan": map[string]any{"ok": true, "kind": "both", "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"`
}
json.Unmarshal(raw, &est)
fmt.Printf("%s (%s) reserve %d min %d\n",
est.Model, est.ModelAlias, est.HoldCredits, est.MinCredits)
String sch = Files.readString(Path.of("board.kicad_sch"));
String pcb = Files.readString(Path.of("board.kicad_pcb"));
// Build this with Jackson in real code; concatenation is shown so the shape of
// the object is readable on one screen.
String inputJson = new ObjectMapper().writeValueAsString(Map.of(
"task", "review",
"sch_text", sch,
"pcb_text", pcb,
"fab_profile", "jlcpcb-2layer",
"design_note", "a 3V3 sensor node; the fab put the order on hold",
"prescan", Map.of("ok", true, "kind", "both", "flags", List.of())));
String est = KicadDesk.call("/estimate", inputJson, null); // free
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
input_obj = {
"task" => "review",
"sch_text" => File.read("board.kicad_sch"),
"pcb_text" => File.read("board.kicad_pcb"),
"fab_profile" => "jlcpcb-2layer",
"design_note" => "a 3V3 sensor node; the fab put the order on hold",
# prescan is what the model is held accountable to - see step 4.
"prescan" => { "ok" => true, "kind" => "both", "flags" => [] }
}
est = call("/estimate", input_obj) # free: no job, no charge
puts "#{est["model"]} (#{est["model_alias"]}) reserve #{est["hold_credits"]}"
<?php
$inputObj = [
"task" => "review",
"sch_text" => file_get_contents("board.kicad_sch"),
"pcb_text" => file_get_contents("board.kicad_pcb"),
"fab_profile" => "jlcpcb-2layer",
"design_note" => "a 3V3 sensor node; the fab put the order on hold",
// prescan is what the model is held accountable to - see step 4.
"prescan" => ["ok" => true, "kind" => "both", "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"] = "review",
["sch_text"] = File.ReadAllText("board.kicad_sch"),
["pcb_text"] = File.ReadAllText("board.kicad_pcb"),
["fab_profile"] = "jlcpcb-2layer",
["design_note"] = "a 3V3 sensor node; the fab put the order on hold",
// prescan is what the model is held accountable to - see step 4.
["prescan"] = new Dictionary<string, object>
{
["ok"] = true, ["kind"] = "both", ["flags"] = new object[0]
},
};
var est = await KicadDesk.Call("/estimate", inputObj); // free
Console.WriteLine($"{est.GetProperty("model")} reserve {est.GetProperty("hold_credits")}");
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.
# 1. submit. The Idempotency-Key is not optional in practice: a retried POST
# without one is a second billed run.
KEY="kicad-desk:review:$(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 both file texts AND the attempt: two lanes over one
# project are two runs and must not collide on one key.
raw = json.dumps([input_obj["task"], input_obj["sch_text"], input_obj["pcb_text"],
input_obj["fab_profile"], input_obj["design_note"]])
key = "kicad-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")
import { createHash } from "node:crypto";
const raw = JSON.stringify([inputObj.task, inputObj.sch_text, inputObj.pcb_text,
inputObj.fab_profile, inputObj.design_note]);
const key = `kicad-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));
const result = JSON.parse(st.output.output);
console.log(result.verdict, result.findings.length, "findings");
b, _ := json.Marshal([]any{inputObj["task"], inputObj["sch_text"],
inputObj["pcb_text"], inputObj["fab_profile"], inputObj["design_note"]})
sum := sha256.Sum256(b)
key := fmt.Sprintf("kicad-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)
String raw = inputJson; // hash the same fields app.js hashes
String key = "kicad-desk:review:"
+ java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes())).substring(0, 32)
+ ":a1";
String job = KicadDesk.call("/run", inputJson, Map.of("Idempotency-Key", key));
String jobId = job.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");
String st;
while (true) {
st = KicadDesk.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["sch_text"], input_obj["pcb_text"],
input_obj["fab_profile"], input_obj["design_note"]])
key = "kicad-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"
result = JSON.parse(st["output"]["output"])
puts "#{result["verdict"]} #{result["findings"].length} findings"
<?php
$raw = json_encode([$inputObj["task"], $inputObj["sch_text"], $inputObj["pcb_text"],
$inputObj["fab_profile"], $inputObj["design_note"]]);
$key = "kicad-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["sch_text"], inputObj["pcb_text"],
inputObj["fab_profile"], inputObj["design_note"] });
var key = "kicad-desk:" + inputObj["task"] + ":" +
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
.Substring(0, 32).ToLowerInvariant() + ":a1";
var job = await KicadDesk.Call("/run", inputObj,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
JsonElement st;
while (true)
{
st = await KicadDesk.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 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.
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: the appearance of
# "findings", then "body", then the lane's own key, 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
# recovery path 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})
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", "")
if '"findings"' in buf:
pass # advance your progress display here
elif event == "done":
print("charged", payload.get("charged_credits"))
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 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 || "";
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"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
if event == "delta" {
buf.WriteString(p.Text)
} else if event == "done" {
fmt.Println("charged", p.Charged)
}
}
}
fmt.Println(buf.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(KicadDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + KicadDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder buf = new StringBuilder();
String[] event = { null };
KicadDesk.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);
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)
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..])
buf << (p["text"] || "") if event == "delta"
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);
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 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());
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. 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
- The lanes are one pipeline, not four tools.
next_lanenames what reads on from here, and passing the previousreviewresult back in asprior_reviewis what makes the later lanes build on it instead of restating it. - Nets are wire-level. The prompt forbids asserting that a component pin is unconnected, because the browser deliberately does not resolve pin positions. If your own prescan does resolve them, say so in
clip_noteordesign_noteand the model will use what you give it. - A failed fab check is two numbers disagreeing. The prompt forbids "cannot be manufactured". Expect "0.1 mm where jlcpcb-2layer lists 0.127 mm", and expect to be told to confirm against the options you order.
- Estimate per lane.
hold_creditsdiffers between lanes because the prompts and output caps differ. Never show one lane's hold for another lane's run.
What this API will not do
- It is not KiCad's DRC or ERC. There is no geometric rule engine behind it. A clean review is not a promise that KiCad's own checks pass.
- It does not fetch anything. No distributor lookup, no stock check, no price, no lead time, no library download. Everything in a reply comes from what you sent.
- It does not resolve pin geometry. See above; this is a deliberate refusal rather than a gap, because a netlist that is wrong at every rotated symbol is worse than no netlist.
- It does not remember. Each run is independent. Continuity between lanes is something you pass in.