Schematics API
Manage project signal flow diagrams with AI-powered auto-wiring.
GET /api/v1/projects/{projectId}/schematics
Get schematics for a project.
Response
{
"success": true,
"data": {
"projectId": 123,
"projectName": "Corporate HQ",
"hasSchematics": true,
"schematics": {
"id": 1,
"projectId": 123,
"hasCustomLayout": true,
"deviceCount": 24,
"pageCount": 2,
"createdAt": "2024-12-10T09:00:00Z",
"updatedAt": "2024-12-15T14:30:00Z"
}
}
}
POST /api/v1/projects/{projectId}/schematics
Create or initialize schematics for a project.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
initialLayoutJson |
string | No | JSON string containing initial layout data (devices, connections, positions) |
Example Request
{
"initialLayoutJson": "{...layout data...}"
}
Response (201 Created)
{
"success": true,
"data": {
"schematicsId": 1,
"projectId": 123,
"message": "Schematics created successfully"
}
}
GET /api/v1/schematics/{id}
Get schematic details including full layout data.
Response
{
"success": true,
"data": {
"schematics": {
"id": 1,
"projectId": 123,
"projectName": "Corporate HQ",
"hasCustomLayout": true,
"deviceCount": 24,
"pageCount": 2,
"layoutJson": "{...full layout data...}",
"createdAt": "2024-12-10T09:00:00Z",
"updatedAt": "2024-12-15T14:30:00Z"
}
}
}
PUT /api/v1/schematics/{id}
Update schematics layout data.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
layoutJson |
string | No | JSON string containing updated layout data |
deviceCount |
integer | No | Total number of devices in the schematic |
pageCount |
integer | No | Number of pages in the schematic |
Example Request
{
"layoutJson": "{...updated layout data...}",
"deviceCount": 26,
"pageCount": 3
}
DELETE /api/v1/schematics/{id}
Delete schematics from a project.
GET /api/v1/schematics/{id}/export/dxf
Export schematics to DXF format for CAD software.
Response
Returns a DXF file download with content-type application/dxf.
GET /api/v1/projects/{projectId}/schematic/export/pdf
Get a signed view URL for the project's signal-flow schematic. Each project has at most one schematic; pass the project id and this endpoint resolves it. Open the returned URL in any browser to see the schematic rendered as a PDF — devices, connections, and the title block exactly as saved. Use the browser's print/save dialog or the on-page Download button to save a local copy. URL expires 30 days after issuance.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
projectId |
integer | The ID of the project whose schematic you want. |
Example Request
curl -X GET 'https://app.avstackr.com/api/v1/projects/431/schematic/export/pdf' \
-H 'Authorization: Bearer avs_yourkey'
Response (200 - Success)
{
"project_id": 431,
"schematic_id": 51,
"view_url": "https://app.avstackr.com/schematic/eyJ...",
"expires_at": "2026-06-15T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
project_id | integer | Echoes the project id from the request. |
schematic_id | integer | The resolved schematic record id. |
view_url | string | A signed URL that renders the schematic as a PDF in any browser. No login required; the signature binds tenant + schematic + expiry. |
expires_at | string (ISO 8601) | When the URL stops working. 30 days from issuance. |
Response (404 - Not Found)
Returned when the project has no saved schematic, or the project doesn't belong to your tenant.
{
"success": false,
"error": {
"code": "SCHEMATIC_NOT_FOUND",
"message": "No schematic found for project 431"
}
}
PATCH /api/v1/schematics/{id}/devices/{deviceId}/notes
Set or clear the top or bottom note on a device. Pass null for text to clear the note at that position.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | Schematic ID. |
deviceId | string | Device ID within the schematic layout. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
position | string | Yes | "top" or "bottom". |
text | string | No | Note text. Omit or pass null to clear. |
alignment | string | No | "left", "center", or "right". Default: "left". |
fontSize | string | No | "NOTES_SM", "NOTES_MD", or "NOTES_LG". Default: "NOTES_MD". |
Example Request
curl -X PATCH 'https://app.avstackr.com/api/v1/schematics/1/devices/dev-42/notes' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"position":"top","text":"Check gain staging","alignment":"center","fontSize":"NOTES_SM"}'
Response (200 - Success)
{
"schematicId": 1,
"deviceId": "dev-42",
"updated": true
}
Error Responses
400 Bad Request—positionis missing or not"top"/"bottom", oralignment/fontSizeis not a recognized value.404 Not Found— schematic or device not found.
PATCH /api/v1/schematics/{id}/devices/{deviceId}/name
Set or clear a device's custom name — the editable "third line" shown in the device header. Pass null or omit name to clear it.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | Schematic ID. |
deviceId | string | Device ID within the schematic layout. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Custom name text. Omit, pass null, or pass a blank string to clear. |
Example Request
curl -X PATCH 'https://app.avstackr.com/api/v1/schematics/1/devices/dev-42/name' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"name":"Head-End Rack A"}'
Response (200 - Success)
{
"schematicId": 1,
"deviceId": "dev-42",
"updated": true
}
Error Responses
404 Not Found— schematic or device not found.
PATCH /api/v1/schematics/{id}/devices/{deviceId}/ports/{index}/label
Set or clear the inner label on a port. index is the unified port index (inputs first, then outputs, zero-based). Omit label to clear.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | Schematic ID. |
deviceId | string | Device ID within the schematic layout. |
index | integer | Unified port index (0-based, inputs before outputs). |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
label | string | No | Label text. Omit or pass null to clear. |
Example Request
curl -X PATCH 'https://app.avstackr.com/api/v1/schematics/1/devices/dev-42/ports/0/label' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"label":"IN 1"}'
Response (200 - Success)
{
"schematicId": 1,
"deviceId": "dev-42",
"portIndex": 0,
"updated": true
}
Error Responses
400 Bad Request— port index out of range or device has no connection metadata.404 Not Found— schematic or device not found.
PATCH /api/v1/schematics/{id}/tags/{tagId}/connector
Set the connector shape and icon on a port tag. Both shape and connectorIcon are updated together as a single atomic operation — pass the current value of the field you are not changing to leave it unchanged.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | Schematic ID. |
tagId | string | Port tag ID within the schematic layout. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
shape | string | No | Connector shape (e.g. "circle", "square"). Null clears the shape. |
connectorIcon | string | No | Icon identifier for the connector. Null clears the icon. |
Example Request
curl -X PATCH 'https://app.avstackr.com/api/v1/schematics/1/tags/tag-7/connector' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"shape":"circle","connectorIcon":"xlr"}'
Response (200 - Success)
{
"schematicId": 1,
"tagId": "tag-7",
"updated": true
}
Error Responses
404 Not Found— schematic or tag not found.
PATCH /api/v1/schematics/{id}/tags/{tagId}/link
Pair two flag tags as a jump-tag pair, so a signal that leaves one continues at the other, or clear a tag's pairing. Send linkedTagId to link; send null (or omit it) to unlink. The pair is symmetric — the partner is updated too.
Linking replaces any pair either tag already belongs to. The tags that lost their partner come back in unlinkedTagIds — read it, because nothing else tells you what the call broke.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | Schematic ID. |
tagId | string | Port tag ID within the schematic layout. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
linkedTagId | string | No | Tag to pair with. Null, omitted, or blank unlinks this tag instead. |
Example Request
curl -X PATCH 'https://app.avstackr.com/api/v1/schematics/1/tags/tag-7/link' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"linkedTagId":"tag-9"}'
Response (200 - Success)
{
"schematicId": 1,
"tagId": "tag-7",
"linkedTagId": "tag-9",
"unlinkedTagIds": ["tag-4"],
"updated": true
}
unlinkedTagIds lists the tags this call left unpaired — up to two when linking, one when unlinking a paired tag, empty otherwise.
Error Responses
400 Bad Request—tagIdandlinkedTagIdare the same tag.404 Not Found— schematic or either tag not found.
Canvas Editing
Build a schematic from your integration the same way the in-app copilot does — place equipment, wire and route connections, assign cable numbers, tag ports, and auto-fill kits. Every write appears live on any canvas open in the app, attributed to the API key's user. These routes are keyed by project id (the canvas URL's id), not the schematic row id used by the PATCH routes above; the /project/ segment keeps the two apart.
Port indices are unified and zero-based (inputs first, then outputs). Always call the devices read first to get correct device ids and port indices — wiring to a wrong index attaches the wire to the wrong port.
cable-types/assign and cable-numbers/delete save immediately, but teammates with the canvas already open see the change on their next load rather than instantly. Everything else (devices, wires, routing) updates live.
Response Shape
Each canvas route returns the tool's own JSON result directly (not wrapped in the { success, data } envelope). Reads return the requested collection with a count; writes return a per-item result summary. All numeric ids are integers; device ids are strings.
GET /api/v1/schematics/project/{projectId}/canvas/devices
Read device blocks on the canvas. Optional ?page= query limits to one page; omit for all pages. Each device also carries sourceRoomId (int) and sourceRoomName (string) — the room the device counts against, set when it was placed. Both are null when the device is not linked to a room.
{
"deviceCount": 2,
"devices": [
{ "id": "dev-1", "name": "Apple TV", "category": "input", "manufacturer": "Apple", "model": "A2843", "page": 1, "x": 1.0, "y": 1.0, "power": "AC", "sourceRoomId": 12, "sourceRoomName": "Boardroom", "ports": [ { "index": 0, "side": "output", "portType": "hdmi_a", "innerLabel": "HDMI", "poeSource": null, "poeDraw": null, "connectedTo": [ { "deviceId": "dev-2", "portIndex": 3 } ] } ] }
]
}
Each device carries power, and each port carries poeSource and poeDraw. power is what the whole box draws — a PoE tier, "AC", "DC", "Power", or null when unstated; a PoE tier here is read off the jack marked PD when the device has one. On a port, poeSource is the tier that jack supplies to whatever wires into it ("none" when it was confirmed to supply nothing, null when never stated), and poeDraw is the tier that jack draws from the far end. A jack carries at most one of the two. So a ceiling microphone's AVB/Ctrl jack reads "poeDraw": "PoE+", and the switch port feeding it reads "poeSource": "PoE+". A jack that draws must land on a jack that supplies at least that tier.
Every port carries connectedTo — what is already wired to it. Each entry is one far end already plugged into that jack, named by deviceId and portIndex. The index is the same flat, zero-based number this API uses everywhere else (inputs 0..N-1, then outputs), so you can feed it straight back into the wire and unwire routes. An unwired port returns an empty list. Nothing is stored: the field is worked out from the drawing's wires each time you read, so deleting a wire frees the port on the next read. A port whose far end sits on another page still reports it — the read is filtered by page, the occupancy is not.
Wire to a port whose connectedTo is empty. Reuse one that already carries a wire only when the human explicitly asked for fan-out — one source feeding several destinations. The wire routes do not refuse a second wire on a busy port; they apply it and warn, which means a double-wire caused by not reading this field looks like success.
GET /api/v1/schematics/project/{projectId}/canvas/connections
Read wiring connections. Optional ?page=. Returns { "connectionCount": N, "connections": [...] }. A wire end on a port the device no longer has reports orphaned: true and index -1; sync that device to clear it.
GET /api/v1/schematics/project/{projectId}/canvas/equipment
Read the project's bill of materials. Optional ?roomId= to scope to one room. Each line item carries a productId you pass directly to the add-device route.
GET /api/v1/schematics/project/{projectId}/canvas/cable-numbers
Read cable-number assignments. Optional ?page=. Returns { "assignmentCount": N, "assignments": [...] }.
GET /api/v1/schematics/project/{projectId}/canvas/waypoints
Read wire waypoints (bend points). Optional ?page=. Returns { "connectionCount": N, "connections": [...] }.
GET /api/v1/schematics/project/{projectId}/canvas/bundles/search
Find placeable equipment bundles by name. Query: ?query= (matched against bundle name and description). Returns { "bundleCount": N, "bundles": [{ "id", "name", "description", "itemCount" }] }. Feed id straight into the add-bundle route's bundleId.
GET /api/v1/schematics/project/{projectId}/canvas/schematic-bundles/search
Find placeable pre-wired schematic templates by name. Query: ?query= (matched against template name and description). Returns { "templateCount": N, "templates": [...] }. Each template's id is Id (capital I — these results use PascalCase keys); feed it into the add-schematic-bundle route's templateId.
GET /api/v1/schematics/canvas/playbook
Return the wiring rule index the copilot works to. JSON body: the charter, the eight drawing stages in order, one line per rule with its id and what enforces it, how many rules nothing enforces, and the manufacturers this knowledge base documents. No project id needed. Call once before building, then read_rules for the full text of any rule by id.
POST /api/v1/schematics/project/{projectId}/canvas/devices
Place a catalog product as a device block. Body: productId (int, required), page (int, required), subDeviceIndex (int, optional; -1 = all kit sub-devices, 0+ = one), roomId (int, optional; the room to link the device to — see the room-link rules below). Appears live in its signal-flow column.
curl -X POST 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/devices' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"productId":1234,"page":1}'
Room links on the three placement routes. A placed device is linked to a room when its product is on a room's equipment list; a product on no list still places unlinked (sourceRoomId: null), as before. Omit roomId and the device links to the room whose equipment list carries the product. This is a behavior change when a product sits on two rooms' lists: the call now refuses with { "error": "ambiguous_room", "message": "...", "candidates": [{ "roomId": 12, "roomName": "Boardroom" }] }. Pick a roomId from candidates and send the call again. A roomId the project's active version does not carry refuses the same way with "error": "room_mismatch". Both refusals return HTTP 200 with the error body — the same shape every canvas tool error uses — and place nothing. You may name any room on the active version, even one the product is not on: the device links to that room and shows there as an extra, which the room's equipment reports flag on every later push. The rooms you can name are the active version's own: archived rooms are not link targets, neither are legacy rooms saved before this project was versioned, and a project with no active version lists no rooms to the copilot at all. Switching the active version hides the links made against the other one. Anyone with the canvas open sees the device appear live with its room link, and their own saves keep it. Two placements running at the same moment can overwrite each other's layout, so place from one caller at a time.
POST /api/v1/schematics/project/{projectId}/canvas/bundles
Place every product from an equipment bundle. Body: bundleId (int, required), page (int, required), roomId (int, optional; applied to every product the bundle places). Devices pop in one by one, live.
POST /api/v1/schematics/project/{projectId}/canvas/schematic-bundles
Place a pre-wired schematic template at a position. Body: templateId (int, required), page (int, required), x (number), y (number) in grid inches, roomId (int, optional). A template has no product to resolve a room from, so omit roomId and its devices stay unlinked; its sub-device rows are never linked.
POST /api/v1/schematics/project/{projectId}/canvas/pages
Add a blank page. No body. Returns { "success": true, "pageCount": N }.
POST /api/v1/schematics/project/{projectId}/canvas/devices/move
Move devices. Body: moves (array of { deviceId, x, y } in grid inches, required), page (int, optional, default 1). Positions snap to grid; wires auto-adjust. Returns { "moved": N, "total": M, "results": [...] }.
POST /api/v1/schematics/project/{projectId}/canvas/devices/arrange
Arrange devices on a grid. Body: deviceIds (array of strings, required), page (int, optional, default 1). A device's column follows the wires feeding it and its row follows the device that feeds it; each room forms its own band, devices not named stay put, and wires the router drew are re-routed. Returns { "moved", "total", "results", "zonesNowMisfit", "pagesAdded", "reroute": { "routed", "skipped", "failed", "flagged", "crossingBlocks", "grazingPins", "sharingTrack" }, "wiringViolations", "skippedBusGroupWires" } (reroute is null when no wire touches a moved device). A plan it cannot place returns error, reason and subjects, plus a refusal when a wire would span two pages. An empty or missing deviceIds and a page outside the layout each return error, and ids not on the layout return error plus unknownDeviceIds.
POST /api/v1/schematics/project/{projectId}/canvas/devices/delete
Delete devices. Body: deviceIds (array of strings, required). Connections to/from each device are removed automatically. Returns { "deleted": N, "total": M, "results": [...] }.
POST /api/v1/schematics/project/{projectId}/canvas/connections
Create wiring connections. Body: connections (array, required), each { sourceDeviceId, sourcePortIndex, destDeviceId, destPortIndex } plus an optional cableType. Returns { "applied": N, "skipped": M, "warnings": [...], "cables": [...], "rejected": [...] }.
cableType is optional, and the server fills it in. Omit it and the registry picks the cable from the source port's own type. Every wire that was applied comes back in cables as { sourceDeviceId, sourcePortIndex, destDeviceId, destPortIndex, cableType, cableTypeSource }, so you always learn which cable each wire got. cableTypeSource is "Caller" when you named one the registry carries, "Registry" when it mapped the source port's type, and "RegistryFallback" when it had no mapping for that port type and used the generic cable. A cableType the registry does not carry is refused: that wire lands in rejected and nothing is written for it.
A port index the device does not have is refused, not skipped. That entry lands in rejected with a sentence naming the device's real port count — the same three-way answer the connection-delete and cable-type routes below give — and every other connection in the same request still applies. rejected also carries the wires the drawing's own rules refuse: a wire between two sheets, a PoE draw landing on a jack that supplies less, a cable type the registry does not carry. Each entry carries a refusal with the ruleId that was applied and, where one exists, a remedy naming the call that clears it.
Landing a wire on a port that already has one is a warning, never a refusal. The wire is applied and warnings gains a line naming what was already there: "Port already carries a wire (the new wire was applied anyway): dev-1 port 0 already wired to dev-2 port 3". A request whose source and destination are both busy produces one line covering both ends. The count in applied includes these wires — they were made, so warnings is the only place they are reported. An exact duplicate of a wire that already exists is skipped instead, and says nothing.
The check sees the wires this same request has already created, not just the ones that were on the drawing when the call arrived. Send two connections onto one port in a single batch and the second warns about the first. So a batch that wires a whole rack reports every collision it caused itself, in order.
curl -X POST 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/connections' \
-H 'Authorization: Bearer avs_yourkey' \
-H 'Content-Type: application/json' \
-d '{"connections":[{"sourceDeviceId":"dev-1","sourcePortIndex":0,"destDeviceId":"dev-2","destPortIndex":0}]}'
POST /api/v1/schematics/project/{projectId}/canvas/connections/delete
Remove wiring connections. Body: connections (array of { sourceDeviceId, sourcePortIndex, destDeviceId, destPortIndex }, required). Returns { "removed": N, "rejected": [...], "unmatched": K }. A port index the device doesn't have is refused on its own — that entry lands in rejected with a sentence naming the device's real port count, and every other connection in the same request still deletes. unmatched counts the requests whose ports are real but that matched no wire.
POST /api/v1/schematics/project/{projectId}/canvas/cable-types/assign
Assign cable-type labels. Body: assignments (array, required), each { sourceDeviceId, sourcePortIndex, destDeviceId, destPortIndex, cableTypePrefix }. Numbers auto-increment per prefix. Returns { "assigned": N, "results": [...], "rejected": [...] }. A port index the device doesn't have is refused on its own — that assignment lands in rejected with a sentence naming the device's real port count, and its siblings in the same request are still numbered.
POST /api/v1/schematics/project/{projectId}/canvas/cable-numbers/delete
Remove cable-number labels. Body: connections (array of connection identifiers, required). Returns { "removed": N, "rejected": [...], "unmatched": K } — the same three-way answer as the connection delete above: refused for an index the device doesn't have, unmatched for real ports carrying no label, removed for the labels actually cleared.
POST /api/v1/schematics/project/{projectId}/canvas/waypoints
Set wire waypoints. Body: connections (array, required), each { sourceDeviceId, sourcePortIndex, destDeviceId, destPortIndex, waypoints:[{x,y}, …] } — up to 12 waypoints per connection, in order from source to destination (use several to route a wire around device blocks). A connection with more than 12 waypoints is rejected: that entry fails while the other connections in the same request still apply. The response also reports any collisions the new routes cause: { "set": N, "total": M, "results": [...], "wiringViolations": [...], "skippedBusGroupWires": K }, where each violation names the wire, the crossed device, and the offending span.
POST /api/v1/schematics/project/{projectId}/canvas/port-tags
Add a label tag to a port. Body: deviceId (string, required), portIndex (int, required, unified index), label (string, required).
DELETE /api/v1/schematics/project/{projectId}/canvas/port-tags/{tagId}
Permanently delete a port tag. If it's paired with a jump-tag partner, the partner is unlinked first (it survives) and its id comes back as clearedPartnerId; any cable number on the tag is released too. Returns { "success": true, "tagId": "…", "clearedPartnerId": "…" } — clearedPartnerId is null when the tag wasn't linked. There's no dedicated route to list tag ids: pull them from the schematic's layoutJson (GET /api/v1/schematics/{id}, above) — each entry under portTags carries its id.
POST /api/v1/schematics/project/{projectId}/canvas/devices/{deviceId}/autofill
Auto-fill connection metadata for a device using AI. Body: kitMode (bool). With kitMode: true the original device is replaced by its sub-devices (they pop in live); the response lists the new sub-device ids.
Sync from the Catalog
Re-copy a device's ports from the catalog product it was placed from — the ports, their types, labels, PoE supply and draw as the catalog holds them today. Use it when a product's ports were corrected or filled in after the device was already drawn.
The product is found by the id stamped on the device when it was placed, falling back to its make and model text. A catalog line that has since been renamed still syncs. Naming one part of a placed kit syncs every part of that kit, because a kit part's ports live on the parent kit's catalog row — a sibling parked on another page is synced too, and comes back carrying its own page number.
A port whose type or label changed can no longer carry the cable plugged into it, so that wire is deleted and its cable number released. A change to PoE or notes alone deletes nothing. A device's display name is never rewritten; a kit part's name is, because that name is the key the catalog part is looked up by.
A sync also deletes any wire whose end sits on a port the device no longer has, listing it in deletedWires with that end's index as -1 — this can happen even on a device the run reports as inSync, when an earlier change elsewhere left the wire stranded.
These three routes sync port metadata and nothing else. Restamping the product's custom device block is the extra step the in-app Sync button takes, so artworkChanged is always false in every response here — even for a product whose artwork did change.
Sync Response Shape
All three routes answer with the same object. preview is true when nothing was written and false when the run was performed. devices lists every device the run visited — including the ones it left alone, so you can tell why a device was skipped. ports is that device's ports after the run, in the same shape the devices read returns them, connectedTo included.
{
"preview": true,
"devices": [
{
"deviceId": "dev-1",
"page": 1,
"status": "synced",
"ports": [...],
"deletedWires": [ { "sourceDeviceId": "dev-1", "sourcePortIndex": 2, "destDeviceId": "dev-4", "destPortIndex": 0 } ],
"artworkChanged": false
}
],
"totals": { "synced": 3, "deletedWires": 1 }
}
deletedWires is flat: one entry per wire, both ends named by device id and flat port index — the same indices the wire routes take, so you can re-create a wire you did not want to lose. A wire between two devices in the same run is deleted once and listed once, under whichever end the run reached first. That end may be a device whose own status is inSync, when only the other end of the wire changed.
Sync Status Values
| Status | Meaning |
|---|---|
synced |
The catalog differed and was written onto the device (or, in a preview, would be). |
inSync |
The device already matches the catalog. Nothing written. |
noProduct |
No catalog product resolved for the device — hand-entered, or matching nothing in the catalog. |
noMetadata |
The product resolved but carries no ports, so there is nothing to copy. |
partNotFound |
A kit part the product's catalog row no longer lists. Nothing written — the parent kit's ports are never one part's ports. |
POST /api/v1/schematics/project/{projectId}/canvas/devices/{deviceId}/sync-metadata
Write. Sync one device. No body. A deviceId no device on the canvas carries changes nothing and returns { "error": "Device dev-9 not found on canvas" }. Changes appear live on open canvases.
curl -X POST 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/devices/dev-1/sync-metadata' \
-H 'Authorization: Bearer avs_yourkey'
GET /api/v1/schematics/project/{projectId}/canvas/pages/{page}/sync-metadata/preview
Read. Report what a page-wide sync would do, and change nothing. This is the apply's own plan, reported rather than performed — every status, every port and every deleted wire is what the apply produces from the same drawing, so nothing here is an estimate. Call it first whenever wires are at stake.
This is the one sync route that is not write-gated: a read-only key can preview a page before asking someone with write access to run it.
Selection is the page filter and nothing else. Devices with no ports at all are included — filling them from the catalog is part of what a page-wide sync is for. A page holding no devices answers an empty result, not an error.
POST /api/v1/schematics/project/{projectId}/canvas/pages/{page}/sync-metadata
Write. Sync every device on one page. No body. Same selection rule as the preview above, and the same response — with preview: false, because this one wrote. Changes appear live on open canvases.
curl -X POST 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/pages/1/sync-metadata' \
-H 'Authorization: Bearer avs_yourkey'
Annotations (Text, Leader, and Cloud)
Draw three kinds of free-floating markup on the canvas: a text caption, a leader (an arrowhead connected by a line to an offset label), or a cloud (a scalloped outline drawn around a group of devices). Every kind can be placed device-relative — beside, anchored to, or wrapped around one or more device ids — instead of typing raw sheet coordinates.
A device id placement works even on boxes the devices read leaves out because they carry no ports — you can anchor to or wrap any box drawn on the canvas, not only wired equipment.
POST /api/v1/schematics/project/{projectId}/canvas/annotations
Add one annotation. type is text, leader, or cloud, required. A cloud carries no text; the other two kinds require it. Never send both placement forms for one annotation — a request that supplies both or neither is rejected.
Text beside a device — device-relative placement with deviceId + side (above, below, left, or right):
{
"type": "text",
"text": "Spare 4-port switch",
"deviceId": "dev-3",
"side": "right"
}
Leader anchored to a device — device-relative placement with anchorDeviceId and optional labelSide (right or left, default right):
{
"type": "leader",
"text": "Replace before install",
"anchorDeviceId": "dev-7",
"labelSide": "left"
}
Cloud around device ids — device-relative placement with deviceIds and optional marginInches (default 0.25):
{
"type": "cloud",
"deviceIds": ["dev-2", "dev-5", "dev-6"],
"marginInches": 0.5
}
Each device-relative form has an explicit-coordinate alternative instead: x+y for a text, all four of targetX+targetY+labelX+labelY for a leader, or all four corners x1+y1+x2+y2 for a cloud.
Optional on every kind: fontSize (MICRO, CAPTION, BODY, or HEADING — default BODY on a text, CAPTION on a leader), bold, and color as a hex string. A leader also takes arrowStyle (arrow, dot, tick, loop, box, or none), labelShape (none, circle, box, hexagon, or triangle), and lineWidthIn. A cloud also takes arcInches for its scallop size. Returns { "success": true, "annotation": { …the created object, plus "type", "page", "pageY"… } } — the same per-item shape a GET list row carries, below.
GET /api/v1/schematics/project/{projectId}/canvas/annotations
List annotations. Both query parameters are optional: page (int) limits to one page, omit for every page; type (text, leader, or cloud) limits to one kind, omit for every kind.
{
"annotations": [
{ "id": 3, "text": "Spare 4-port switch", "x": 6.0, "y": 2.5, "fontSize": "BODY", "bold": false, "color": null, "type": "text", "page": 1, "pageY": 2.5 },
{ "id": 1, "text": "Replace before install", "targetX": 9.0, "targetY": 4.25, "anchorDeviceId": "dev-7", "anchorDx": 0.0, "anchorDy": 1.0, "labelX": 8.5, "labelY": 4.25, "fontSize": "CAPTION", "bold": false, "color": null, "lineWidthIn": null, "arrowStyle": null, "labelShape": null, "type": "leader", "page": 1, "pageY": 4.25 },
{ "id": 2, "x1": 8.0, "y1": 3.0, "x2": 13.0, "y2": 6.0, "color": null, "arcInches": null, "type": "cloud", "page": 1, "pageY": 3.0 }
]
}
Every row carries type, id, page, and pageY (page-local inches, alongside the global position), plus that kind's own fields.
PUT /api/v1/schematics/project/{projectId}/canvas/annotations/{type}/{id}
Update one annotation, by kind and id from the GET list above. Every field is a patch: an omitted field is left unchanged. An empty string ("") clears color, arrowStyle, or labelShape; an empty text is rejected — a text or leader annotation always keeps its label. Re-placement takes the same two forms as POST: x+y or deviceId+side for a text, anchorDeviceId (with optional labelSide) for a leader, deviceIds or the four corners for a cloud — mixing the two forms is refused. A leader's anchorDeviceId re-anchors it to a new box; labelSide on its own re-places an already-anchored leader and is an error on a free (unanchored) one.
Three flags clear a field back to its default, because there is no null to send on a patch: clearLineWidth restores a leader's default line width, clearArcInches restores a cloud's default scallop size, and clearAnchor frees an anchored leader — the arrowhead stays exactly where it was. clearAnchor cannot be combined with anchorDeviceId, labelSide, anchorDx, or anchorDy. Explicit targetX/targetY only apply to a leader that is already free (unanchored) — sending them on an anchored leader is refused.
{
"text": "Confirmed, no changes needed",
"color": "",
"fontSize": "HEADING"
}
Returns { "success": true, "annotation": { …the annotation as it now stands… } }.
DELETE /api/v1/schematics/project/{projectId}/canvas/annotations/{type}/{id}
Delete one annotation by kind and id. This cannot be undone. Returns { "success": true, "type": "…", "id": n }.
All four routes answer with the tool's own raw JSON result (see Response Shape above) — never the { success, data } envelope. A rejected input comes back as { "error": "…" } with HTTP 200, the same as every other canvas tool. POST, PUT, and DELETE need a write-access API key; GET works with a read-only key, the same split every other canvas route uses.
Checkpoints (Undo History)
Every save silently captures the pre-save layout as a checkpoint, so the last ~20 states are always recoverable. Bursts of edits by the same writer within 10 minutes collapse into a single checkpoint (the state before the first write), keeping the history readable.
GET /api/v1/schematics/project/{projectId}/canvas/checkpoints
List the undo history, newest first. Each entry's createdBy is the writer whose save displaced that state; label is present only on deliberate save-points and on the automatic pre-restore checkpoint a restore leaves behind. Feed id into the restore route.
{
"projectId": 42,
"checkpointCount": 2,
"checkpoints": [
{ "id": 8, "createdAt": "2026-07-03T12:00:00Z", "createdBy": "[email protected]", "deviceCount": 5, "pageCount": 2, "label": "before big edit" },
{ "id": 7, "createdAt": "2026-07-03T11:50:00Z", "createdBy": "[email protected]", "deviceCount": 3, "pageCount": 1, "label": null }
]
}
POST /api/v1/schematics/project/{projectId}/canvas/checkpoints
Create a deliberate, labeled save-point of the current layout — exempt from burst-collapse and pruned last. Body: label (string, required). Returns { "success": true, "project_id": 42, "checkpoint_id": 9, "label": "milestone" }.
POST /api/v1/schematics/project/{projectId}/canvas/checkpoints/{checkpointId}/restore
Restore the schematic to a checkpoint. The current state is first captured as an automatic pre-restore checkpoint (so a restore is itself undoable), then the layout is overwritten and the difference is broadcast live — open canvases animate to the restored state. Returns { "success": true, "project_id": 42, "restored_checkpoint_id": 7, "operations": 4 }. A missing checkpoint id returns 200 OK with an { "error": "…" } body.
Rendered Image (PNG)
Render one page of the schematic to a PNG — the exact canvas the editor draws, captured server-side. Useful for embedding a snapshot in a report or letting an AI agent visually verify a layout it built.
GET /api/v1/schematics/project/{projectId}/canvas/image?page=N
Returns raw image/png bytes for the requested page. page is optional (defaults to 1) and is capped to the schematic's real page count. The response body is the PNG itself — content-type image/png, not JSON.
curl 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/image?page=2' \
-H 'Authorization: Bearer YOUR_API_KEY' \
--output schematic-page-2.png
To crop to a rectangle instead of the whole page, add all four x, y, w, and h query params (page inches, top-left origin over the 36×24 sheet). Cropping spends the pixel budget on less sheet, so labels and wire numbers come out sharper. The four params are all-or-none, and the region must be at least 2×2 inches after clamping to the sheet — a partial or too-small region returns 400 with a VALIDATION_ERROR body.
curl 'https://app.avstackr.com/api/v1/schematics/project/42/canvas/image?page=1&x=24&y=12&w=8&h=6' \
-H 'Authorization: Bearer YOUR_API_KEY' \
--output schematic-corner.png
Error Responses (all canvas routes)
200 OKwith an{ "error": "…" }body — the tool ran but rejected the input (e.g. product not found, no connections provided).403 Forbidden— the API key's user is read-only (write routes only).404 Not Found— the project does not exist under your account.
