HTTP API
Everything under /api is JSON. Errors are {"error": "<message>"} with an honest status code. Slugs are lowercase [a-z0-9-]; versions are vNNN.
Authentication. Requests from the machine itself need nothing. Any other client needs the access token — as the cookie set by /login, as Authorization: Bearer <token>, or once as ?token= in a URL. /api/health, /static/, /login and the manifest are always open. See Access control.
Long operations are jobs. A chat turn or an import answers 202 with a job; poll it or listen on the event stream. Add "wait": true to the body for a synchronous answer instead.
The app
GET /api/health | {ok, version, uptime, jobs:{queued,running,done,failed}}. Cheap: never touches git, Java or the disk. |
GET /api/app | version and build (commit, tag, dirty), project format, chat:{available, backend, model, why}, router:{java, jar, ready}, project counts, data_dir, job stats, whether auth is on. |
GET /api/changelog | the parsed CHANGELOG.md: releases:[{version, date, sections:{Added:[…], …}}]. |
GET /api/benchmarks | what the shipped models scored, read off disk and never recomputed: routability (the meta block of soldermask/assets/routability.json — training-day numbers, see Benchmarks), pipeline:{runs:[{run, mtime, rows:[{backend, …}]}]} from any eval*/summary.json, and rubric. routability and rubric carry available: false when the file is not there, which is the normal state for a deployed checkout. |
GET /api/platforms | {platforms:[…]} — the curated microcontrollers a board can be built around, each with what platforms.py declares (symbol, mounting, rails, antenna keepout) plus geometry, price and stock read live by LCSC code. See Projects. |
GET /api/platforms/<pid>/mesh | the packed 3D model for one platform, in models3d.pack_mesh()'s wire format. The meshes are the heavy half, which is why they are a second endpoint rather than part of the list. |
Projects
GET /api/projects?archived=1 | {projects:[summary…], trash:n}, most recently touched first. A summary carries name, slug, description, tags, archived, format, versions, current, summary, prompt, parts, erc_errors, gaps, cost, passed, label, app_version, thumbnail, created, touched. passed is the build's own verdict (workspace.verdict(), the evaluator's pass rule): true, false, or null for a version built before there was one or read from a file. |
POST /api/projects | create. Body: name, description, and one of — nothing (a blank project, 201), prompt with inline: true (a chat job, 202 {project, job}), design (an import job, 202), or kicad with filename (a finished board read as it is, 202; add route: true to route it too). note labels an import. wait: true runs inline and answers like a finished turn. Without a name the project is named from the prompt and renamed after the first board. |
GET /api/projects/<slug> | the detail: the summary plus versions:[{id, created, when, summary, prompt, parts, erc_errors, gaps, cost, passed, parent, app_version, label, notes, sim}] (sim — whether a sim.json lies beside the version) and chat:[{t, you, bot, version, steps, kind?, first?, error?}], the thread oldest first — every turn's message, reply, the version it made ("" for a question) and the stages the turn went through as [{phase, at, s}], empty for a turn from before they were kept. One turn is one entry: a build that was revised names its first version in first and carries the revision as version. kind: "note" is a turn the app started (a rebuild, a route-again, a file read in); error: true is a turn that failed before it answered, bot being why. |
PATCH /api/projects/<slug> | {name?, description?, tags?, archived?} → detail. |
DELETE /api/projects/<slug> | to the trash. 409 while a build is running. |
POST /api/projects/<slug>/fork | {name?, version?} → 201 the new project's detail. |
GET /api/projects/<slug>/export.zip?chat=0 | the whole project as one zip — project.json, draft.json, every vNNN/ — laid out as on disk. chat=0 leaves the conversation out of the bundled project.json. 409 if the version table names a version the disk lacks. |
POST /api/projects/import | a bundle back in: multipart with a file field (and optional name), or the zip as the body. 201 {project}. 400 with the member or field it stopped at for anything that is not a project, 409 for a newer app's format, 413 over 256 MB. Nothing is written unless the whole zip held. |
POST /api/projects/<slug>/chat | {message, base?, backend?, model?, effort?, wait?}. base is the version to edit (it becomes current first). 202 {job, project}, or with wait 200 {reply, changes, board, version, project}. A question is answered without a build: version and board are null and changes is empty. A change whose build was not clean may make two versions (the build and its revision); version is the one that stands. 400 empty, 413 over 20,000 characters, 409 already building, 503 no credentials, 502 model failure, 500 pipeline failure. |
POST /api/projects/<slug>/import | {design, note?, wait?} — same shape of answer as chat. 422 if it is not a design, or if its constraints block is malformed (named by field). A well-formed block is kept when the project has none in force and set aside, with a line in the reply, when it has. |
POST /api/projects/<slug>/import | {kicad, filename?, route?, wait?} — the text of a .kicad_pcb, read into a version with its own placement kept. Nothing is designed, resolved, priced or routed unless route: true. 422 with the reason if the board is not one this pipeline can build. |
GET /api/projects/<slug>/jobs | {active: job|null, recent:[job…]}. |
Constraints
What a person decided about their own board — pinned parts, edges, keepouts, schematic positions, hand-drawn copper, which side of the board a part is on (pinned[ref].side, back or the default front), how many sides the placer may use (board.sides, 1 to hold a board to one side; 2 is the default), and the fab rules they set themselves (rules.min_track_mm, a floor under every routed net; rules.router, freerouting or krt, which program lays the copper first). Saved without building anything: a rebuild is the only thing that makes a version.
| Route | Answer |
|---|---|
GET /api/projects/<slug>/constraints?base=<vid> | {base, constraints, unbuilt, touched}. base defaults to the current version. unbuilt: true means there are edits saved against this version that no build has seen yet — which is what lets the canvas say the copper on screen is stale. |
GET /api/projects/<slug>/sheet?base=<vid> | {base, sheet} — the schematic sheet laid out under the constraints in force for that version (the draft's positions and lanes when the draft is that version's). Milliseconds, builds nothing; the workbench asks for it after a symbol or a lane is dropped. |
PUT /api/projects/<slug>/constraints | {base?, constraints} → the same shape. Validates and stores; runs no pipeline and creates no version. 400 names the first thing wrong (pinned[J1] rot must be 0, 90, 180 or 270) and stores nothing — a pin the app said it saved and did not is worse than a refusal, because the next rebuild moves the part and nothing on screen explains why. 404 for an unknown version, 409 for a project with no version to pin to yet. |
DELETE /api/projects/<slug>/constraints | throw the unbuilt edits away and fall back to what was last built. |
POST /api/projects/<slug>/rebuild | {base?, seed?, wait?} — build the design that is already there again, against the constraints as they now stand. Nothing about the design changes; what changed is around it. seed overrides the block's rules.seed for this one build; absent, the block's applies (1 when unset). Answers like chat: 202 {job, project}, or with wait the finished turn. 409 for a project with nothing built yet or a build already running, 404 for an unknown version, 400 for a seed that is not a whole number 0–10000. |
GET /api/projects/<slug>/variations | The rack's saved variations: {variations: [{id, name, macros, when}]}, macros a constraints-shaped block carrying only the knobs (rules.router, rules.seed, rules.pour, rules.precedent, rules.min_track_mm, board.sides, board.fixed). |
POST /api/projects/<slug>/variations | {name, macros?, base?} — save one. With macros, that block (validated; a key that is not a knob is 400); without, the knobs as they stand in the constraints for base. 201 {variation, variations}. |
PATCH /api/projects/<slug>/variations/<id> | {name} — rename. DELETE forgets it. 404 for an unknown id. |
POST /api/projects/<slug>/variations/<id>/recall | {base?} — the variation's knobs written over the draft constraints for base: every knob it carries set, every one it does not carry cleared, everything that is not a knob untouched. Builds nothing; answers as GET /constraints does, unbuilt true. |
POST /api/projects/<slug>/route | {base?, wait?} — one stage on its own: the version's placement routed again. Its parts and positions go into the build as fixed (workspace.from_version), so the anneal has nothing to move and the router runs once on the board that was there, as the constraints now stand. The router is deterministic, so the copper differs only where something it reads has. A new version, saved with the version's own design rather than the copy the parts were pinned onto. Same answers and errors as rebuild; 409 for a version with no placement. |
POST /api/projects/<slug>/versions/<vid>/simulate | The simulation stage on its own, for a version that has no sim.json — built before the stage existed, or where ngspice was not — or again after the models improved. Resolves the version's own parts and runs ngspice on the netlist: seconds, synchronous, no new version — the file lands beside the version's board.json as it would have at build time. 200 {sim, gaps, notes, version}, the gaps and notes being what the build would have put on the board; 409 with the reason when there is no simulation (no engine, no ground, no declared rail). |
See design.json for the block itself. A change publishes {constraints: <base>} on /api/events, because another device looking at this board has no other way to find out.
Jobs
GET /api/jobs/<id> | {id, kind: chat|import, project, message, created, started, finished, status: queued|running|done|failed, phase, steps, result, error, elapsed}. phase is the stage the job is on now; steps is every stage it has passed through, [{phase, at, s}] — when each began (epoch seconds) and how long it ran, the last one to now while the job is still on it. result is {version, prompt, reply, changes, decisions}; version is null for a turn that answered without building, and prompt is the line the record keeps for the turn (what was asked, or the app's own note for a rebuild or a route-again). |
Parts and blocks
Three ways to change what is on a board without describing it to a model — two that add and one that takes away. All of them end in a proposal: the board that results is resolved, placed, routed and priced before anybody commits to it, which is as true of a deletion as of an addition.
| Route | Answer |
|---|---|
GET /api/parts?q=&package=&limit=&stock= | JLCPCB stock: {query, parts:[{lcsc, mfr, description, package, category, stock, price, basic, pads, kind, footprint}]}. kind is what the catalogue's own category says the part is, "" where it does not say. footprint is the package name this pipeline has geometry for, "" when the pads would come from the vendor's drawing of that exact code instead. stock=0 includes parts that are out. 503 when the catalogue cannot answer — which is an outage here, not a statement about the part. |
GET /api/parts/<lcsc>?kind=&value= | one part, resolved: {part, kind, package, symbol, component, pins:[{pin, pad, name, why}], notes, gaps}. pins is the answer to "what may a net call this pad": pin is the string to use and is empty when nothing resolves to that pad, with why saying so. Every name is checked through the resolver the build uses, so a name offered here is a name the build accepts. component is the design line the part would be written as. 404 for a code JLCPCB does not list. |
POST /api/projects/<slug>/parts | {lcsc, kind, value?, purpose?, connect?, wait?} — put that part on the board. connect is {pin: net} against nets the board already has; a pin left out is left unconnected, and ERC reports the part as an orphan if none are given. kind is required and never defaulted: it decides which electrical rules the part is checked against. 202 {job, project}, or with wait {reply, proposal, review, said, refused, ref}. 400 for a missing part or kind, 422 for a net that does not exist, 409 if there is no board yet. |
DELETE /api/projects/<slug>/parts | {refs: […], wait?} — take those parts off the board. No model is asked: which parts leave is not a judgement. What is not free is what leaves with them — every pin they had on a net goes too, and a net left with fewer than two pins goes with it, both counted in review and named in reply before anything is written. Same answers as adding a part, plus removed. 400 for no refdes, 422 for a refdes this board does not have and for every part at once, 409 if there is no board yet. |
GET /api/blocks?q= | the block library: {blocks:[{name, boards, source, instances, agreed, named, ports, parts, checked}], total}. source is mined or authored, and the evidence differs by source — a mined block offers the boards that agreed, an authored one that a person wrote it against a datasheet. |
POST /api/projects/<slug>/blocks | {block, connect?, wait?} — drop a block in. connect is {port: net} and is a net merge, not a wire. Same answers as adding a part. |
Proposals
A change to a board that already exists does not become the board. The model returns what changes — a patch, not a new copy of the design, so a part it never mentions cannot be dropped — which is applied to a copy, built, and reported on before anyone commits to it.
| Route | Answer |
|---|---|
POST /api/projects/<slug>/propose | {message, selection?, backend?, model?, effort?, wait?}. selection is {refs: […], nets: […]} and scopes the instruction: what is selected may be changed or removed, anything may be added, and everything else is refused by name. 202 {job, project}, or with wait {reply, proposal, review, said, refused}. Builds, but writes nothing to the project. 409 if there is no board yet. |
GET /api/projects/<slug>/proposal | {proposal} — what is on the table: the message, the scope, the review, the files built. null when nothing is. |
GET /api/projects/<slug>/proposal/board | the proposal's board as it was built, for a page that did not build it and has to draw what is under the Accept bar. 404 when nothing is proposed. |
POST /api/projects/<slug>/proposal/accept | the proposal becomes the next version, and only now: {version, board, project}. 409 if nothing is proposed. |
POST /api/projects/<slug>/proposal/reject | the board is exactly as it was. |
review is {parts:{added,removed,changed}, nets:{added,removed,changed}, erc:{before,after,errors,warnings}, cost:{before,after,delta}}, and said is the same thing in sentences. Acceptance is the whole proposal at once — per-hunk review works on text because text is lines, and there is no way to accept half a route.
Versions
GET …/versions/<vid> | the version's metadata plus files and its diff against its parent. |
PATCH …/versions/<vid> | {label?, notes?} → project detail. |
POST …/versions/<vid>/restore | make it current: {board, project, version}. |
GET …/versions/<vid>/board | board.json. |
GET …/versions/<vid>/design | design.json. |
GET …/versions/<vid>/diff?against=<vid> | {from, to, components:{added, removed, changed}, nets:{added, removed, changed}, board, summary:[…], empty}. |
GET …/versions/<vid>/files | {files:[{name, size, kind: gerber|assembly|router|kicad|preview|app|other}]}. |
GET …/versions/<vid>/files/<name>?dl=1 | one file; SVG, JSON and HTML display inline unless dl=1. Cached a day: versions never change. |
GET …/versions/<vid>/gerbers.zip | Gerbers, drill, BOM and CPL. |
GET …/versions/<vid>/inspector.html?dl=1 | the standalone viewer with board and 3D models inlined. |
Trash
GET /api/trash | {trash:[{slug, name, versions}]}. |
POST /api/trash/<slug>/restore | back to the projects list (renamed if the name is taken). |
DELETE /api/trash | empty it permanently. |
Live events
GET /api/events?project=<slug> is a server-sent event stream. Messages are JSON objects and can be:
- sync state from another device —
{from, view?, orbit?, sel?, net?, qty?}; - a version switch —
{version}; - build progress —
{phase, at}and{job, status, kind, phase?, at?, version?, error?}as a job is queued, runs, reports a phase, and finishes;atis when the phase began, epoch seconds; event: byewhen the stream ends.
A stream holds one server thread for as long as it runs, and it cannot tell that its client has gone — a closed laptop leaves a socket that is written into and never read. So two limits apply, and between them a browser that keeps reconnecting can never take the threads that answer ordinary requests. At most a third of the server's workers stream at once; past that the endpoint answers 503 with Retry-After: 10 instead of spending a thread, and the page keeps working on its job poll until a slot frees. A stream ends after five minutes and the browser's EventSource reconnects on its own, which is what returns a thread abandoned by a client that went away. A listener too slow to drain its queue is dropped and told so, ending its stream, rather than being left connected to a queue nobody publishes to.
POST /api/state with {project, from, …} publishes sync state to the other devices on that project (204).
Example
T=$(cat .cache/access-token)
J=$(curl -sk -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"message":"add a power LED"}' https://192.168.1.10:8443/api/projects/ldo-breakout/chat \
| jq -r .job.id)
until curl -sk -H "Authorization: Bearer $T" https://192.168.1.10:8443/api/jobs/$J | jq -e '.status=="done"' >/dev/null; do sleep 5; done
curl -sk -H "Authorization: Bearer $T" -o ldo.zip https://192.168.1.10:8443/api/projects/ldo-breakout/versions/v003/gerbers.zip