EazyDraw

← Automation API

EazyDraw Automation API — Reference

The Automation API is part of EazyDraw 12.10 and later, in both the direct-download and App Store editions. It is a local HTTP API served by the running app; turn it on and copy its bearer token in EazyDraw ▸ Settings ▸ API Settings. The eazydraw package on PyPI provides a Python client and a Model Context Protocol (MCP) server for AI agents over this API.

Machine-readable contract: spec/openapi.yaml (OpenAPI 3.1) describes the same surface. This document is the source of truth and the two are updated together. JSON keys are the actual on-the-wire names (e.g. lowercase uuid, DocumentFileName).

Server

The API is served by EazyDraw itself while the app is running and the API is enabled. There are two transports with the same wire protocol, handlers, and token: TCP on localhost:52737 (configurable) in the direct-download edition, and a UNIX-domain socket in the App Store edition, which is sandboxed and cannot open a listening port. API Settings shows the port or the socket path in use, and its "Copy Claude desktop config" button emits the matching settings. The socket file is created mode 0600 in the app container (~/Library/Containers/com.dekorra.EazyDraw/Data/api.sock), so only the logged-in user can connect. Clients: curl --unix-socket <path> http://eazydraw/status, the Python client's socket_path= argument, and the MCP server's EAZYDRAW_SOCKET variable (default selects the App Store socket).

Versioning

All resource paths under /v1. The /status endpoint is intentionally unversioned (it describes the running app, not a versioned resource).

Authentication

All endpoints require a bearer token. Clients supply it in an HTTP header on every request:

Authorization: Bearer 7b3f2a9e4c1d8b6a5f0e3c2d1b8a6f4e0123456789abcdef0123456789abcdef
  • Format. 32 random bytes (SecRandomCopyBytes), lower-case hex encoded — 64 characters. Generated client-side never; only EazyDraw produces tokens.
  • Storage. Keychain item, kSecClassGenericPassword, service com.eazydraw.api, account bearer-token. Accessibility kSecAttrAccessibleWhenUnlocked. The token is not stored in the app's plist or any preferences file.
  • Comparison. Constant-time byte compare (DKDAPIKeychain constantTimeEqualsTokenA:tokenB:) on the server. The token never appears in URL query strings — only in the Authorization header — so it stays out of access logs and shell history.
  • No token → server refuses to start. If no token exists in Keychain, applyHTTPServerSettings will not bring the server up and surfaces an NSError directing the user to API Settings → Generate Token. Authenticating an absent endpoint is impossible by construction.
  • Missing or wrong header → 401. Response body is the server's default error HTML with message Missing or invalid bearer token. The response includes WWW-Authenticate: Bearer realm="EazyDraw" so HTTP clients know what scheme to use.
  • Single token. One configured token at a time, no scopes, no expiry. Regenerating in API Settings invalidates the old token immediately — in-flight clients begin receiving 401 until they update.

Python example

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"   # token from API Settings → Copy
r = session.get("http://localhost:52737/v1/drawings")
r.raise_for_status()
print(r.json())

Threat model

The token protects against:

  • Other local processes or other user accounts on the same machine driving EazyDraw via the API.
  • Browser-side requests from a page tricked into hitting http://localhost:<port>. CORS doesn't cover this fully (extensions, misconfigurations); the bearer header a malicious page can't supply without already having stolen the token.
  • An off-host exposure later if BindToLocalhost is ever turned off (e.g. for testing) — auth is the same regardless of bind address.

The token does not protect against:

  • Code running as your user that can read your Keychain — that code can read the token directly. Localhost APIs cannot escape this in general.
  • Token leakage via shell history, log files, or accidental commits — keep tokens out of URLs and out of repo-tracked configuration files.
  • MITM on the wire when going off-host — out of scope while we are localhost-only. TLS becomes the answer if/when that changes.

Endpoints

MethodPathBody shape (200)
GET/status`{ status, version, build, transport, port \socket }`
GET/v1/drawings{ drawings: [ <drawing> ] }
POST/v1/drawings<drawing> — opens the file at the supplied path, or { "new": true } creates a blank drawing (201)
DELETE/v1/drawings/{uuid}<drawing> — closes the drawing (200 OK)
POST/v1/drawings/{uuid}/save<drawing> — save in place, or Save As to path (the drawing is then bound to the file)
GET/v1/drawings/{uuid}/undo{canUndo, canRedo, undoActionName, redoActionName} — what Undo / Redo would do
POST/v1/drawings/{uuid}/undo / /v1/drawings/{uuid}/redo{operation, actionName, canUndo, canRedo, …} — step the undo stack one entry
GET/v1/drawings/{uuid}/selection{graphics, count} — the graphics selected in the window
PUT/v1/drawings/{uuid}/selection{graphics, count, requested} — replace the selection (empty array clears it)
GET/v1/libraries{ libraries: [ <library> ] }
POST/v1/libraries<library> — opens the library file at the supplied path
DELETE/v1/libraries/{uuid}<library> — closes the open palette window for the library (200 OK)
GET/v1/libraries/{uuid}<library>
GET/v1/libraries/{uuid}/elements[ <element> ]
GET/v1/libraries/{uuid}/stateDKDLib.propertyListRepresentation (forced flat) — full library state
GET/v1/libraries/{uuid}/elements/{uuid}/stateDKDLibElement.propertyListRepresentation — full element state
GET/v1/drawings/{uuid}<drawing>
PATCH/v1/drawings/{uuid}<drawing> — set layerSelect and/or apply a named layerConfiguration
GET/v1/drawings/{uuid}/layer-configurations[ <layer-configuration> ] — the drawing's saved layer arrangements
GET/v1/drawings/{uuid}/pages{ pageSize, drawingSize, pagesAcross, pagesDown, orientation, margins, units, … } — page geometry
GET/v1/drawings/{uuid}/pages/setupDKDPagesSpec.propertyListRepresentation
GET/v1/drawings/{uuid}/pages/layoutDKDPrintInfo.propertyListRepresentation
GET/v1/drawings/{uuid}/gridsDKDGridPair.propertyListRepresentation
GET/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation
PATCH/v1/drawings/{uuid}/propertiesDKDProperties.propertyListRepresentation — set metadata (merge)
GET/v1/drawings/{uuid}/scale[[doc activeLayer] dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/window[doc docWindowConfiguratonDictionary]
GET/v1/drawings/{uuid}/viewport{visibleRect, zoom} — what the user is looking at (on- vs off-screen)
GET/v1/drawings/{uuid}/layers[ <layer> ]
POST/v1/drawings/{uuid}/layers<layer> — create a layer at the front, active by default (201)
GET/v1/drawings/{uuid}/layers/{uuid}<layer>
PATCH/v1/drawings/{uuid}/layers/{uuid}<layer> — set layer state (on / off / active) and/or rename (layerName)
DELETE/v1/drawings/{uuid}/layers/{uuid}<layer> — delete the layer and its graphics (pre-delete dict)
POST/v1/drawings/{uuid}/layers/{uuid}/order<layer> — restack the layer: front / back / forward / backward
GET/v1/drawings/{uuid}/layers/{uuid}/scale[layer dkdScale].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/color-modification[layer layerColorMod].propertyListRepresentation
GET/v1/drawings/{uuid}/layers/{uuid}/state[layer propertyListRepresentation] — full layer state (flat)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics[ <graphic> ]
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics{deleted} — clear the layer (remove all its graphics; atomic on locks)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics<graphic> — places a library element on the layer (201)
POST/v1/drawings/{uuid}/layers/{uuid}/imagesinsert content at front of layer — raster <graphic>+image, or PDF {pageCount, graphics[]} (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes<graphic> — create a parametric shape: rectangle, rounded-rectangle, oval, polygon, text-box, line, diagram-box (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"path")<graphic> — create an editable cubic Bézier path from a node list (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"fit")<graphic> — fit a Bézier through sparse waypoints (Catmull-Rom + per-node corners/KB; smooth→continuous-bezier, cornered→bezier) (201)
POST/v1/drawings/{uuid}/layers/{uuid}/shapes (shape:"conduit")<graphic> — a band of fixed thickness along a centerline path (cap + join appearance) (201)
POST/v1/drawings/{uuid}/layers/{uuid}/groups<graphic> — group 2+ top-level graphics into a new DKDGroup (201)
POST/v1/drawings/{uuid}/layers/{uuid}/combine<graphic> — path boolean of two closed shapes: union / difference (A−B) / intersection (201)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/ungroup{graphics, count} — ungroup a DKDGroup back into its members (200)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/path (or nested){path, editable} — read a graphic's editable bézier path (nodes)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/duplicate (or nested){graphics} — duplicate a graphic (optional offset / count); fresh UUID + name each (201)
PUT/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/image (or nested)<graphic> + image meta — fill a named placeholder slot with an image
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/flip (or nested)<graphic> — flip (mirror) horizontal / vertical / mirror
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/order (or nested)<graphic> — z-order: front / back / forward / backward
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/move-to-layer (or nested)<graphic> — move the graphic to another layer
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/attributes<graphic> — apply a library attribute-action transfer (200)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/style (or nested)<graphic> + style — set solid fill / stroke (color & style)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/style (or nested){fill, stroke} — read current fill / stroke
GET/v1/dash-patterns{dashPatterns} — the builtin named dash catalog (name + pattern)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/dash (or nested){dash, dashable} — set the line dash (builtin name / PDF pattern / none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/dash (or nested){dash, dashable} — read the current line dash
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/shadow (or nested){shadow, shadowable} — set the drop shadow (color/drop/angle/blur, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/shadow (or nested){shadow, shadowable} — read the current drop shadow
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/gradient (or nested){gradient, gradientable} — set the gradient fill (linear/radial stops, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/gradient (or nested){gradient, gradientable} — read the current gradient fill
GET/v1/pattern-sets{sets} — the builtin pattern/texture catalog (named sets of named tiles)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/pattern (or nested){pattern, patternable} — set the pattern fill (builtin set+name, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/pattern (or nested){pattern, patternable} — read the current pattern fill
GET/v1/hatch-patterns{hatchPatterns} — the builtin vector-hatch catalog (named hatches)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/hatch (or nested){hatch, hatchable} — set the vector hatch fill (builtin name + angle/scale/double, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/hatch (or nested){hatch, hatchable} — read the current vector hatch fill
GET/v1/arrow-forms{arrowForms} — the builtin arrow-head catalog (named forms)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/arrow (or nested){arrow, arrowable} — set the line-end arrow (builtin form + ends/size/angle, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/arrow (or nested){arrow, arrowable} — read the current line-end arrow
GET/v1/crossover-styles{crossoverStyles, positions, directions} — the builtin crossover catalog
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/crossover (or nested){crossover, crossoverable} — set the crossover symbol (style + position/direction, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/crossover (or nested){crossover, crossoverable} — read the current crossover
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/brush (or nested){brush, brushable} — set a variable-line-width stroke (width profile or artistic outline, or none)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/brush (or nested){brush, brushable} — read the current brush
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/conduit (or nested){conduit, conduitable} — change a conduit's spec (thickness / join / caps; only given fields)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/conduit (or nested){conduit, conduitable} — read a conduit's spec
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/offset (or nested){graphics} — parallel-offset a path left / right / both by distance (201)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/name (or nested)<graphic> — set a graphic's name (409 if taken; uniquify to auto-suffix)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/lock (or nested){locks, accepts} — which locks are set and which the graphic can take
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/lock (or nested)<graphic> — set / clear deleteLock, moveLock, sizeLock (Format ▸ Lock)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — set absolute bounds (position + size) and/or rotation
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — single graphic dict
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid} (or nested)<graphic> — pre-delete dict; removes the graphic
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics[ <graphic> ] — children of the named DKDGroup
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/graphics/{uuid}/graphics (and deeper)[ <graphic> ] — children of the deepest named group
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — DKDTextArea content + layout metrics
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<text> — set text + uniform style overrides
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/runs (or nested)<runs> — text + normalized attribute runs
PUT/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/runs (or nested)<runs> — replace styling wholesale from a runs array (round-trip)
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text/attributes (or nested)<runs> — apply range attribute operations (non-destructive)
POST/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<runs> — Insert Text: create contained text on a shape (201)
DELETE/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/text (or nested)<graphic> — Disconnect Text: detach contained text into a standalone box
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation (or nested)<annotation> — host graphic's annotation state
PATCH/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/annotation/text (or nested)<annotation> — set annotation plain text
GET/v1/drawings/{uuid}/export/{format}bytes — full drawing rendered
GET/v1/drawings/{uuid}/layers/{uuid}/export/{format}bytes — same render as drawing (see notes)
GET/v1/drawings/{uuid}/layers/{uuid}/graphics/{uuid}/export/{format} (any nesting depth)bytes — single graphic at its bounds
GET/v1/libraries/{uuid}/export/{format} / /v1/libraries/{uuid}/elements/{uuid}/export/{format}501 Not Implemented

UUID matching is case-insensitive against [doc documentUUID], [layer layerUUID], and [graphic graphicUUID].

Recursive group traversal

The path grammar for descending into nested groups is:

/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics(/{Gn}/graphics)*

Each {Gn} after the first must be a child of the preceding group's groupArray. Every {Gn} along the chain — including the last one whose children are returned — must resolve to a DKDGroup (or subclass). Nesting is unlimited; the same shape applies at every depth.

The terminal segment is always /graphics, which returns the array of immediate children. To check whether a graphic has children before recursing, look at graphicsCount in the parent's listing: present (any value, including 0) ⇒ it is a group and the recursive endpoint will resolve; absent ⇒ it is a leaf graphic and the recursive endpoint will 404. An empty group correctly returns [], distinct from a 404 for "not a group".

<drawing> shape

{
  "uuid":             "<DKDDocument documentUUID>",
  "DisplayName":      "<NSDocument displayName>",
  "DocumentFileName": "<doc.fileURL.path or empty string>",
  "layerSelect":      "active-only | show-others | select-others | show-all | select-all",
  "pageSize":         { "width": 612, "height": 792 },
  "drawingSize":      { "width": 612, "height": 792 }
}

layerSelect is the document's layer‑visibility mode (DKDDocument.layerSelect, reported as a kebab name; advanced directional modes report other). See Layer visibility below.

pageSize is one page and drawingSize the whole page grid (pages across × pages down), both in internal points with the origin at the top-left of page 1 — enough to centre content without a second call. The full page geometry (orientation, margins, units) is GET /v1/drawings/{D}/pages. The same dict is returned by GET /v1/drawings (the list), GET /v1/drawings/{uuid}, and the open / new / close responses.

If a document has no UUID at the time it is requested via /v1/drawings or /v1/drawings/{uuid}, one is assigned via [docProperties setAPI_UUID:YES] so subsequent calls are stable.

Open drawings never share a UUID (uniquify on open). A file copy made outside EazyDraw (Finder duplicate, backup restore) carries the same stored UUID as its source. When such a file is opened while another open document already holds that UUID, the newly opened document is assigned a fresh UUID at load time — the earlier-opened document keeps the stored one — so /v1/drawings/{uuid} addressing is always unambiguous. The fresh UUID persists when that document is next saved. A file opened with no collision always keeps its stored UUID.

GET /v1/drawings/{D}/viewport — what the user is looking at

Returns the drawing window's current visible region and zoom, so a client can talk about what the user actually sees instead of narrating graphics scrolled out of view.

{ "visibleRect": { "x": 0, "y": 0, "width": 612, "height": 460 }, "zoom": 175.0 }

visibleRect is in internal points — the same space and Y-down orientation as a graphic's hiddenBounds, and already zoom-adjusted (zoom in → a smaller visibleRect). So a graphic is on screen iff its hiddenBounds intersects visibleRect; one whose bounds fall outside (e.g. below the visible area) is scrolled out of view. zoom is the percent (100 = 1:1). The visible rect comes from the scroll view's documentVisibleRect — the same value updateOnScreenGraphics uses internally.

GET /v1/drawings/{D}/pages — page geometry

The page as a coordinate frame: what an agent needs to place content on it. No query parameters.

{
  "pageSize":           { "width": 612, "height": 792 },   // one page, points, oriented
  "pagesAcross":        1,
  "pagesDown":          1,
  "drawingSize":        { "width": 612, "height": 792 },   // pagesAcross × pagesDown pages
  "orientation":        "portrait",                        // "portrait" | "landscape"
  "paperName":          "na-letter",                       // when the print info has one
  "margins":            { "left": 18, "right": 18, "top": 18, "bottom": 18 },  // printer margins, points
  "units":              "Inches",                          // document units (active layer's scale)
  "unitsAbbreviation":  "in",
  "pointsPerUnit":      72,
  "pageSizeInUnits":    { "width": 8.5, "height": 11 },
  "drawingSizeInUnits": { "width": 8.5, "height": 11 }
}

Sizes in points are in the internal points space — the same frame as a graphic's hiddenBounds and the viewport's visibleRect: page 1's top-left corner is (0, 0), Y grows downward, and the pages tile across then down, so the whole drawing is drawingSize with its origin at (0, 0). The centre of a one-page drawing is therefore (pageSize.width / 2, pageSize.height / 2). margins are the print margins from DKDPrintInfo (not enforced on drawing). units is the un-localized name (Inches, Centimeters, Points, …) from DKDUnits, taken from the active layer's scale; pointsPerUnit converts.

Sources: DKDDocument pageSize / documentSize / pageSizeInDocumentUnits, DKDPagesSpec pagesAcross / pagesDown, DKDPrintInfo orientation / paperName / margins. The raw model dicts remain at pages/setup and pages/layout. 404 when the drawing is not open.

MCP: get_page(drawing?) returns this dict verbatim.

MCP convenience tool. place_on_page(name, anchor="center", dx=0, dy=0, respect_margins=True) uses this geometry to move a named graphic to a page position: anchor is center, top, bottom, left, right, or a corner (top-left, top-right, bottom-left, bottom-right). The frame is the drawing area inset by the print margins (respect_margins=False uses the paper edge); dx/dy then offset from the anchor in points (dy grows downward). The graphic keeps its size, moveLock/layerLock are honored (force=True overrides), and the new bounds are returned. It is built on GET /pages plus the morph endpoint; there is no separate wire endpoint.

POST /v1/drawings — open a file, or create a blank one

Create a blank drawing — send { "new": true } (optionally with a "name") instead of a path:

{ "new": true, "name": "Site Plan" }

This is File ▸ New: NSDocumentController openUntitledDocumentAndDisplay: puts a new untitled drawing on screen with the user's default page setup (one page, one active layer, the default units). name only titles the window (NSDocument setDisplayName:); the drawing has no file (DocumentFileName is "") and is unsaved until the user saves it — autosave-in-place applies only to documents that already have a file, so API edits to a new drawing live in the window until then. Response is 201 Created with the <drawing> dict (including pageSize, so the caller can place content immediately). 500 if the document controller could not create the document. When new is true the path field is ignored.

MCP: new_drawing(name?) — the answer to "create a new drawing"; it also becomes the active drawing.

Open a file — send a path:

Opens any file type EazyDraw can read (drawings in .ezdraw, .ezddata, .ezdjson; importable .svg, .pdf, .dxf, image formats, etc. — anything the app's CFBundleDocumentTypes declares). The result is a drawing window on screen, the same as a File → Open from the menu.

Import sizes the page to the content. When the file is an importable (non-EazyDraw) type, opening creates a new drawing whose page geometry matches the content: a PNG/JPEG/TIFF becomes a one-page drawing the size of the image; an SVG a page the size of the artwork; a multi-page PDF a drawing with one page per PDF page, at the PDF's page size (DKDDocBitmapImageOperation / DKDDocPDFOperation / DKDDocSVGOperation). This is the *open as a new drawing* path; the complement — dropping content into an existing drawing without changing its page geometry — is POST /v1/drawings/{D}/layers/{L}/images.

Request body (Content-Type: application/json recommended; the handler reads the body bytes directly so the header is not strictly required):

{
  "path": "/absolute/path/to/file.ezdraw"
}

path is a required string. Tilde expansion is performed (~/Documents/... is fine). Relative paths are rejected — there's no well-defined "current directory" for the API.

Success responses carry the same <drawing> dict shape /v1/drawings/{uuid} returns, so the response is immediately usable in subsequent GETs:

  • 201 Created — file was not previously open; a new drawing window is now on screen.
  • 200 OK — file was already open in EazyDraw; the existing window is returned (no new window is created, no in-flight changes are disturbed). Matches NSDocumentController openDocumentWithContentsOfURL:display:completionHandler: semantics: documentWasAlreadyOpen == YES.

Error responses are JSON { "error": "...", "path": "..." } (path included when relevant):

  • 400 Bad Request — empty body, body is not JSON, body is not a JSON object, missing/empty path field, or path is not absolute / tilde-prefixed.
  • 404 Not Foundpath resolves but no file exists there.
  • 422 Unprocessable Entity — file exists but NSDocumentController could not open it (unknown type, corrupt, unsupported version). The NSError's localizedDescription is returned in error.

Sandboxed (App Store) build: a permission failure (NSFileReadNoPermissionError / EACCES) is the container boundary, not a bad file — a path-based open never gets the security-scoped access the Open panel mints. The response then adds "reason": "sandbox" and "reachable": [ … ], the folders the API *can* open from (the app container's Documents and the iCloud Drive container's Documents, when present), and the error text says to copy the file there or open it from EazyDraw's File menu (files the user opened are always addressable via GET /v1/drawings). The direct build never sets these keys.

  • 500 Internal Server Error — open completion did not fire within 30 seconds, or returned a document that was not a DKDDocument, or response build failed. The 30-second timeout is generous for typical drawings; extremely large drawings can theoretically exceed it.

Threading note. The handler dispatches openDocumentWithContentsOfURL:display:completionHandler: onto the main thread and waits on a dispatch_semaphore for the async completion. The request thread blocks until the window is up. Typical opens complete in well under a second; the 30-second timeout exists only as a safety net.

Python example:

import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {token}"

r = session.post("http://localhost:52737/v1/drawings",
                 json={"path": "~/Documents/sketch.ezdraw"})
r.raise_for_status()
drawing = r.json()
print(drawing["uuid"], "was already open" if r.status_code == 200 else "newly opened")

DELETE /v1/drawings/{uuid} — close a drawing

Closes the drawing identified by {uuid} and removes its window from the screen. No request body, no query parameters.

Success — 200 OK. Body is the <drawing> dict for the drawing that was just closed. The dict is built before [doc close] is called so the response is complete. The UUID will no longer resolve in subsequent GETs.

Errors:

  • 400 Bad Request — missing UUID in path (should not happen given the regex, but handled defensively).
  • 401 Unauthorized — missing/bad bearer token.
  • 404 Not Found — no open drawing matches the supplied UUID. Body: { "error": "Drawing not found" }.
  • 500 Internal Server Error — response build failed.

macOS autosave note. DKDDocument autosaves changes in place (the standard modern NSDocument behavior). Edits made between open and close are typically already on disk by the time the close request runs. The API therefore does not offer a "close without saving" mode — there is nothing reliable to discard. To roll a drawing back to a prior state, use EazyDraw's File → Revert To before closing. An earlier revision of this endpoint accepted ?force=true to gate dirty-document behavior; testing showed the dirty check is essentially never true under autosave-in-place, so the flag has been removed.

Threading. The walk-and-close runs entirely inside one dispatch_sync(dispatch_get_main_queue(), ...) block: find the window, build the dict, and call [doc close] atomically with respect to other API requests. [doc close] is synchronous — when it returns, the window controllers are gone and the document is removed from NSDocumentController.

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/drawings/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/drawings/{uuid}")
r.raise_for_status()

POST /v1/drawings/{D}/save — save, or Save As

Saves the drawing. With no body (or no path) it saves in place, which needs a drawing that already has a file — a blank drawing from POST /v1/drawings {"new": true} gets 422 until it is saved with a path. With a path it is Save As: the drawing is bound to that file from then on (and autosaves there, as any file-backed drawing does).

Request body (optional):

{
  "path":      "<absolute path, or ~/…>",   // Save As; omit to save in place
  "overwrite": <Bool>                        // default false
}

The extension chooses the format: .ezdjson (EazyDraw JSON, the default for new drawings) or .ezdraw / the binary form; no extension appends the drawing's current one; any other extension is 400. If a *different* file already exists at path the call is 409 unless overwrite is true (saving over the drawing's own file is not a conflict). The save runs through NSDocument's saveToURL:ofType:forSaveOperation:completionHandler: on the main thread; the request waits for the completion (60 s timeout).

Success — 200 OK. Body is the <drawing> dict, DocumentFileName now set to the saved path.

Errors: 400 bad path / extension; 404 drawing not found; 409 file exists (see above); 422 untitled drawing saved without a path, or the document refused the save — in the sandboxed build a path outside the app's own folders is a 422 with "reason": "sandbox" and "reachable" listing the folders it *can* save into (the same shape as the open error).

Python: ed.save_drawing(d, path="~/Documents/plan.ezdjson"), ed.save_drawing(d) in place. MCP: save_drawing(path?, overwrite?).

Undo and redo — GET / POST /v1/drawings/{D}/undo, POST /v1/drawings/{D}/redo

Every mutating API call runs inside its own discrete undo group (see Threading), so one POST …/undo reverses exactly one API call (or one user step, if that is what is on top of the stack). Redo replays the last undone step. Text editing in progress is ended first.

  • GET …/undo{ "canUndo", "canRedo", "undoActionName", "redoActionName" }

— what the next Undo / Redo would do (the action names the Edit menu would show, e.g. "Set Text", "Lock", "Delete Layer"). Read-only.

  • POST …/undo / POST …/redo → the same four keys after the step,

plus "operation": "undo" | "redo" and "actionName", the name of the step that was just reversed / replayed. No body.

Errors: 404 drawing not found; 422 nothing to undo / redo.

Python: ed.undo_state(d), ed.undo(d), ed.redo(d). MCP: undo(), redo().

Selection — GET / PUT /v1/drawings/{D}/selection

The selection in the drawing's window: what the user is pointing at ("make *these* blue"), or what a script wants to *show* the user ("here is the graphic I mean").

  • GET …/selection{ "graphics": [ … ], "count": N } in selection

order. Each entry is the standard <graphic> dict plus layerUUID and layerName, so it can be addressed without a second lookup.

  • PUT …/selection with { "graphics": [ graphicUUID, … ] } replaces the

selection with those top-level graphics (any layer); an empty array clears it. The response is the resulting selection, as GET returns it, plus requested (how many UUIDs were sent): a graphic the view would not select — its layer is out of reach under the current layerSelect mode — is simply absent, so compare count with requested. Undoable in the usual UI sense (a selection change registers with the undo manager only when the document is edited, as it does from the mouse).

Errors: 400 body / array shape; 404 drawing not found, or a UUID that is not a top-level graphic of any layer (nothing is changed in that case).

Python: ed.selection(d), ed.set_selection(d, [uuid, …]). MCP: get_selection(), select(names).

<library> shape

{
  "uuid":             "<DKDLib.libraryUUID>",
  "DisplayName":      "<DKDLib.titleLib>",
  "DocumentFileName": "<DKDLib.fullFilePathLib>",
  "inMenu":           <Bool>,
  "paletteOpen":      <Bool>,
  "elementsCount":    <NSUInteger>
}

elementsCount is [[lib libCollectionElement] count] — the number of DKDLibElement items in the library's root collection. Mirrors graphicsCount on <layer> (libraries have no layer level, so this sits directly on the library).

The <library> shape mirrors the <drawing> shape's first three keys (same key names — uuid, DisplayName, DocumentFileName — for consistency across resource types) and adds two state booleans:

  • inMenutrue when the library is in _libMenus, the AppDelegate's array of active menu-system libraries (built-ins the user hasn't deactivated, plus user-added libraries currently on the Library menu).
  • paletteOpentrue when at least one window's controller is a DKDLibPalette whose dkdLib matches this library. Library palette windows are NSPanels and may be hidden when EazyDraw is not the active app; this flag reflects controller presence and is not filtered by [NSWindow isVisible], so the result is stable regardless of EazyDraw's foreground state.

A library can have any combination of the two flags set: in-menu only, palette-open only, or both. The collection is deduplicated so each library appears at most once. Dedup keys, in order of preference: pointer identity of the DKDLib instance, then fullFilePathLib string equality.

The 6 built-in libraries (Math, Charting, Stellate, Tools, Technical, CharacterBuilderLibrary) are returned only when active in the menu system; deactivated built-ins do not appear unless their palette window is open.

If a library has no UUID at the time it is requested, one is assigned via [lib setLibraryUUID:] so subsequent calls are stable.

POST /v1/libraries — open a library file

Opens an EazyDraw library file (.ezdrawlib, .ezddatalib, .ezdrawjsonlib) and brings up its palette window. The handler is structurally identical to POST /v1/drawingsNSDocumentController openDocumentWithContentsOfURL:display:completionHandler: routes by UTI to DKDLibDocument, whose -makeWindowControllers instantiates the DKDLibPalette and shows it.

Request body: same shape as POST /v1/drawings.

{
  "path": "/absolute/path/to/my.ezdrawlib"
}

path is required; tilde-expanded; absolute (or tilde-prefixed). Relative paths are rejected.

Success responses return the same <library> dict shape as /v1/libraries entries:

  • 201 Created — file was not previously open; a new palette window is now on screen.
  • 200 OK — file was already open in EazyDraw (matched by URL); the existing palette is returned. Same documentWasAlreadyOpen semantics as POST /v1/drawings.

The returned <library> dict reflects current state:

  • inMenu: true only if the library happens to already be in _libMenus (the active menu-system list). Opening via POST /v1/libraries does not add the library to the menu — the API only opens the palette. Use the existing Library menu UI ("Add to Menu") if menu membership is desired.
  • paletteOpen: true (the palette is what we just opened or refocused).

Errors: same matrix as POST /v1/drawings:

  • 400 — missing/malformed body, missing/empty path, relative path.
  • 404 — file does not exist on disk at the supplied path.
  • 422 — file exists but NSDocumentController couldn't open it (wrong UTI, corrupt library, or the opened object wasn't a DKDLibDocument). JSON body carries the NSError's localizedDescription.
  • 500 — open completion timed out (30s safety net) or response build failed.

curl:

curl -i -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"~/Library/Application Support/EazyDraw/MyMenuLibraries/Math.ezdrawlib"}' \
  http://localhost:52737/v1/libraries

Python:

r = session.post("http://localhost:52737/v1/libraries",
                 json={"path": "~/Documents/widgets.ezdrawlib"})
r.raise_for_status()
lib = r.json()
print(lib["uuid"], "newly opened" if r.status_code == 201 else "already open")

DELETE /v1/libraries/{uuid} — close a library palette

Closes the open palette window for the library identified by {uuid} and removes it from the screen. No request body, no query parameters.

Scope. This endpoint operates only on the palette window. It does not mutate _libMenus (the active menu-system list). Menu membership is user-managed state — added or removed via the EazyDraw Library menu UI — and the API does not touch it. If you DELETE a library that has both a menu entry and an open palette, only the palette is closed; the library remains accessible via the menu and continues to appear in GET /v1/libraries with inMenu: true, paletteOpen: false.

Lookup. The handler resolves {uuid} against both sources in order: _libMenus first, then open palette windows. Once the DKDLib is found, the matching palette is located by pointer equality first, then by fullFilePathLib equality — the path fallback handles the case where a menu entry and a palette-only entry refer to the same file as two different DKDLib instances (the same dedup logic GET /v1/libraries uses to coalesce them into one response entry).

Idempotent. Calling DELETE on a library that has no palette open (menu-only) is a no-op and returns 200 OK with the dict. Safe to retry.

Success — 200 OK. Body is the <library> dict for the library, built before [libDoc close] is called, so the paletteOpen field reflects pre-close state. The UUID continues to resolve via subsequent GETs as long as the library remains in _libMenus.

Errors:

  • 400 — missing UUID in path (defensive).
  • 401 — auth.
  • 404{uuid} did not match any library in either _libMenus or any open palette. Body: { "error": "Library not found" }.
  • 500 — response build failure.

macOS autosave note. Same as for DELETE /v1/drawings/{uuid}: DKDLibDocument autosaves in place, so any edits made via the palette flow to disk before close. No force/dirty machinery. To roll a library back, use Revert To.

Threading. Lookup, dict build, and [libDoc close] all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests. libDoc is obtained via [palette document] (the standard NSWindowController accessor).

curl:

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/libraries/$UUID

Python:

r = session.delete(f"http://localhost:52737/v1/libraries/{uuid}")
r.raise_for_status()

<element> shape (entry in /v1/libraries/{uuid}/elements)

Every element entry carries these common fields:

{
  "index":       <NSUInteger>,
  "uuid":        "<DKDLibElement.libraryUUID>",
  "elementType": "graphic" | "create-tool" | "arrange-tool" | "attribute-action",
  "nameElement": "<DKDLibElement.nameElement>"   // omitted if nil
}

elementType is the API-facing kebab-case kind, not the internal DKDLib*Element class name. The four values map to the underlying DKDLibElement subclasses (and the attribute-action exception) as follows:

elementType: "graphic"

The element is a DKDLibGraphicElement whose embedded graphic is not an attribute-transfer template. Adds the same per-graphic fields used in /v1/drawings/{D}/layers/{L}/graphics, with two omissions specific to library graphics:

{
  ...common fields...,
  "elementType":   "graphic",
  "graphicUUID":   "<inner DKDGraphic.graphicUUID>",
  "type":          "<stable kind: text|image|pdf-image|group|...>",
  "typeName":      "<localized display name, e.g. Text>",
  "nameGraphic":   "<...>",        // omitted if nil
  "graphicsCount": <NSUInteger>    // present iff inner graphic is DKDGroup
}

Omitted compared to the layer-level graphic shape: hiddenBounds — not meaningful in a library context.

The element-level index here is the position in the library's elements array. The graphic itself does not get a separate index field; it has only one slot inside the element.

elementType: "attribute-action"

The element is a DKDLibGraphicElement whose embedded graphic represents an attribute-transfer template rather than a drawable shape. Detected as: graphic is not a DKDGroup, has a non-nil dkdTransfer, and that transfer has at least one of these scope flags set: brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer.

{
  ...common fields...,
  "elementType": "attribute-action"
}

Currently only the kind label is exposed. A future endpoint will report which transfer scopes are active for a given attribute-action element.

elementType: "create-tool"

The element is a DKDLibCreateToolElement — a graphic-creation tool. Adds the API type token of the graphic the tool produces:

{
  ...common fields...,
  "elementType": "create-tool",
  "toolType":    "<stable kind the tool creates, e.g. rectangle>"
}

elementType: "arrange-tool"

The element is a DKDLibArrangeToolElement — an arrange/align/distribute action tool. Adds the localized tool name resolved via [DKDArrangePalette arrageToolNameWithTag:] from the element's arrangeButtonTag:

{
  ...common fields...,
  "elementType": "arrange-tool",
  "toolName":    "<localized arrange-tool title, e.g. \"Align Left Edges\">"
}

If a DKDLibElement is encountered that is none of the three known subclasses, elementType falls back to the kebab-cased class name (the DKD prefix dropped) so the response remains well-formed.

If an element has no UUID at the time it is requested, one is assigned via [el setLibraryUUID:] so subsequent calls are stable.

POST /v1/drawings/{D}/layers/{L}/graphics — place a library element

Adds a new graphic to the layer by invoking the library element's *use* action — the same operation the Use button in a DKDLibPalette performs. The source is identified by libraryUUID + elementUUID in the body.

Request body:

{
  "libraryUUID": "<DKDLib.libraryUUID>",
  "elementUUID": "<DKDLibElement.libraryUUID>"
}

Both fields are required, non-empty strings. The library is located by walking _libMenus first, then open palette windows (same dual-source lookup as GET /v1/libraries).

Allowed element types:

elementType (from GET /v1/libraries/{L}/elements)Behavior
graphicA copy of the library's embedded graphic is placed on the target layer.
create-toolA fresh default graphic of the tool's class is instantiated and placed. Initial size depends on the drawing's current zoom (the existing DKDLibPalette use-action does this).
arrange-toolRejected with 422. Arrange tools don't place graphics.
attribute-actionRejected with 422. Attribute-action elements modify a graphic's attributes; they don't add graphics. Use POST .../graphics/{uuid}/attributes (below) to apply the transfer to a target graphic.

Success — 201 Created. Response body is the standard <graphic> dict shape — index, graphicUUID (freshly generated, never matches the library's source UUID), type, typeName, nameGraphic (set to the library element's nameElement), hiddenBounds, and graphicsCount if the placed graphic is a DKDGroup. The returned graphicUUID is immediately usable for follow-up GET / DELETE / future morph calls.

Position and size. Initial position and size come from the existing DKDLibPalette use-action machinery. For graphic elements, they reflect the library author's design (the graphic's saved bounds). For create-tool elements, they reflect the drawing's zoom state at the time of placement. Neither is predictable in detail — a follow-up position / size / rotation endpoint (planned, modeled on EazyDraw's Morph palette) will let clients place a graphic and then move it precisely.

Errors:

  • 400 Bad Request — missing UUIDs in path, empty body, body is not a JSON object, or missing/empty libraryUUID / elementUUID.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing, layer, library, or element UUID does not resolve. Body: { "error": "Drawing not found" | "Layer not found" | "Library not found" | "Library element not found" }.
  • 422 Unprocessable Entity — element is arrange-tool / attribute-action, or use action produced no graphic for unexpected reasons.
  • 500 Internal Server Error — response build failure.

Fresh UUIDs. After the graphic is added to the target layer, [newGraphic recurseNewUUIDs] runs to assign fresh graphicUUID values for the new graphic and every descendant (groups, sub-graphics). The library's source UUIDs are never reused — a library can be placed any number of times into one or more drawings and each placement has a distinct identity. (The companion recurseUUID: toggle method, used by the Library and Properties panels, retains its original "assign-if-missing / clear" semantics; recurseNewUUIDs is a new method specifically for force-regeneration after placement.)

Threading. Lookup of doc/layer/lib/element, classification, property-list build, setActiveLayer: save-and-restore, the use-action call (-[DKDDocumentView addGraphicsWithArrayOfPropertyLists:]), and recurseNewUUIDs all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block. The target document's activeLayer is briefly set to the API-supplied target layer for the duration of the add, then restored — this routes the new graphic to the right layer using the existing use-action infrastructure without permanently disturbing UI state.

curl:

curl -i -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"libraryUUID":"'"$LIB_UUID"'","elementUUID":"'"$EL_UUID"'"}' \
  http://localhost:52737/v1/drawings/$DRAWING_UUID/layers/$LAYER_UUID/graphics

Python:

r = session.post(
    f"http://localhost:52737/v1/drawings/{drawing_uuid}/layers/{layer_uuid}/graphics",
    json={"libraryUUID": lib_uuid, "elementUUID": element_uuid},
)
r.raise_for_status()
new_g = r.json()
print("Placed:", new_g["type"], "uuid:", new_g["graphicUUID"], "bounds:", new_g["hiddenBounds"])

POST /v1/drawings/{D}/layers/{L}/shapes — create a parametric shape

Creates one shape from a shape discriminator + geometry, on layer {L}, at the front of the layer's z-order. Geometry only — fill, stroke, and shadow are composed afterward (apply a library swatch via POST .../graphics/{G}/attributes), and position is adjusted with align / distribute / morph. This is the parametric-primitive complement to POST .../graphics (which places a *library* element).

Bodyshape plus geometry; most shapes take a bounding box, line takes endpoints. Optional name sets nameGraphic.

shapeGeometryParams
rectanglebounds
rounded-rectangleboundscornerRadius
ovalbounds
arcboundsstartAngle, endAngle (degrees, 0 = right / 3 o'clock), arcType (arc / pie), clockwise (default true) — circular arc inscribed in the box
polygonboundssides (≥3), orientation (degrees) — regular, inscribed in the box
text-boxboundsstring
linestart, end
polylinepoints— (connected straight segments)
pathnodesclosed — an editable cubic Bézier path (see Curve Tools)
diagram-boxboundsfigure, direction

Style at creation and sizeMode (2026-09-10). For the box shapes (rectangle, rounded-rectangle, oval, arc, polygon, diagram-box) the engine's constructors treat bounds as the painted extent: the path is inset by half the line width so the outline fits inside the box (DKDRectangle defaultGraphicWithZone:bounds: and kin — with the default 1-pt stroke a 144 box yields a 143-pt path, the discrepancy the first Claude Desktop session hit). Two optional fields make that predictable:

  • style — the same { fill, stroke } block PATCH .../style takes, applied at creation. The inset is computed from this style: stroke.color: "none" → no inset (a 144 box is a 144 path); stroke.width: 4 → a 2-pt inset. The response then carries the same style echo block the style PATCH returns.
  • sizeMode"painted" (default, above) or "path": the box is the path geometry, whatever the stroke; the painted shape then extends half a stroke beyond it.

Implementation: the requested style is built with the same _dkd_styleFromBody: used by the style PATCH, the constructor receives the box pre-adjusted by (requested inset − default inset), and the style is set on the new graphic before insert. 400 for a sizeMode other than painted / path. line, polyline, path, text-box accept style but are not inset.

{ "shape": "oval", "bounds": { "x": 234, "y": 324, "width": 144, "height": 144 },
  "style": { "fill": { "color": "#FF0000" }, "stroke": { "color": "none" } },
  "name": "red-circle" }                                  // hiddenBounds comes back 144 x 144

bounds is { "x", "y", "width", "height" } (width/height > 0); start / end are { "x", "y" }; points is an array of at least two { "x", "y" }. diagram-box.figuretriangle, box-arrow, fat-arrow, flame, mushroom, nose, trapezoid, brace, drum, tear-off; directionup, down, left, right (resolved to the concrete DKD{Direction}{Figure} class). Per-figure knobs (tip fraction, lean, brace radius, …) use class defaults in v1.

{ "shape": "rounded-rectangle",
  "bounds": { "x": 100, "y": 120, "width": 180, "height": 90 },
  "cornerRadius": 16, "name": "title-card" }

{ "shape": "polygon", "bounds": { "x": 0, "y": 0, "width": 120, "height": 120 }, "sides": 6 }   // hexagon

{ "shape": "arc", "bounds": { "x": 0, "y": 0, "width": 120, "height": 120 },
  "startAngle": 0, "endAngle": 90, "arcType": "pie" }                          // quarter wedge

{ "shape": "diagram-box", "figure": "fat-arrow", "direction": "right",
  "bounds": { "x": 40, "y": 40, "width": 160, "height": 60 } }

{ "shape": "line", "start": { "x": 0, "y": 0 }, "end": { "x": 240, "y": 120 } }

{ "shape": "polyline", "points": [ {"x":0,"y":0}, {"x":50,"y":40}, {"x":120,"y":10} ] }

Curve Tools — editable Bézier paths

Authored for an AI client that places *points it has reasoned about* and reads geometry back exactly, not one that drags handles. The contract: write a path, read it, write the read back, and you get the identical path (round-trip fidelity).

Node model

A path is { "closed": bool, "nodes": [ Node, … ] }. Segment *i* runs nodes[i] → nodes[i+1]; closed wraps nodes[n-1] → nodes[0].

Node = {
  "point": [x, y],         // on-curve anchor (absolute document points)
  "in":    [x, y],         // control handle governing the segment ARRIVING at this node
  "out":   [x, y],         // control handle governing the segment LEAVING this node
  "continuity": "corner" | "smooth" | "symmetric"
}
  • Handles are absolute coordinates — the source of truth, and what guarantees the round-trip.
  • A handle coincident with point means that side is straight (a degenerate cubic).
  • continuity is derived from the handle geometry on read (EazyDraw stores none): smooth = in/out colinear through the anchor, same direction; symmetric = also equal length; corner = a cusp. On add_path the handles are authoritative; continuity in the input is advisory.
  • Client shortcut (not the wire): the Python client's add_shape (and so the add_path / add_conduit MCP tools) also accepts a bare [x, y] pair or an { "x", "y" } dict per node and converts it to { "point": [x, y] } — a corner node with no handles — before sending. The HTTP body always carries the node model above.
  • Open-path caveat: nodes[0].in and nodes[n-1].out are the dangling end handles — they are not part of the path geometry, so they read back coincident with the anchor (degenerate). Closed paths round-trip 100%.

POST /v1/drawings/{D}/layers/{L}/shapes with shape: "path" — create a path (add_path)

Body{ "shape": "path", "nodes": [ Node, … ], "closed": false, "name"?: … }. in/out/continuity are optional per node (default = straight handles at the anchor, corner). At least 2 nodes. Creates a generalized editable Bézier (DKDBezier, type: "bezier"), open or closed — *not* the straight‑line DKDPath/DKDPolygon forms. Lands at the front of the layer's z-order; honors name/uniquify. Returns the new <graphic> (201). add_path accepts exactly the { closed, nodes } that get_path returns.

{ "shape": "path", "closed": true, "nodes": [
    { "point": [100, 100], "out": [140, 100], "in": [60, 100] },
    { "point": [200, 160], "out": [200, 200], "in": [200, 120] },
    { "point": [100, 220], "out": [60, 220],  "in": [140, 220] } ] }

POST /v1/drawings/{D}/layers/{L}/shapes with shape: "fit" — fit a curve through waypoints (fit_curve)

Body{ "shape": "fit", "points": [ Waypoint, … ], "closed": false, "alpha"?: 0.5, "tension"?: 0.0, "name"?: … }. The author supplies only the waypoints the curve must pass through (≥ 2) — *intent, not handles*. Each Waypoint is either a bare [x, y] (a smooth point) or a dict for per-node control:

{ "point": [x, y], "corner": true }                              // a CUSP at this point
{ "point": [x, y], "tension": 0.0, "continuity": 0.0, "bias": 0.0 }  // full Kochanek–Bartels

EazyDraw interpolates a cubic Bézier through the points: a non-uniform (centripetal) Catmull–Rom base with per-node Kochanek–Bartels controls on top (Catmull–Rom is exactly KB at t=c=b=0). closed: true wraps modularly; an open path uses one-sided end tangents and collapses the two non-drawing outer handles. Honors name/uniquify. Returns the new <graphic> (201) — get_path it to see the synthesized nodes and refine.

Output class follows the corners (this is the Catmull–Rom ↔ KB / DKDContinuousBezierDKDBezier pairing, made automatic):

  • No corners anywhere → a smooth DKDContinuousBezier (type: "continuous-bezier") — editing in EazyDraw keeps it smooth.
  • Any corner → a generalized DKDBezier (type: "bezier") — the corners survive editing.

Global controls (clamped, optional):

  • alpha — *parameterization*, 0..1, default 0.5. 0 = uniform (can overshoot/bulge between widely-spaced points), 0.5 = centripetal (overshoot-free — the right default), 1 = chordal. Non-uniform (Barry–Goldman): each chord weighted by length^alpha.
  • tension0..1, default 0.0. Baseline handle tightness, scales handles by 1 − tension (0 roundest, 1 ≈ straight). Overridable per node.

Per-node controls (inside a waypoint dict):

  • cornertruecontinuity: -1: the tangent splits so the curve arrives along the incoming chord and leaves along the outgoing one — a sharp cusp. This is the headline knob; a heart is two smooth lobes with corners only at the top dip and bottom point.
  • continuity-1..1. 0 = smooth; -1 = full corner; in between softens the cusp. (Negative values are what flip the output class to bezier.)
  • tension / bias0..1 / -1..1, per-node overrides; bias leans the in/out handle lengths.
// A heart: two smooth lobes, cusps at the dip and the bottom tip.
{ "shape": "fit", "closed": true, "points": [
    { "point": [200, 120], "corner": true },   // bottom tip
    [120, 180], [110, 250],                     // left shoulder, left lobe peak (smooth)
    { "point": [200, 210], "corner": true },    // top dip
    [290, 250], [280, 180] ] }                  // right lobe peak, right shoulder

Use fit to author a shape by the points it threads (smooth, or with marked corners); use path when you already have explicit handles (e.g. writing back an edited get_path result, or straight segments).

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/path — read a path (get_path)

Returns { "path": <spec>, "editable": bool }path is "none" or { closed, nodes:[{point, in, out, continuity}] }. editable is true for a freeform path (DKDBezier, DKDPath, DKDPolygon, DKDContinuousBezier) — false for a parametric shape (rectangle, oval, regular polygon, …) and a graphic with no path (text, image, group; the latter path: "none"). Reading a primitive's exact Bézier equivalent and converting it in place (to_path) come with the rest of the curve‑tool foundation.

Success — 201 Created. Body is the standard <graphic> dict (type, typeName, graphicUUID, nameGraphic, hiddenBounds, locks). The graphic is built via +[<class> defaultGraphicWithZone:bounds:] (line via +[DKDLine defaultLineWithZone:start:end:]), carries the default attribute transfer, and is inserted undoably ("Add Shape").

Naming. The body accepts an optional name (and uniquify), with the same uniqueness rule as PATCH …/name409 if the name is taken, unless uniquify: true. Omit name and, in a UUID drawing, the graphic is auto-named (see Graphic naming model). The response's nameGraphic is always the final name.

Limitations. arcType: "chord" (the chord-closed arc) returns 422, and morph rotation is always about the graphic's geometric center (no custom pivot). (A broader angle-conventions pass across the other angle-bearing graphics — rotated rectangle, math-function graphics, cut circles, … — is separate.)

Errors: 400 — unknown shape / figure / direction, missing or non-positive bounds, sides < 3, line without start / end, or polyline with fewer than two points; 404 — drawing or layer not found; 422arcType: "chord"; 423 — the layer is locked (pass ?force=true to override).

Conduit — a thick band along a path

A conduit is a band of fixed thickness that follows a centerline path — its left and right edges stay parallel to and thickness/2 from the path. It's the general "thick path / wall / pipe / road" primitive. Backed by EazyDraw's wall‑graphic family (root DKDWallPath); the family's several engine classes are deliberately hidden behind the single conduit concept, and the type token always reads back conduit.

Tier‑1 is predictable and explicit: it exposes the two appearance levers and nothing that makes EazyDraw decide for you. Excluded (by design): the dimension snap‑point setting (only matters for dimension connectors, which the API doesn't have yet), automatic end‑mating / snapping to other conduits, automatic multi‑conduit intersection geometry, and the floor‑plan window/door fittings.

POST /v1/drawings/{D}/layers/{L}/shapes with shape: "conduit" (add_conduit)

Body{ "shape": "conduit", "nodes": [ Node, … ], "closed": false, "thickness": 12.0, "join"?: "miter", "startCap"?: "butt", "endCap"?: "butt", "miterLimit"?: …, "name"?: … }. nodes is the centerline, the same {point, in, out} node list as add_path (≥ 2 nodes; straight or curved). thickness (required, > 0) is the band width.

  • join — interior‑corner appearance (WallJoinStyle): bevel | round | miter (default miter); miterLimit caps long miter spikes.
  • startCap / endCap — how each end of the band looks (WallCornerShape): open | butt | relief | extend | miter | round (default butt, squared off). One value per end; it applies to both sides of that end.

Success — 201 Created → the standard <graphic> dict (type: "conduit").

{ "shape": "conduit", "thickness": 16, "join": "round", "startCap": "round", "endCap": "butt",
  "nodes": [ {"point":[60,200]}, {"point":[180,120]}, {"point":[300,220]} ] }

GET / PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/conduit (get_conduit / set_conduit)

GET returns { "conduit": <spec>, "conduitable": bool }<spec> is "none" (not a conduit) or { thickness, join, startCap, endCap, miterLimit }. PATCH body { "conduit": { thickness?, join?, startCap?, endCap?, miterLimit? } } changes only the given fields; undoable, layerLock‑gated (423 + ?force=true), 422 if the graphic isn't a conduit. MCP: add_conduit(nodes, thickness, …), get_conduit(name), set_conduit(name, thickness?, join?, start_cap?, end_cap?, miter_limit?).

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/offset — parallel‑offset a path (offset_path)

Creates a new editable path that runs parallel to an existing one — the "Offset Path" operation (cf. Illustrator). The source graphic is unchanged; a fresh DKDBezier is generated distance document points to one side, following the source's twists at constant separation. Backed by the same conduit/wall engine: internally a scratch band of width 2 × distance with squared (butt) ends is built and its left or right edge is taken as the result. The offset of a tightly curved path is an approximation — a true 2‑D parallel can self‑intersect or crinkle where the radius is smaller than distance.

Body{ "side": "left" | "right" | "both", "distance": N, "name"?: …, "uniquify"?: false }. side is relative to the path's travel direction (the order its nodes were drawn); both produces two results (one each way). distance must be positive. The source must have a path (a line, bezier, shape outline, or a conduit centerline) — 422 otherwise. With name and side: "both", the side is appended to each result (name-left, name-right).

Success — 201 Created{ "graphics": [ <graphic>, … ] } — one new bezier per side, inserted at the front of the source's layer. Undoable as a single step ("Offset Path"); layerLock‑gated (423 + ?force=true). MCP: offset_path(name, distance, side="right", new_name?, uniquify?).

{ "side": "both", "distance": 10 }   // → two paths, ±10 pt, parallel to the source

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — fetch a single graphic

Returns the standard <graphic> dict for a single graphic identified by UUID chain. Same chain grammar as the morph, export, and DELETE endpoints — one UUID for a top-level layer graphic, additional UUIDs to descend into groups.

Success — 200 OK. Body is the standard <graphic> dict (same shape as one entry in GET /v1/drawings/{D}/layers/{L}/graphics). The index field reflects the graphic's position in its immediate container (layer or parent group), not a global drawing-wide index.

Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID along the chain does not resolve, or an intermediate non-group blocks descent), 500 (response build failure).

Use case: when you have a UUID from a prior call (POST use, PATCH morph, or a listing) and you want to refetch the current state without re-listing the layer. Cheaper than layer_graphics() for spot-checks; especially useful after operations whose response represents pre-state (DELETE) or in-flight state.

g = ed.graphic(d_uuid, l_uuid, g_uuid)
print(g["type"], g["hiddenBounds"])

DELETE /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — remove a graphic

Removes the graphic identified by UUID chain. The remove is registered with the document's undo manager, so Edit → Undo in the EazyDraw UI restores the graphic at the same z-order index in its original container.

Success — 200 OK. Body is the standard <graphic> dict for the pre-delete state. The graphicUUID will no longer resolve in subsequent calls (until an undo restores it).

Container handling:

  • Top-level layer graphic (chain length == 1): removed via [doc removeGraphic:atIndex:] which handles connection cleanup, contained text pairs, layer panel sync, and undo registration.
  • Nested in a group (chain length > 1): removed from its immediate parent group's groupArray via [parentGroup groupRemoveGraphicAtIndex:]. Same undo registration pattern.

In both cases the index field in the response dict reflects the position in the immediate container at the time of delete.

Errors: 400 (missing UUIDs), 401 (auth), 404 (any UUID does not resolve, or intermediate non-group blocks descent), 500 (response build failure).

Threading. Resolution, dict build, and removal all run inside one dispatch_sync(dispatch_get_main_queue(), ...) block — atomic against other API requests.

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
deleted = ed.delete_graphic(d_uuid, l_uuid, g_uuid)
print("Removed", deleted["type"], "at z-order", deleted["index"])

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/duplicate — duplicate a graphic

Duplicates a graphic — the copy primitive (the companion to create / name / delete). A deep copy (the engine's dupGraphics: with fresh paste IDs), inserted into the same layer just above the source, then offset. Each copy is introduced through the normal funnel, so in a UUID drawing it gets a fresh UUID and a uniquified name (a copy of "box" becomes "box 2").

Body (all optional):

{ "offset": { "x": 20, "y": 20 },   // internal points, Y-down; default { 20, 20 }
  "count":  1 }                      // number of copies, 1..100; default 1

With count > 1, copy *k* is offset by k × the offset vector (a cascade).

Success — 201 Created{ "graphics": [ <graphic>, … ] }, one entry per copy (each with its new UUID and name).

v1 scope: top-level layer graphics only — duplicating a nested (grouped) graphic returns 422 (chain length > 1). Undoable ("Duplicate"), layerLock-gated (423; ?force=true overrides). 404 — drawing / layer / graphic not found.

new = ed.duplicate_graphic(d_uuid, l_uuid, g_uuid, offset={"x": 30, "y": 0}, count=3)
for g in new["graphics"]:
    print("copy:", g["nameGraphic"], g["graphicUUID"])

POST /v1/drawings/{D}/layers/{L}/graphics/{G}/attributes — apply a transfer

Applies a library attribute-action element's DKDTransfer (fill color, line color/style, gradient, hatch, dashes, arrows, brush, shadow, dimension scopes) to a target graphic — the programmatic equivalent of dragging that library swatch onto the graphic, or selecting the graphic and clicking the element's Use button.

Request body:

{
  "libraryUUID": "<DKDLib.libraryUUID>",
  "elementUUID": "<DKDLibElement.libraryUUID>"
}

Both required, non-empty strings. The element must classify as attribute-action — a DKDLibGraphicElement whose embedded graphic is not a DKDGroup, carries a non-nil dkdTransfer, and has at least one active scope flag (brushScopeTransfer, shadowScopeTransfer, gradientScopeTransfer, hatchScopeTransfer, dashesScopeTransfer, patternScopeTransfer, arrowsScopeTransfer, colorAndStyleScopeTransfer, dimensionScopeTransfer). A graphic / create-tool / arrange-tool element returns 422 (use POST .../graphics to place those).

v1 — top-level graphics only. The target is a single top-level layer graphic; a nested (grouped) UUID chain returns 422. The transfer is applied by the same mechanism as a drag-drop (DKDTransferApply dropTransfer): save the current selection, select the target graphic, run the per-scope inspector-panel applies against it, then restore the prior selection. On-canvas selection must be able to address the target, which today means a top-level graphic. (A brief selection change may be visible to a user watching the document.)

Success — 200 OK. Body is the standard <graphic> dict; the graphicUUID is preserved (the graphic is restyled in place, not replaced). The summary dict does not include attribute values — to see the resulting fill/line/etc. inline, GET .../graphics/{G}/export/native or the layer /state.

Undo. Registered as a single "Transfer" step by applyTransferFromGraphic: (the action name reflects the scopes applied), so Edit → Undo reverts the change.

Locks. Only the layer's layerLock blocks the apply — 423 Locked, with ?force=true to override. The graphic's moveLock / sizeLock / deleteLock do not apply: an attribute change is neither a move, a resize, nor a delete.

Errors:

  • 400 Bad Request — missing path UUIDs; empty/malformed body; missing/empty libraryUUID / elementUUID.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing, layer, top-level graphic, library, or element UUID does not resolve.
  • 422 Unprocessable Entity — element is not an attribute-action (it's a graphic / create-tool / arrange-tool); or the target is a nested graphic (top-level graphics only).
  • 423 Locked — the target layer is locked; retry with ?force=true.
  • 500 Internal Server Error — response build failed.

Threading. Resolution, the select → applyTransferFromGraphic: → restore-selection sequence, and the dict build all run inside one dispatch_sync(dispatch_get_main_queue(), …) block — atomic against other API requests.

Python:

g = ed.apply_attributes(d_uuid, l_uuid, g_uuid, lib_uuid, el_uuid)
print(g["type"], g["graphicUUID"])
# to confirm the applied attributes, read the flat state:
native = ed.export_graphic(d_uuid, l_uuid, g_uuid, fmt="native")

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/style — solid fill & stroke

Sets a graphic's solid color fill and strokeDKDGraphicStyle, the 90% case. The compose-after-creation styling step: splash a shape, then color it.

Terminology. The API uses fill and stroke (SVG/CSS-aligned, the vocabulary agents already speak). EazyDraw's *UI* calls the stroke the "outline"; the API standardizes on stroke.

Body — both blocks and all keys optional; only what you pass changes. color is a hex string (#RRGGBB / #RRGGBBAA); "none" (or null) turns that fill/stroke off.

{
  "fill":   { "color": "#cc2200" | "none", "rule": "nonzero" | "evenodd" },
  "stroke": { "color": "#000000" | "none", "width": 2.0,
              "cap": "butt" | "round" | "square", "join": "miter" | "round" | "bevel" }
}

rule is the SVG fill-rule (NSWindingRule); cap/join map to NSLineCapStyle/NSLineJoinStyle. Works on nested (grouped) graphics too.

Success — 200 OK. Returns the standard <graphic> dict plus a style block echoing the result:

{ "type": "rectangle", "graphicUUID": "…", "hiddenBounds": {…}, "locks": {…},
  "style": { "fill":   { "color": "#cc2200", "rule": "nonzero" },
             "stroke": { "color": "#000000", "width": 2.0, "cap": "butt", "join": "miter" } } }

Model reuse. DKDGraphicStyle instances are shared and immutable — a style change builds one new immutable style (never mutates the shared instance in place), and the undo registration holds the style references (not copies), so the wide reuse of the attribute model survives undo/redo. (A future batch op styling N graphics will share a single instance across all of them.)

Richer looksdashes now have a native endpoint (PATCH .../dash, below). Gradient / pattern / hatch fills and arrow / crossover / brush strokes remain Tier 2 for now: apply a saved library swatch with POST .../graphics/{G}/attributes.

Undoable ("Color and Style"), layerLock-gated (423; ?force=true overrides). 400 — body without fill or stroke; 404 — drawing / layer / graphic not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/style — read fill & stroke

Returns the graphic's current { "fill": {color, rule}, "stroke": {color, width, cap, join} } (the same style block shape). color is a hex string, or "none" when that fill/stroke is off.

GET /v1/dash-patterns — builtin named dash catalog

Lists EazyDraw's builtin named dash patterns (the factory dashes: Long Dash, Dash Dot, Dash Dot Tight, Long Dash Tight, Sparse Dot, Dot Dash, Dotted — names describe what is *inked*, never the gap). Global — not drawing-scoped. Use a name as the dash spec on PATCH .../dash. The pre-2026-06 names (Long, Dash Space, Short Space, Long Space, Space Space, Short) are still accepted as aliases on PATCH .../dash.

{ "dashPatterns": [ { "name": "Long Dash", "pattern": [16, 16] },
                    { "name": "Dash Dot",  "pattern": [16, 4, 4, 16] }, … ] }

pattern is the dash/gap length sequence in points (the PDF/Adobe dash array).

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/dash — set the line dash

Sets a graphic's stroke dash (DKDDashPattern, stored on the graphic's bezier). The dash is its own attribute, parallel to /style's solid stroke (and to the gradient / arrow attributes).

Body{ "dash": <spec> }, where <spec> is one of:

  • "none" — solid line (dash off)
  • "Dash Dot" — a builtin name (or { "name": "Dash Dot" }); see GET /v1/dash-patterns
  • { "pattern": [10, 5, 2, 5], "phase": 0 }any dash, the PDF/Adobe model: pattern is an array of ≥2 non-negative on/off lengths in points (at least one positive); phase (optional, default 0) is the absolute length offset where dashing begins.
{ "dash": { "pattern": [10, 5], "phase": 0 } }

Success — 200 OK{ "dash": <resolved spec>, "dashable": true }. The resolved spec is { "pattern": […], "phase": <absolute>, "name": <builtin name if it matches one> }, or "none".

Internally phaseDash is a fraction of the total dash length; the API converts to/from the absolute PDF phase so the client stays in the standard model.

Undoable ("Dashes"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept dashes (e.g. text, image, group), or the pattern / name is invalid. 400 — missing dash. 404 — drawing / layer / graphic not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/dash — read the line dash

Returns { "dash": <spec>, "dashable": bool }dash is "none" or { pattern, phase, name? }; dashable is false for graphic types that cannot carry a dash.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/shadow — set the drop shadow

Sets a graphic's drop shadow (DKDShadow on graphicShadow). The API exposes bitmap shadows (the soft, system-rendered NSShadow) on the graphic/path — not the associated annotation text, and not the EazyDraw vector shadow. This is the 90% case.

Body{ "shadow": <spec> }, where <spec> is one of:

  • "none" — shadow off
  • { "color": "#000000cc", "drop": 5, "angle": 315, "blur": 4 } — a bitmap shadow, turned on. All keys optional; the shadow starts from the graphic's current (or default) shadow and overrides only what you pass.
    • color — hex; alpha welcome (#RRGGBBAA) — shadows are usually translucent
    • drop — offset distance, points
    • angle — degrees (the Shadow panel's angle dial; e.g. a lower-right drop)
    • blur — blur radius, points (≥ 0)
{ "shadow": { "color": "#00000080", "drop": 6, "angle": 315, "blur": 5 } }

Success — 200 OK{ "shadow": <resolved>, "shadowable": true }. The resolved spec is { color, drop, angle, blur, method } (or "none"). method is read-only ("bitmap" | "vector") — a vector shadow set in the UI stays legible, but PATCH always writes bitmap.

Undoable ("Shadow"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a shadow, or the color/blur is invalid. 400 — missing shadow. 404 — drawing / layer / graphic not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/shadow — read the drop shadow

Returns { "shadow": <spec>, "shadowable": bool }shadow is "none" or { color, drop, angle, blur, method }; shadowable is false for graphic types that cannot carry a shadow.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/gradient — set the gradient fill

Sets a graphic's gradient fill (DKDGradientFill on gradientFill). The API exposes the bitmap gradient — the system NSGradient — in linear or radial mode; the vector EazyDraw gradient types are not written here.

Body{ "gradient": <spec> }, where <spec> is one of:

  • "none" — no gradient fill (reverts to the solid fill)
  • { "type": "linear"|"radial", "stops": [ {"color":"#RRGGBB","location":0.0}, … ], "angle": 270, "radius": 0 }
    • type"linear" (uses angle) or "radial"
    • stops — the gradient color stops, ≥2 of { color: hex (alpha ok), location: 0..1 }. Linear: location 0 is one edge, location 1 the other (in the angle direction). Radial: location 0 is the center, location 1 the outer edge. Intermediate stops sit at their location.
    • angle — degrees, standard-math / screen convention (counterclockwise from →). Linear: the direction colors run, low→high location (270 = top→bottom, 90 = bottom→top, 0 = left→right). Radial: the off-center direction (used with radius).
    • radius — percent, radial only: how far the gradient center is offset from the shape center (0 = centered).
{ "gradient": { "type": "linear",
                "stops": [ { "color": "#ff5500", "location": 0 },
                           { "color": "#ffd000", "location": 0.5 },
                           { "color": "#2200ff", "location": 1 } ],
                "angle": 270 } }

Success — 200 OK{ "gradient": <resolved>, "gradientable": true }. The resolved spec is { type, stops, angle, radius? } (or "none") and round-trips with what you sent. A graphic carrying a *vector* gradient set in the UI reports type: "vector" (read-only) with its stops; PATCH always writes a bitmap linear/radial gradient.

If the graphic also has a pattern fill whose overlay option is off, the response adds "note" — the gradient is stored but the pattern draws over it (enable the pattern overlay in EazyDraw to reveal it). The endpoint does not silently change the pattern.

Undoable ("Gradient"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a gradient, or the type/stops are invalid. 400 — missing gradient. 404 — not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/gradient — read the gradient fill

Returns { "gradient": <spec>, "gradientable": bool }gradient is "none" or { type, stops, angle, radius? } (type "linear" | "radial" | "vector"); gradientable is false for graphic types that cannot carry a gradient.

GET /v1/pattern-sets — builtin pattern / texture catalog

Returns { "sets": [ { "name": "Brick", "patterns": ["Ancient","Bricks","HerringBone","Old","Red","Yellow"] }, … ] } — EazyDraw's builtin pattern and texture fills, grouped into named sets. A pattern is a small bitmap tile (typically ≤ 64×64 px) painted by tiling; a *texture* is just a larger tile (sets like Texture, Favorite Textures). Global (not drawing-scoped). Apply one with a { set, name } on PATCH .../pattern. (The DXF hatch sets are intentionally not listed here — hatch is a separate attribute.)

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/pattern — set the pattern fill

Sets a graphic's pattern fill (DKDPattern on graphicPattern) to a builtin tile, named by set + pattern.

Body{ "pattern": <spec> }, where <spec> is one of:

  • "none" — no pattern fill (reverts to the solid / gradient fill)
  • { "set": "Brick", "name": "Red", "overlay": false }
    • set + name — a set and pattern from GET /v1/pattern-sets (both case-insensitive)
    • overlay (optional, default false) — the pattern's overlay mode. Default false is the usual opaque pattern fill (the tile covers any solid/gradient fill). true turns on overlay so an underlying gradient shows through (the same option referenced by the gradient endpoint's pattern note)
{ "pattern": { "set": "Wood", "name": "Oak" } }

Success — 200 OK{ "pattern": <resolved>, "patternable": true }. The resolved spec is { set, name, overlay } (or "none"); name is returned in its canonical capitalization.

Undoable ("Pattern"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a pattern, or the set/name is unknown. 400 — missing pattern. 404 — not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/pattern — read the pattern fill

Returns { "pattern": <spec>, "patternable": bool }pattern is "none" or { set, name, overlay }; patternable is false for graphic types that cannot carry a pattern.

GET /v1/hatch-patterns — builtin vector-hatch catalog

Returns { "hatchPatterns": [ "ANSI31", "Concrete", … ] } — EazyDraw's builtin vector hatches: line fills drawn as real bezier lines (distinct from the bitmap tiles of /v1/pattern-sets). Flat named list (the source is the bundle drawing NamedHatches.ezddata). Global (not drawing-scoped). Apply one with a { name } on PATCH .../hatch.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/hatch — set the vector hatch fill

Sets a graphic's hatch fill (DKDHatch on graphicHatch) to a builtin named hatch, with optional parametric overrides.

Body{ "hatch": <spec> }, where <spec> is one of:

  • "none" — no hatch fill
  • { "name": "ANSI31", "angle": 45, "scale": 1.0, "double": false }
    • name — a hatch from GET /v1/hatch-patterns (case-insensitive)
    • angle (optional, degrees, counterclockwise) — rotates the hatch lines, relative to the hatch's natural orientation (0 = as designed)
    • scale (optional) — density multiplier (1.0 = the hatch's default; larger = coarser)
    • double (optional, default false) — cross-hatch (a second set of lines across the first)
{ "hatch": { "name": "ANSI31", "angle": 45, "double": true } }

Success — 200 OK{ "hatch": <resolved>, "hatchable": true }. The resolved spec is { name, angle, scale, double } (or "none"); name echoes back in canonical case.

Undoable ("Hatch"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a hatch, or the name is unknown. 400 — missing hatch. 404 — not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/hatch — read the vector hatch fill

Returns { "hatch": <spec>, "hatchable": bool }hatch is "none" or { name, angle, scale, double }; hatchable is false for graphic types that cannot carry a hatch.

GET /v1/arrow-forms — builtin arrow-head catalog

Returns { "arrowForms": [ "Solid", "Open", "Curved", "Bar", "Dot", "Diamond", … ] } — EazyDraw's builtin arrow-head forms (the basic, model-level set). Global (not drawing-scoped). Apply one with a { form } on PATCH .../arrow.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/arrow — set the line-end arrow

Puts an arrow head on a graphic's path ends (DKDArrow on the DKDBezier). Open paths only (a line, multi-segment line, arc, or open bezier). One form applies to whichever ends you choose — start, end, or both.

Body{ "arrow": <spec> }, where <spec> is one of:

  • "none" — no arrow (both ends off)
  • { "form": "Solid", "ends": "end", "size": 12, "angle": 160, "reference": "relief", "shift": 0 }
    • form — an arrow form from GET /v1/arrow-forms (required, case-insensitive)
    • ends"start" | "end" | "both" (default "end"); which path end(s) carry the head
    • size (optional) — the head size
    • angle (optional, degrees) — the head's spread angle (default 160°)
    • reference"relief" | "offset" (default "relief"); how the tip sits vs. the line end — relief keeps a thick line from poking through the head
    • shift (optional) — the relief/offset distance along the path end
{ "arrow": { "form": "Solid", "ends": "both", "size": 14 } }

Success — 200 OK{ "arrow": <resolved>, "arrowable": true }. The resolved spec is { form, ends, size, angle, reference, shift } (or "none"); form echoes back in canonical case.

Undoable ("Arrow"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept an arrow (only open paths do), or the form / ends is invalid. 400 — missing arrow. 404 — not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/arrow — read the line-end arrow

Returns { "arrow": <spec>, "arrowable": bool }arrow is "none" or { form, ends, size, angle, reference, shift }; arrowable is false for graphic types that cannot carry an arrow.

GET /v1/crossover-styles — builtin crossover catalog

Returns the schematic crossover symbols (the mark where one line crosses another), each tagged with the meaning that matters most — does the crossing connect or not:

{ "crossoverStyles": [
    { "name": "Junction",    "group": "connection",    "family": "junction", "aliases": ["Dot"] },
    { "name": "Terminal",    "group": "connection",    "family": "terminal", "aliases": ["Block"] },
    { "name": "Header",      "group": "connection",    "family": "header",   "aliases": ["Square"] },
    { "name": "Hop (Round)", "group": "no-connection", "family": "hop",      "aliases": ["Bridge","Jumper"] },
    { "name": "Hop (Square)","group": "no-connection", "family": "hop",      "aliases": ["Hop"] },
    { "name": "Gap (Plain)", "group": "no-connection", "family": "gap",      "aliases": ["Open"] },
    { "name": "Gap (Dashed)","group": "no-connection", "family": "gap",      "aliases": ["Dash"] },
    { "name": "Gap (Barred)","group": "no-connection", "family": "gap",      "aliases": ["Bar"] },
    { "name": "Gap (Arrow)", "group": "no-connection", "family": "gap",      "aliases": ["Break"] },
    { "name": "Jog (Z)",     "group": "no-connection", "family": "jog",      "aliases": ["Z Break"] },
    { "name": "Jog (S)",     "group": "no-connection", "family": "jog",      "aliases": ["S Break"] },
    { "name": "Jog (N)",     "group": "no-connection", "family": "jog",      "aliases": ["N Break"] },
    { "name": "Loop (Open)", "group": "no-connection", "family": "marker",   "aliases": ["Eye"] }
  ],
  "positions": ["Percent","Across","Down","Automatic"],
  "directions": ["Path Right","Path Left","Path Alternate"] }

group is the key distinction: connection symbols mean the wires join (a filled Junction is the universal schematic dot) — no-connection symbols mean they cross *without* joining (the hops, gaps, jogs). Don't substitute one for the other. Global (not drawing-scoped). Apply one with a { style } on PATCH .../crossover; every legacy aliases name is still accepted on input.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/crossover — set the crossover symbol

Sets a graphic's crossover (DKDCrossOver on the DKDBezier) — the wire-hop/break symbol drawn where this path crosses another. Open paths only.

Body{ "crossover": <spec> }, where <spec> is one of:

  • "none" — no crossover (active off)
  • { "style": "Hop (Round)", "position": "Automatic", "direction": "Path Right", "along": 0.5, "across": 0, "down": 0, "size": {"width": 8, "height": 8} }
    • style — a crossover style name (or any legacy alias) from GET /v1/crossover-styles (required, case-insensitive). Pick from the matching groupno-connection for a crossing, connection to join.
    • positionPercent | Across | Down | Automatic (default for a new crossover). Automatic auto-detects where this path crosses other paths and places the symbol there — the headline of the tool.
    • directionPath Right | Path Left | Path Alternate (which way the symbol bends)
    • along — 0..1 down the path (used when position = Percent); across/down are the values for the Across/Down fixed modes
    • size{ "width", "height" } of the symbol
{ "crossover": { "style": "Hop (Round)", "position": "Automatic" } }

Success — 200 OK{ "crossover": <resolved>, "crossoverable": true }. The resolved spec is { style, position, direction, along, across, down, size } (or "none"); names echo back in canonical case.

Undoable ("Crossover"), layerLock-gated (423; ?force=true overrides). 422 — the graphic type does not accept a crossover (only open paths do), or the style / position / direction is invalid. 400 — missing crossover. 404 — not found.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/crossover — read the crossover

Returns { "crossover": <spec>, "crossoverable": bool }crossover is "none" or { style, position, direction, along, across, down, size }; crossoverable is false for graphic types that cannot carry a crossover.

Brush — variable-line-width stroke (DKDBrush)

A brush gives a path a stroke whose width varies along its length (the Illustrator/Affinity "width profile", or a hand‑drawn artistic outline). Stroke‑side, on DKDBezier (acceptsBrush → only bezier paths). Tier‑1 ships the Artistic engine brush authored two ways; both produce the same kind of brush and differ only in how the defining outline is built.

How it works: a brush is one closed *outline* whose X spans the path's length and whose height at each X is the stroke width there (centered on the path). The profile form generates that outline from a width array; the artistic form takes the outline directly. The drawing engine needs a dense defining outline, so the profile is sampled to density points per side (default 64, clamped 8…512) — the discoverable appearance lever.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/brush — set the brush (set_brush)

Body{ "brush": <spec> }:

// remove the brush
{ "brush": "none" }

// PROFILE — width along the path (Illustrator/Affinity model)
{ "brush": {
    "type": "profile",
    "strokeWidthBase": 12.0,                         // full stroke width (document points) at widthScale 1.0
    "widthProfile": [                                 // >= 2 points; t 0..1 along the path, widthScale 0..
        { "t": 0.0, "widthScale": 0.0 },             //   tapered start (a point)
        { "t": 0.5, "widthScale": 1.0 },             //   full width mid
        { "t": 1.0, "widthScale": 0.0 } ],           //   tapered end
    "density": 64,                                    // optional; samples per side, 8..512
    "curveLinear": true                               // optional; interpret the brush along curves
} }

// ARTISTIC — supply the outline directly (full control; the ivy-leaf case)
{ "brush": {
    "type": "artistic",
    "outline": { "closed": true, "nodes": [ { "point": [x, y], "in": [x, y], "out": [x, y] }, … ] },
    "curveLinear": true
} }

widthProfile is interpolated linearly and need not be evenly spaced; values before the first / after the last t hold the endpoint. outline is the same { closed, nodes } shape as add_path/get_path. Tier‑1 uses brushStyle: path (the stroke takes the host path's color/line attributes — no separate brush graphic style); a per‑brush style is a later tier.

Success — 200 OK{ "brush": <spec>, "brushable": true }. The response brush echoes the exact authoring spec (the profile or outline you sent) — the spec is stashed on the brush so get_brush round‑trips it verbatim. layerLock‑gated (423 + ?force=true); 422 if the graphic does not accept a brush.

GET /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/brush — read the brush (get_brush)

Returns { "brush": <spec>, "brushable": bool }brush is "none" or the stashed authoring spec (type + strokeWidthBase/widthProfile/density, or outline) plus curveLinear and style. A brush built in EazyDraw's UI (no API stash) reports a small geometry summary (type: "artistic", pathCount) instead of the exact spec.

MCP. set_brush(name, width_profile=…, stroke_width_base=…) for the profile form, set_brush(name, outline=…) for artistic, on=False to remove; get_brush(name) to read. The example‑brush table in EazyDraw's UI is a mechanical assembly tool, intentionally not exposed to the client.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/name — name a graphic

Sets a graphic's nameGraphic — a human-meaningful handle ("ceo-photo-frame", "living-room-fireplace") so a client can address it in a complex scene.

Body{ "name": "garden-north-lettuce-plot", "uniquify": false }. name is required and non-empty; uniquify defaults to false.

Uniqueness (server-owned). A name must be unique among the drawing's top-level graphics. If name is already taken, the request fails with 409 Conflict — unless uniquify: true, which appends a suffix to make it unique. Either way the response is the <graphic> dict carrying the final nameGraphic, so the client always learns the actual name it can address.

Renaming a graphic to its own current name is a no-op (not a conflict). Naming is metadata — allowed even on a locked layer (not lock-gated). Undoable ("Name Graphic"). Works on nested (grouped) graphics. 404 — drawing / layer / graphic not found; 400 — empty name.

Graphic naming model

Source of truth: the drawing's UUID. A drawing "uses UUIDs" iff it has a documentUUID (the Flat/UUID feature) — *not* any app setting or default, which only seed new drawings.

  • UUID drawing → every graphic introduced to it (created, pasted, duplicated, or placed via the API) gets both a unique UUID and a unique name. A *unique* user-/client-supplied name is respected; a graphic with no name is auto-named from its localized typeNameForMenu ("Rectangle", "Text", …); and any name that collides is uniquified ("Rectangle 2") — so a paste/duplicate (which copies the source's name) becomes "ceo-photo 2", not a second "ceo-photo". The UUID is likewise made unique: a duplicate's copyWithZone: drops it (→ fresh), and a paste deserializes the source's UUID (→ regenerated on collision). File-load is exempt (graphics keep their stored names/UUIDs).
  • Legacy drawing (no documentUUID) → lax: no enforcement, an unnamed graphic stays nameless.

So in a UUID drawing every graphic always has a unique name and UUID, and the client can address any of them by name — replacing the bland auto-names with descriptive ones via PATCH …/name.

GET / PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/lock — read and set locks

The three locks the API enforces (see Lock policy) can be read and set here — the Format ▸ Lock menu for scripts: protect finished work before a bulk edit, or unlock something the user has asked to have changed.

  • GET …/lock → `{ "locks": { "deleteLock", "moveLock", "sizeLock", "layerLock" },

"accepts": { "deleteLock", "moveLock", "sizeLock" } } — which locks are set (the same locks sub-dict every <graphic> carries) and which of them this graphic can take (acceptsDeleteLock / acceptsMoveLock / acceptsSizeLock`).

  • PATCH …/lock with any subset of `{ "deleteLock": <Bool>, "moveLock": <Bool>,

"sizeLock": <Bool> } — only the keys sent change; all three false unlocks. Built the way the menu commands build it: a DKDMutableLock copy of the graphic's DKDLock, flags applied, frozen back to an immutable DKDLock, and installed with swapLock:oldLock:` (self-registering undo). Action name "Lock" (or "Unlock" when the result has no locks set).

Success — 200 OK. PATCH returns the <graphic>, whose locks show the result.

Errors: 400 no lock key, or a value that is not a boolean; 404 graphic not found; 422 the graphic does not accept a requested lock (check accepts); 423 the graphic's layer is locked, unless ?force=true (layerLock itself is set in the Layers table, not here).

Python: ed.get_lock(d, l, g), ed.set_lock(d, l, g, delete=True, move=True). MCP: get_lock(name), set_lock(name, delete?, move?, size?).

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/order — z-order

Changes a graphic's front‑to‑back position within its layer (the layer's graphics array order; 0 = bottom). Reuses -[DKDDocument sendGraphic:toIndex:].

Body: { "to": "front" | "back" | "forward" | "backward" }front/back to the top/bottom of the layer, forward/backward one step. 200 OK — the <graphic> dict (its index reflects the new z‑position). Undoable; layerLock‑ gated. Top-level graphics only (nested → 422).

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/move-to-layer — move to a layer

Moves a graphic from its current layer to another, via -[DKDDocument moveGraphic:toLayer:index:] (undo‑aware). Useful for parking a graphic on a working layer or bringing it onto a visible one.

Body: { "layer": "<destination name or uuid>", "index"?: <Number> } — the destination is matched against each layer's uuid (case‑insensitive) then layerName; index is the z‑position in the destination (default: top). 200 OK — the <graphic> dict; graphicUUID is preserved, so after the move address it under the *destination* layer's uuid. Undoable; layerLock on both source and destination is honored (423 + ?force=true). Top-level graphics only.

Errors: 400 (UUIDs/body/missing layer), 401, 404 (graphic chain or destination layer unresolved), 422 (nested graphic, or already on that layer), 423 (locked), 500.

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/flip — flip a graphic

Mirrors a graphic in place — the common vector‑editing flip, and the fix for an upside‑down image (something an AI can spot in a render but EazyDraw itself cannot perceive). Reuses the flip menu command's primitive (flipGraphicsWithFlipSpec:), so it handles the same geometry, grid‑reference, and undo behavior as the UI.

Request body: { "axis": "horizontal" | "vertical" | "mirror" }horizontal mirrors left↔right, vertical top↔bottom, mirror both (180°).

Success — 200 OK. The standard <graphic> dict; the bounding box is unchanged (a flip preserves position and size), and graphicUUID is preserved. One undoable "Flip …" step. Honors layerLock (423 + ?force=true); a flip is neither a move, resize, nor delete, so the per‑graphic move/size/delete locks do not apply.

Errors: 400 (missing UUIDs / body / bad axis), 401, 404 (chain unresolved), 423 (layer locked), 500.

POST /v1/drawings/{D}/layers/{L}/images — insert content (raster or PDF)

Inserts content onto the layer at the front of its z-order (above existing content). This is the *insert into an existing drawing* path; to bring a file in as a new drawing sized to its content, use POST /v1/drawings (see below). The destination is the layer in the URL — make that layer active (the agent drives the destination by choosing the active layer). Page geometry is never changed here.

Request body:

{
  "imageBase64": "<bytes>",   // OR "path": "~/sig.pdf" (a file the EazyDraw process can read)
  "quality":     "screen | retina | print",   // raster only — resample ceiling 72 / 144 / 300; default retina
  "width":       <Number>,    // raster only — display width in document points; default = native pixel size
  "x": <Number>, "y": <Number>,   // raster only — lower-left origin; default centered on the visible view
  "name":        "<string>"   // optional — makes the graphic an addressable named slot
}

Provide imageBase64 (the contract for remote/MCP callers; same Base64 the API *returns* image data as) or a local path (tilde‑expanded; read server‑side).

Raster (PNG/JPEG/TIFF/HEIC/…) — routed via NSImage initDKDWithData:. One bitmap graphic at its native pixel size (1 px → 1 pt), matching how opening the image sizes a page; pass width (document points) to scale it down. Height follows the source aspect (never distorted). scaleFract = (targetDPI × displayWidthPoints / 72) / sourcePixelsWide, clamped ≤ 1 (never upsamples); resampled to that fraction and tagged at targetDPI. Centered on the visible view unless x/y given.

  • Success 201 Created — the <graphic> dict plus an image block:

{ "pixelsWide", "pixelsHigh", "dpi", "bytes" }. Undoable "Insert Image".

PDF — detected by NSPDFImageRep. Comes in as vector, one DKDPDFImage graphic per page at native page size (no scaling), all pages sharing one data blob. Pages are laid onto the drawing's existing page grid via pointWithPageNumber: (page *i* → drawing page *i*); pages past the current grid extend below the canvas — growing the page count is the user's call (not done here). width/x/y/quality are ignored. With more than one page the name is suffixed -1, -2, … so each page stays individually addressable; every page also gets its own graphicUUID.

  • Success 201 Created{ "pageCount": <N>, "graphics": [ <graphic>, … ] },

each graphic dict carrying an extra "pageNumber" (0-based). Undoable "Insert PDF".

Both honor the layer's layerLock (423 + ?force=true).

Errors: 400 (missing path UUIDs / body / neither imageBase64 nor path / bad Base64), 401, 404 (drawing/layer not found; or path unreadable), 422 (image data could not be decoded), 423 (layer locked), 500.

PUT /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/image — fill a named slot

The image Fill keystone (the analog of set_text). The target is a named placeholder — a plain rectangle a designer drew where the image goes (no need to author a PNG placeholder), or an existing image. The incoming image is fitted into the placeholder's rectangle preserving aspect (contain: min(rectW/imgW, rectH/imgH)), centered on the placeholder's center, then the placeholder is replaced — the new image keeps the placeholder's nameGraphic and graphicUUID, so the slot stays addressable by the same name for the next fill.

Body is the same as the insert endpoint (imageBase64 or path, plus quality screen/retina/print); resampling/DPI policy is identical, sized to the fitted rectangle. Success 200 OK — the new <graphic> dict + image block. One undoable "Fill Image" step. v1: top-level placeholders only (a nested chain returns 422). Honors layerLock (423 + ?force=true).

Errors: 400 (path/body), 401, 404 (graphic chain unresolved / unreadable path), 422 (nested placeholder, undecodable image, or zero-size placeholder), 423 (locked), 500.

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain} — morph a graphic

Sets the absolute bounds (position + size) and/or rotation of an existing graphic. The path identifies a single graphic, either at the top of a layer or nested in groups (same UUID-chain grammar as the recursive group-children GET and the graphic-export endpoints).

PATCH /v1/drawings/{D}/layers/{L}/graphics/{G}                          # top-level
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}           # nested
PATCH /v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/graphics/{G3}   # deeper, and so on

Request body: bounds and/or rotationat least one required:

{
  "bounds": {                  // optional; if present, all four fields required
    "x":      <Number>,
    "y":      <Number>,
    "width":  <Number, > 0>,
    "height": <Number, > 0>
  },
  "rotation": <Number>,        // optional; degrees, about the graphic's geometric center
  "text": "reflow" | "font" | "stretch"   // optional; how a size change reaches text (below)
}

Text boxes resize by reflow (2026-09-10). For a DKDTextArea target a bounds change defaults to "text": "reflow": the box is set (setBoundsTextArea:, the knob-drag behavior, clamped to the box's minSize), the text re-wraps, and the font size is untouched — so "the text doesn't fit, make the box bigger" is one PATCH to the requiredSize, not a delete-and-recreate. "font" scales the point size with the box (FontTextMorphMethod; fontSize in the text dict changes accordingly). "stretch" is the engine's glyph stretch (StretchShrinkTextMorphMethod, the previous default), which leaves fontSize nominal and records the factor in layout.stretch (see the text dict). For non-text graphics text only matters for text nested in a group: "font" scales those labels' point size; otherwise they stretch. Rotation is unaffected by this field. 400 for any other value.

x and y are document-space coordinates; width and height must be positive. rotation is in degrees and spins the graphic about its own geometric center (nativeCenterGraphic), matching the Morph panel and the general expectation for grouped/nested content. Omit bounds to rotate in place; omit rotation for a pure resize/move. A custom pivot point (e.g. via each graphic's DKDGridReference snap point, or an arbitrary point) is a future enhancement.

MCP convenience tools. The morph endpoint is fronted by four intent-named MCP tools that read the graphic's current bounds and fill in the rest: move(x, y) (absolute position, keep size), resize(width?, height?) (absolute size, keep origin), scale(factor) (uniform, about the center), and rotate(degrees) (relative spin about the center). They exist because a single morph verb was hard for an AI client to discover; each is a thin wrapper over this same PATCH.

Success — 200 OK. Body is the standard <graphic> dict reflecting the post-morph state: graphicUUID is preserved, but type may change (see "Class swap" below). Note hiddenBounds is the axis-aligned bounding box, so it grows when a graphic is rotated — it is not the requested bounds once rotation is non-zero.

Class swap (minimum geometric generalization). Some EazyDraw graphics cannot represent themselves under a given transform and convert to a more general class, preserving the graphicUUID and z-order index:

  • Scaling: a DKDCircle asymmetrically scaled converts to DKDOval (acceptsScalingTransform / conversionClassForScaling / convertForScaling).
  • Rotation: a graphic with no angle parameter converts before rotating — e.g. a DKDRectangle (which deliberately has no rotation handle) becomes a DKDRotatedRect (acceptsRotationTransform / conversionClassForRotation / convertForRotation, applied via +[DKDTransMorphPanel performRequiredRotationConversionsWithGraphicView:]). Graphics that already carry an angle (DKDTextArea, DKDRotatedRect) rotate in place. Arc/pie-type graphics morph as Bezier geometry about their center.

The type field in the response shows the post-swap type. Both swaps are top-level layer graphics only: a nested graphic that would require a class swap returns 422 "Nested graphic class swap not supported in v1" — uniform-scale, pure-translation, and rotation that needs no class change still work for nested graphics.

Transform sequence. Inside one dispatch_sync(dispatch_get_main_queue(), ...), with the target temporarily selected: (1) scale sX = newW/oldW, sY = newH/oldH if not unity; (2) translate (dx, dy) so the box origin lands at the requested x, y; (3) rotate last — setPureRotation + rotateByDegrees:-rotation, applied via [gView morphSelectedGraphicsWithTransform:includeText:YES] which rotates about the graphic's native center. Selection is saved and restored.

Locks. moveLock (when origin changes), sizeLock (when size changes or when rotating — there is no separate rotate-lock in v1), and the layer's layerLock. 423 Locked + ?force=true to override.

Errors:

  • 400 Bad Request — missing path UUIDs; empty/malformed body; neither bounds nor a non-zero rotation; bounds present but missing/non-numeric x/y/width/height or non-positive width/height; rotation not a number.
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — drawing/layer/graphic chain does not resolve.
  • 422 Unprocessable Entity — graphic does not accept the scale/rotation and has no conversion class; source graphic has non-positive bounds; nested graphic would require a class swap (top-level graphics only); or class conversion produced no replacement.
  • 423 Locked — target graphic or its layer is locked against the change; retry with ?force=true.
  • 500 Internal Server Error — response build failure.

curl:

# resize + reposition
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"bounds":{"x":100,"y":100,"width":200,"height":150}}' \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G
# rotate 30 degrees in place
curl -i -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"rotation":30}' \
  http://localhost:52737/v1/drawings/$D/layers/$L/graphics/$G

Python:

g = ed.morph_graphic(d_uuid, l_uuid, g_uuid,
                     x=100, y=100, width=200, height=150)
g = ed.morph_graphic(d_uuid, l_uuid, g_uuid, rotation=30)   # rotate in place
print(g["type"], g["hiddenBounds"])
# A rectangle rotated comes back type "rotated-rect"; graphicUUID unchanged.

POST /v1/drawings/{D}/layers/{L}/combine — union / difference / intersection

Path booleans of two closed shapes — EazyDraw's Tools ▸ Combine (DKDDocViewCombine: combineKeepInsideA:insideB:, driven through an ordered two-graphic selection so the tested engine path, layer bookkeeping and undo are reused).

{
  "operation": "union" | "difference" | "intersection",
  "graphics":  [ "<A graphicUUID>", "<B graphicUUID>" ],     // exactly two, top-level on layer {L}
  "name":      "<optional name for the result>",
  "uniquify":  false
}
operationEngine callResult
unionkeepInsideA NO, insideB NOone outline covering both shapes
differencekeepInsideA NO, insideB YESA minus B — B is cut out of A (order matters)
intersectionkeepInsideA YES, insideB YESonly the overlap

The result is a new DKDBezier / DKDPolygon initialized from A (dkdinitWithDKDBez:), so it keeps A's graphicUUID, name, style and z-order index — think of it as A reshaped; B is consumed. A's type may change (e.g. rectanglepath). The active layer is switched to the operands' layer for the operation and restored, as the menu command does. One undo step, named Union / Difference / Intersection.

Eligibility. Each operand must answer acceptsCombine (a closed bezier path: rectangle, rounded-rectangle, oval, polygon, closed path, …); lines, text boxes, images and groups do not. The shapes must overlap or touch (canCombine); for difference B must cut into A (canDifference).

Success — 201 Created. The result's <graphic> dict (same graphicUUID as A).

Errors: 400operation not one of the three, or graphics not exactly two UUIDs; 404 — drawing / layer / an operand not found among the layer's top-level graphics; 409name taken (and no uniquify); 422 — the two UUIDs are the same graphic, an operand is not a closed path, the shapes do not overlap, or the engine produced no result; 423 — a delete lock on an operand or layerLock (?force=true overrides).

MCP: combine(operation, a, b, name?) — the tool description also answers to merge / add, subtract / cut out, and clip / overlap, and accepts those words as aliases.

Group / ungroup — DKDGroup

The single most common vector-drawing structural operation: combine several graphics into one addressable object, and dissolve it again. Both drive EazyDraw's own Group / Ungroup engine commands (via a temporary selection inside dispatch_sync(main)), so the tested grouping path — connection handling, layer bookkeeping, undo — is reused rather than reimplemented. Each is one discrete, undoable step ("Group" / "Ungroup").

POST /v1/drawings/{D}/layers/{L}/groups — group (group)

Body: { "graphics": [uuid, uuid, …], "name"?: …, "uniquify"?: bool }. Two or more graphic UUIDs, all top-level on layer {L} (v1). The new DKDGroup is created at the grouped graphics' position, becomes the sole selection, and (in a UUID drawing) is auto-named unless a name is given. A supplied name is validated before the group is built, so a clash is a clean 409 (with no half-made group) unless uniquify: true.

Success — 201 Created. Body is the standard <graphic> dict for the new group, including graphicsCount. Its graphicUUID is immediately usable to morph / restyle / export the group as a unit, or to descend into it via the recursive …/graphics GET.

Errors: 400graphics missing or fewer than 2 UUIDs; 404 — drawing, layer, or a listed graphic not found among the layer's top-level graphics; 409 — name taken (no uniquify); 422 — fewer than 2 distinct graphics resolved; 423 — the layer is locked (?force=true to override). Groups top-level layer graphics only (a nested member is not addressable here).

POST /v1/drawings/{D}/layers/{L}/graphics/{G_chain}/ungroup — ungroup (ungroup)

Dissolves the DKDGroup at {G_chain} back into its members, which return to the layer as independent graphics (the group is removed). v1: top-level groups only ({G_chain} is a single UUID).

Success — 200 OK{ "graphics": [ <graphic>, … ], "count": N } — the freed members (now the selection). Member graphicUUIDs are preserved, so anything you addressed before grouping remains addressable.

Errors: 404 — not found; 422 — the target is not a DKDGroup, or {G_chain} is nested (not top-level); 423 — the layer is locked (?force=true). Member-level locks are not separately enforced — ungroup is a structural change, not a move/resize/delete.

MCP. group(members=[name, …], name?, uniquify?) resolves each member by name (must share a layer, all top-level) and returns the new group; ungroup(name) returns the freed members. Both are intent-named so a client reaches for them directly.

DELETE /v1/drawings/{D}/layers/{L}/graphics — clear a layer (clear_layer)

Removes all top-level graphics from layer {L} in one undoable step ("Clear Layer") — the bulk complement to the single-graphic DELETE …/graphics/{G}. Natural for prompt-driven "wipe this layer and start over".

Lock policy is atomic. With no ?force, the layer is scanned first: if the layer itself is locked, or any graphic on it blocks delete (deleteLock / moveLock as the single-delete uses), nothing is removed — 423 Locked. ?force=true clears everything regardless. (This differs from a best-effort "skip the locked ones" — clearing is all-or-nothing so the result is predictable.)

Success — 200 OK{ "deleted": N } (the number removed; 0 if the layer was already empty — the call is idempotent). Internally each graphic is removed via the document's own removeGraphic: inside one undo-hold-off group, so a single Undo restores the whole layer.

Errors: 404 — drawing or layer not found; 423 — locked (see above). MCP clear_layer(layer?, force?)layer by name or uuid, omitted = the active layer; returns {layer, deleted}.

Text endpoints — DKDTextArea

Programmatic control of text-box content. The target is any DKDTextArea graphic, addressed by the same UUID-chain grammar as the morph/GET/DELETE graphic endpoints (top-level or nested in groups).

Model: a DKDTextArea is a first-class DKDGraphic carrying an NSAttributedString. The intended workflow is template-driven: a designer places a styled placeholder text box (font, size, color, alignment, tab stops all set in the EazyDraw UI); the API then feeds plain text into it. Styling is inherited from the placeholder's run-0 attributes — the API does not require the client to specify appearance. Optional uniform overrides adjust the whole box when needed. There are no per-character / per-range style controls in this version (see "Not exposed by the API").

GET .../graphics/{G_chain}/text

Returns the current text and live layout metrics. A shape host resolves to its contained text (the response carries textTarget: "text" or "contained-text"), matching the runs endpoints and Disconnect Text — so any text tool can address the shape it sees. Annotation hosts are *not* resolved here (no layout contract — use .../text/runs or .../annotation). base always includes alignment (default left when the text carries no explicit paragraph style; note a centered-text centers via its host geometry, independent of paragraph alignment).

{
  "text": "Quarterly\nSchematic",
  "type": "text",
  "typeName": "Text",
  "layout": {
    "bounds":       { "x": 100, "y": 120, "width": 180, "height": 48 },
    "requiredSize": { "width": 142.0, "height": 44.0 },   // size text needs at the current width
    "fits":         true,                                  // requiredSize.height <= bounds.height
    "stretch":      { "x": 1.0, "y": 1.0 },                // glyph stretch from a stretch-mode morph; 1.0 = none
    "charCount":    19,
    "paragraphCount": 2
  },
  "base": {                          // resolved run-0 attributes (the inherited base)
    "fontFamily": "Helvetica Neue",
    "fontSize":   18.0,
    "bold":       false,
    "italic":     false,
    "color":      "#1A1A1A",
    "alignment":  "left"
  },
  "flow": {                          // text-flow / linked-chain state
    "allowTextLink": false,          // is the box flow-enabled (the "allow text link" switch)
    "linked":        false,          // part of a linked chain (has a pre or post connector)
    "isLead":        false,          // chain head (no incoming link, has an outgoing one)
    "chainLength":   1,              // boxes in the chain (1 if not linked)
    "chainFits":     true            // the whole chain holds the text (no box overflows)
  }
}

requiredSize is -[DKDTextArea requiredSize:] evaluated at the box's current width — the "give a width, get the needed height" primitive for fitting text to a design area. fits compares that height to the current box height.

flow is the upstream clue that long text can *flow* instead of overflowing: when linked is true, setting text on the lead distributes it across the chain (see Text flow below). chainFits is the chain-wide analog of fitsfalse means even the whole chain can't hold the text (shrink the font, or enlarge the boxes). allowTextLink mirrors EazyDraw's Text ▸ Allow Text Link switch.

Errors: 400 (missing UUIDs), 401, 404 (chain does not resolve), 422 (the graphic is not a DKDTextArea), 500.

PATCH .../graphics/{G_chain}/text

Sets text and/or uniform style. A shape host resolves to its contained text, as in GET (response carries textTarget). Body — all fields optional, but at least one of text or a style override (or autoHeight) must be present:

KeyTypeEffect
textstringReplace the string (may contain \n and \t). Omit to keep the existing string and only restyle.
fontFamilystringFont family, via NSFontManager.
fontSizenumberPoint size (> 0).
boldboolAdd/remove the bold trait.
italicboolAdd/remove the italic trait.
alignmentstringleft \center \right \justified \natural.
kerningstringdefault \off \tight \loose (approximate point-based mapping).
colorstring#RRGGBB or #RRGGBBAA.
autoHeightboolWhen true, grow the box height to fit the text at the current width (setTextAreaSize), as a single undo step with the text change. Ignored when the box flows (a linked chain reflows instead of growing).
allowTextLinkboolSet/clear the box's flow switch (-[DKDTextArea allowTextLink], EazyDraw's Text ▸ Allow Text Link). With a linked chain present, enabling it makes set-text reflow across the chain.

Uniform semantics. The new content is built by taking the placeholder's run-0 attributes, applying the supplied overrides, and laying that uniform style across the entire string. A box with mixed runs is flattened to the run-0 style. This is the Level-1 contract; structural rich text is out of scope.

Tabs / columns. Because the run-0 paragraph style (including tab stops) is inherited, \t-delimited input lays out into the columns the template designer set up — tables, invoices, packing lists work with no table API.

Fit policy. Without autoHeight, the text is set at the current box; if it overflows, the response reports fits:false and requiredSize so the client (or Claude, with a PNG render) decides how to fit — resize the box (PATCH bounds) or resend with a smaller fontSize. EazyDraw never silently auto-fits.

Text flow (linked chains). When a box is flow-enabled (allowTextLink) and linked to following boxes, setting text on the lead makes the whole chain reflow: the text is laid out across the linked boxes (each sized to itself) and each box's slice is written back — attributes preserved (the chain shares one text stream, so styling is not lost the way a manual split would lose it). This reuses the same engine as interactive editing (-[DKDTextArea fullReflowText], gated by reflowApplies = allowTextLink && a post-link connector).

  • Setting text on the lead replaces the whole flowed stream: the handler clears

the downstream boxes first, then reflows, so repeated fills don't double-count.

  • The response's flow.chainFits reports whether the whole chain holds the text

(the per-box layout.fits will read true for the lead even when the tail overflows, because after reflow the lead only holds its own slice — use chainFits for the real answer).

  • Text distributes across the existing linked boxes; it does not create new

boxes when the chain overflows (it does not add boxes to the chain). Address the lead box for whole-stream replacement.

  • Linking boxes themselves (drawing the DKDTextLinkPath connectors) is done in

the EazyDraw UI today; the API toggles allowTextLink and drives the reflow.

Success — 200 OK. Body is the <text> dict (same shape as GET), reflecting post-change state — so the fit metrics come back in the same call.

Undo. One discrete step named "Set Text" (swapWithUndoDKDContents:); with autoHeight, the height change is grouped into the same step.

Locks. Checked via the standard helper: the layer's layerLock blocks any change; the graphic's sizeLock blocks only when autoHeight would resize. A pure text/style change is not gated by moveLock/sizeLock. 423 Locked with ?force=true to override.

Errors: 400 (missing UUIDs; empty/malformed body; text not a string; nothing actionable supplied), 401, 404, 422 (not a DKDTextArea), 423 (locked), 500.

t = ed.set_text(d, l, g, "Widget\t3\t$9.00\nGrommet\t12\t$1.20", autoHeight=True)
print(t["layout"]["fits"], t["layout"]["requiredSize"])
t = ed.set_text(d, l, g, font_size=10)        # restyle only, keep the string

Contained text — Insert / Disconnect (POST / DELETE .../text)

EazyDraw's Text ▸ Insert Text and Text ▸ Disconnect Text, as REST verbs on the host graphic's /text sub-resource. Contained text is a DKDTextArea connected to a shape — the label travels with the shape.

POST .../graphics/{G_chain}/text — Insert Text

Body: { "text": "<non-empty string>" } plus optional centered (bool, default true) and the Level-1 uniform overrides (fontFamily, fontSize, bold, italic, alignment, kerning, color). The new graphic gets a UUID and an auto-name like any API-introduced graphic.

Two forms (stable type distinguishes them everywhere):

centeredClasstypeBehavior
true (default)DKDCenterTextcentered-textre-centers with the host on move and resize — what a shape label wants
falseDKDTextAreatextposition-fixed contained text, placed in the upper half of the host's bounds; moves with the host but does not re-center on resize

Success — 201 Created. Body is the <runs> dict of the new contained text (textTarget: "contained-text"; its type key echoes the form) plus containedTextUUID and the layout block. Undo: one step ("Insert Centered Text" / "Insert Text"). Locks: layerLock; 423 + ?force=true.

Errors: 400 (missing/empty text), 401, 404, 409 (the graphic already has contained text), 422 (the graphic is a text box — use PATCH .../text; the graphic does not accept inserted text; the graphic is nested in a group), 423, 500.

DELETE .../graphics/{G_chain}/text — Disconnect Text

Address either the host shape or the contained text box itself. The text becomes a standalone text box: an API/plain contained text is simply unlinked in place; a UI-typed centered text (DKDCenterText) is replaced by an equivalent plain text box with a new UUID and name (a centered text only exists connected). Contents and styling are preserved either way.

Success — 200 OK. Body is the freed text box's <graphic> dict — use its graphicUUID / nameGraphic to address it afterward. Undo: one step, "Disconnect Text". Locks: layerLock on the host; 423 + ?force=true.

Errors: 400, 401, 404, 422 (no contained text; contained text was empty — unlinked, but no text box produced), 423, 500.

r = ed.insert_text(d, l, shape, text="Pump House", color="#004080")
ed.set_text_attributes(d, l, shape, operations=[{"match": "Pump", "set": {"underline": "single"}}])
box = ed.disconnect_text(d, l, shape)      # -> standalone text box <graphic>

Rich text — attribute runs (.../text/runs, .../text/attributes)

Range-level styling for DKDTextArea content. Unlike the Level-1 PATCH .../text (which rebuilds the string as one uniform run), these endpoints are non-destructive: they read and edit the attribute runs of the existing string without touching characters or unrelated attributes.

> Decision record (2026-07-10). This supersedes the earlier "never a > span-offset API" stance. Field experience with Claude as the MCP agent shows > agents reach instinctively for range styling (highlighting words). The > original objection — LLMs are unreliable at character-offset arithmetic — is > mitigated by design: match addressing resolves substrings to ranges > server-side, and GET .../text/runs supplies authoritative offsets so a > client reads offsets rather than computing them.

GET .../graphics/{G_chain}/text/runs

Returns the string and its normalized runs (contiguous, non-overlapping — NSAttributedString's native form):

{
  "text": "Quarterly Schematic",
  "type": "text",
  "typeName": "Text",
  "charCount": 19,
  "runs": [
    { "range": { "location": 0, "length": 10 },
      "attributes": {
        "fontFamily": "Helvetica Neue", "fontSize": 18.0,
        "bold": false, "italic": false,
        "color": "#1A1A1A", "alignment": "left"
      } },
    { "range": { "location": 10, "length": 9 },
      "attributes": {
        "fontFamily": "Helvetica Neue", "fontSize": 18.0,
        "bold": true, "italic": false,
        "color": "#CC0000", "highlightColor": "#FFFF00",
        "underline": "single", "underlineColor": "#0000CC",
        "alignment": "left"
      } }
  ]
}

Attribute vocabulary (read side is complete from day one; keys camelCase):

KeyTypeNSAttributedString sourceNotes
fontFamilystringNSFontAttributeNamefamily name
fontSizenumberNSFontAttributeNamepoint size
bold / italicboolfont traitsvia NSFontManager
colorhexNSForegroundColorAttributeNamealways present (default #000000)
highlightColorhexNSBackgroundColorAttributeNameper-glyph background — the UI's Highlight
strokeColorhexNSStrokeColorAttributeNameomitted when unset
strokeWidthnumberNSStrokeWidthAttributeName% of point size; negative = stroke and fill
underlinestringNSUnderlineStyleAttributeNamesingle \double \thick (omitted when off)
underlineColorhexNSUnderlineColorAttributeNameomitted when unset (defaults to text color)
strikethroughstringNSStrikethroughStyleAttributeNamesame vocabulary as underline
strikethroughColorhexNSStrikethroughColorAttributeNameomitted when unset
kerningnumberNSKernAttributeNamepoints; omitted when default
baselineOffsetnumberNSBaselineOffsetAttributeNameomitted when 0
obliqueness / expansionnumbercorresponding attributesomitted when 0
alignmentstringparagraph styleparagraph-level (left\center\right\justified\natural)
shadowobjectNSShadowAttributeName{ offset:{x,y}, blur, color }; omitted when no shadow

Optional channels are omitted when unset, so a run's dict is exactly the styling that is actually in effect. highlightColor, underline, and strikethrough form the emphasis trio — the three ways to call out a range of text.

Text targets. Both runs endpoints resolve the addressed graphic to its text target — a text box is not the only thing that carries text:

GraphicTargettextTarget valuelayout block
DKDTextAreaits own content"text"yes
path/shape with an annotation in usethe annotation's attributed text"annotation"no (no fit contract — text may follow a curve)
shape with contained textthe contained DKDTextArea"contained-text"yes

The textTarget key on every runs response says which case applied. This mirrors the drawing engine's own routing (annotation first, contained text second). A graphic with none of these returns 422 "Graphic has no text target". Groups are not resolved (address the member).

Errors: 400, 401, 404, 422 (no text target), 500.

PATCH .../graphics/{G_chain}/text/attributes

Body: { "operations": [ <op>, … ] } — an ordered list, applied sequentially to one working copy and committed as a single undo step. Overlapping ranges are welcome: later operations win where they overlap (NSMutableAttributedString semantics, per character).

Each <op> addresses a range one of three ways, and carries set and/or clear:

KeyTypeMeaning
allboolthe whole string
range{location, length}explicit character range (from GET .../text/runs)
matchstringliteral substring of the current text (case-sensitive)
occurrenceint or "all"with match: which occurrence (1-based, default 1), or every occurrence
setdictattribute keys → values (write vocabulary below)
cleararrayattribute keys to remove from the range

Write vocabulary (Phase 2, 2026-07-11 — now the full read vocabulary except paragraph-level alignment):

  • Colorscolor, highlightColor, strokeColor (+ strokeWidth),

underlineColor, strikethroughColor (hex / number).

  • Style switchesunderline, strikethrough ("none" \| "single" \|

"double" \| "thick"; boolean accepted as sugar — truesingle, falsenone).

  • FontsfontFamily (family name via NSFontManager; an unknown

family leaves the text unchanged, matching the Level-1 override), fontSize (points, > 0), bold, italic (booleans; per-run trait conversion — mixed-font ranges convert each run's own font).

  • Numberskerning (points; distinct from the Level-1 endpoint's

named kerning presets), baselineOffset, obliqueness, expansion.

  • Paragraph (Phase 3) — alignment (left \| center \| right \|

justified \| natural). Alignment is a paragraph property: addressing part of a paragraph aligns every paragraph the range touches (the operation extends to whole-paragraph bounds). A centered-text graphic also centers via its host geometry, independent of this.

  • Shadow (Phase 3) — shadow: `{ "offset": { "x", "y" }, "blur",

"color" } (all fields optional; defaults offset {2,-2}, blur 2, color black). clear: ["shadow"]` removes it.

clear accepts the color, style, number, and shadow keys (font and alignment keys are not clearable — a run always has a font, and a paragraph always has an alignment). This is the full write vocabulary — it now matches the read vocabulary, so a run read from GET .../text/runs can be written back verbatim (see PUT .../text/runs).

PUT .../graphics/{G_chain}/text/runs

The round-trip write: GET .../text/runs → edit → PUT .../text/runs reproduces the edited state. Body:

{ "text": "optional replacement string",
  "runs": [ { "range": { "location": 0, "length": 5 },
              "attributes": { "color": "#CC0000", "bold": true, … } }, … ] }
  • runs (required) is the array GET .../text/runs returns — each entry a

{ range, attributes }. Every attribute is applied over its run's range, authoritatively (this is a *replace*, not a merge): the string is rebuilt to a uniform base and the runs lay the styling over it in order.

  • text (optional) replaces the string; each run's range must fall within

it (else 422). Omit to keep the current string.

  • Ranges may overlap; later runs win (as with operations).

Success — 200 OK. Body is the <runs> dict (as GET), plus layout for text/contained-text targets, textTarget. Undo: one step, "Text Attributes". Locks: layerLock; 423 + ?force=true.

Errors: 400 (runs missing/not an array; a run without range or attributes; unknown/invalid attribute), 401, 404, 422 (no text target; a run range out of bounds for the string), 423, 500.

backgroundColor is accepted on write as an alias of highlightColor (agents produce both spellings); responses always say highlightColor.

{ "operations": [
  { "all": true,                          "set": { "color": "#1A1A1A" } },
  { "match": "Schematic",                 "set": { "highlightColor": "#FFFF00" } },
  { "match": "urgent", "occurrence": "all", "set": { "color": "#CC0000", "underline": "single" } },
  { "range": { "location": 0, "length": 9 }, "clear": ["highlightColor"] }
] }

Semantics.

  • Whole-box restyle without flattening: { "all": true, "set": { … } }

this is the non-destructive alternative to the Level-1 color override.

  • set uses replace-within-range per attribute: other attributes and other

ranges are untouched.

  • clear removes the attribute (for underline/strikethrough, equivalent

to "none"; clearing color restores default black).

  • Match ranges are resolved against the current string as each operation

runs, in order.

Success — 200 OK. Body is the <runs> dict (as GET .../text/runs) plus the standard layout block (bounds, requiredSize, fits, …) so fit feedback returns in the same call. Styling never resizes the box (fits reports; EazyDraw never silently auto-fits).

Undo. One discrete step, "Text Attributes", regardless of the number of operations.

Locks. layerLock only (no geometry change). 423 + ?force=true.

Errors: 400 (malformed body: operations missing/empty/not an array; an op with no addressing or with neither set nor clear; unknown attribute key; bad hex; bad style string), 401, 404, 422 (no text target; range out of bounds for the current string; match not found), 423 (locked),

  1. The 400/422 split: what is checkable without the document is 400; what

depends on the document's current text is 422.

Annotation targets. When the resolved target is an annotation the same operations apply to the annotation's attributed text (committed through the annotation rebuild path, one undo step); the response carries textTarget: "annotation" and no layout block. This makes annotations first-class styling targets while PATCH .../annotation/text remains the plain-string replacement primitive.

r = ed.get_text_runs(d, l, g)
ed.set_text_attributes(d, l, g, operations=[
    {"match": "Schematic", "set": {"highlightColor": "#FFFF00"}},
])

Annotation endpoints — DKDAnnotation

A DKDAnnotation is not a graphic; it is owned by a DKDBezier (or subclass), so it is addressed as a sub-resource of its host graphic. These endpoints are plain text only — strings are short and may follow a curve, so there is no size/fit contract; the visual result is confirmed via a PNG export render. For styling an annotation's text (colors, highlight, underline, strikethrough, per range), address the *host graphic* with the rich-text runs endpoints above — they resolve to the annotation automatically (textTarget: "annotation").

GET .../graphics/{G_chain}/annotation

{
  "type": "rectangle",
  "typeName": "Rectangle",
  "hasAnnotation": true,
  "text": "Section A",
  "annotationFormat": "Box",      // un-localized DKDAnnotationFormat name
  "annotationShow":   "Yes"       // un-localized DKDAnnotationShow name
}

If the graphic has no annotation (or cannot carry one), hasAnnotation is false and the text/format/show keys are omitted — still 200 OK.

Errors: 400, 401, 404, 500. (No 422: a graphic with no annotation reports hasAnnotation:false rather than erroring.)

PATCH .../graphics/{G_chain}/annotation/text

Body: { "text": "<string>" } (required). Sets the annotation's text, inheriting the existing annotation's run-0 attributes, then rebuilds and redisplays it (touchBarSetAnnotationTextWithUndo:). Requires an existing annotation on the target (template-placed); a graphic without one returns 422 "Graphic has no annotation target".

Success — 200 OK. Body is the <annotation> dict (post-change). Undo: one step, "Set Annotation Text". Locks: layerLock only; 423 + ?force=true.

Errors: 400 (missing UUIDs; text missing/not a string), 401, 404, 422 (no annotation target), 423 (locked), 500.

Export endpoints

Direct GET of a rendered representation. Path grammar:

/v1/drawings/{D}/export/{format}
/v1/drawings/{D}/layers/{L}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/export/{format}
/v1/drawings/{D}/layers/{L}/graphics/{G1}/graphics/{G2}/.../graphics/{Gn}/export/{format}
/v1/libraries/{L}/export/{format}                                            (501)
/v1/libraries/{L}/elements/{E}/export/{format}                               (501)

{format} is one of: native, svg, pdf, png, jpg. Case-insensitive.

Format → on-the-wire mapping

formatContent-TypeFile extensionSource
nativeapplication/json.ezdjsonDKDFileType_EZDJSON — flat (un-optimized) JSON dictionary. Drawings: full document via [doc documentDictionaryForDocumentIncludeLayers:YES outputFileType:DKDFileType_EZDJSON] with serialization temporarily forced to DKDSerialization_Flat. Graphics: [g propertyListRepresentation], the same dict the file format embeds, JSON-encoded.
svgimage/svg+xml.svg[doc svgDataForDocument]. For graphics, the document's SVG path is invoked while the graphic is temporarily the only selected graphic and exportContents is Graphics_Selected — see "Selection mutation" below.
pdfapplication/pdf.pdfDrawings: [doc pdfDataMultiPagination:SinglePagePDFPagination withError:&err]. Graphics: [doc pdfDataWithGraphics:@[g]] (no selection mutation — direct entry point).
pngimage/png.png[expCtrlr exportDataWithType:DKDFileType_PNG] with exportContents set per resource (Drawing_Full for drawing/layer, Graphics_Selected with the target selected for graphic). Resolution is pinned to 144 dpi by default (overridable with ?dpi=, see Resolution). PNG color space, alpha, bits-per-color, and antialias settings come from the document's DKDExport defaults.
jpgimage/jpeg.jpgSame as PNG but DKDFileType_JPG. JPG color space and compression come from DKDExport defaults.

Response headers

  • Content-Type set per the table above.
  • Content-Disposition: attachment; filename="<safe-name>.<ext>" so curl -O, browser saves, and requests's iter_content all get a sensible filename. Filename composition:
    • drawing: <displayName>.<ext>
    • layer: <displayName>-<layerName>.<ext>
    • graphic: <displayName>-<graphicNameOrUUID>.<ext>
  • Path-unsafe characters (/ \ : * ? " < > |) in the filename component are replaced with _.

Limitations and behavior

  • A blank drawing renders. Full-drawing exports (png, jpg, pdf, svg) of a drawing with no visible graphics return the empty page (background per the document's export settings) rather than failing — so a fresh new drawing can be rendered before anything is placed. Graphic- and selection-scoped exports still need content to size themselves from. (DKDExportData previously required at least one graphic on every path; relaxed for ExportContents_Drawing_Full 2026-09-10.)
  • Layer endpoint renders the full drawing. Per the spec note "For Layers and Drawings we use Full Drawing Area", the API uses the existing Drawing_Full export mode at the layer endpoint. The bytes returned for /drawings/{D}/export/{f} and /drawings/{D}/layers/{L}/export/{f} are identical for the same document state. Layer-specific filtering (render only the named layer's graphics on a full-drawing canvas) is a future enhancement and would require temporary layer-state mutation.
  • Selection mutation for graphic raster/SVG. PNG, JPG, and SVG of a single graphic are produced by:
    1. Save current [gView selectedGraphics]
    2. clearSelection, then selectGraphics:@[targetGraphic]
    3. Save current [exp exportContents], set to ExportContents_Graphics_Selected
    4. Call the export
    5. Restore both, in @finally

This runs inside dispatch_sync(main_queue, ...) so it is atomic against other API calls. UI selection updates may be visible briefly to a user watching the document. Native and PDF for graphics use direct entry points and do not mutate selection.

  • Library export is not available. /v1/libraries/{uuid}/export/{format} and /v1/libraries/{uuid}/elements/{uuid}/export/{format} return 501 Not Implemented.
  • Resolution (raster). PNG/JPG renders are pinned to a predictable 144 dpi by default regardless of the document's own export setting (the expandFactor is saved, set to dpi/72, and restored around the export). Override with the query string ?dpi=N, clamped to 36..600 (e.g. …/export/png?dpi=300 for print). Vector formats (svg, pdf, native) ignore it.
  • MCP render / export tools. Three intent-named tools front this so a client doesn't bloat its context with a big image: render(drawing, dpi=96) returns the PNG inline to look at — kept light at 96 dpi for the perception loop (bump for fine detail); export_drawing(fmt, path?, drawing?, dpi=144) and its twin save_render(drawing, path?, dpi=144, fmt) write a file to disk — fmt is pdf | svg | png | jpg | ezdjson (ezdjson = native) — and return only the path ({path, fmt, bytes[, dpi]}), so the bytes never enter the context. path may be a file (extension added if missing) or a folder (auto-named inside it). The MCP server writes the file itself, outside the App Store sandbox, so any client-reachable folder works. With no path, save_render auto-names into a render folder (default ~/EazyDraw-Renders, override with the EAZYDRAW_RENDER_DIR env var — point it at a folder the client's filesystem access can read to view the result).
  • Other export parameters not exposed. Color space, SVG profile, SVG glyph mode, JPG compression, PNG alpha and bits-per-color all use the document's current DKDExport defaults. A future revision will accept further query-string overrides (e.g. ?colorSpace=srgb).

Status codes

  • 200 — bytes returned with the correct Content-Type.
  • 400 Bad Request{format} is not one of the five recognized values, or (for the recursive graphic endpoint) the UUID chain is empty.
  • 404 Not Found — drawing / layer / graphic at the named UUID(s) does not resolve.
  • 500 Internal Server Error — export pipeline returned nil bytes.
  • 501 Not Implemented — library export endpoints.

Layer visibility — PATCH /v1/drawings/{D}/layers/{L} and PATCH /v1/drawings/{D}

EazyDraw layer visibility is two‑dimensional: each layer has a LayerState (On / Off / Active, exactly one active = the destination for new content), and the document has a LayerSelect mode governing which non‑active layers show. Export is WYSIWYG — graphics gate their own drawing on visibleLayer:, so what renders to PDF = the active layer + (ON layers, only if layerSelect permits). With one layer this collapses and never matters. This is the basis for conditional regions: park an optional clause on its own layer and toggle it.

PATCH /v1/drawings/{D}/layers/{L} { "layerState": "on" | "off" | "active" }

  • off hides the layer (drops its content from the render); on shows it;

active makes it the destination (the previous active becomes on).

  • The active layer's on/off can't be changed (it's always shown/editable) —

422 "make another layer active first". Setting an already‑active layer active is a no‑op. 200 OK — the updated <layer> dict.

PATCH /v1/drawings/{D} { "layerSelect": "active-only" | "show-others" | "select-others" | "show-all" | "select-all" }

  • active-only → only the active layer shows; show-others → ON layers visible;

select-others → ON layers visible and editable; show-all / select-all → include OFF layers. Set show-others/select-others so a layer you turn ON actually appears in the render. 200 OK — the updated <drawing> dict.

Both are undoable (self‑registering inverse + screen refresh) and mark the document dirty. Layer state is not gated by layerLock (visibility is meta, not a content edit).

POST /v1/drawings/{D}/layers/{L}/order — restack a layer

Moves a whole layer in the document's front‑to‑back stack — the order the Layers drawer table shows (front layer at the top) and the order layers paint (front on top of the layers beneath it). This restacks the entire layer; POST .../graphics/{G_chain}/order restacks one graphic *within* its layer.

Request body { "to": "front" | "back" | "forward" | "backward" }

  • front → top of the Layers table, drawn on top of every layer below it

(internal index 0).

  • back → bottom of the table (internal index count − 1).
  • forward → one step toward the front; backward → one step toward the back.

Already at the end → clamped (no‑op move, still 200).

Backed by -[DKDDocument moveLayer:toIndex:], which registers its own undo inverse; the handler wraps it in a discrete undo group, refreshes the screen and the Layers drawer popup (dkdAPIRefreshLayerVisibility), and marks the document dirty. Action name "Layer To Front" / "Layer To Back" / "Layer Forward" / "Layer Backward".

> Front/back vs. the internal index. The vocabulary is viewer‑consistent: > *front* = on top = top of the Layers table. Internally that is array index 0, > and the draw loop iterates the layer array in reverse so index 0 paints last > (on top). Callers never see the index — they say front/back/forward/ > backward.

Not gated by layerLock (restacking is document structure, like layer visibility, not a content edit). 200 OK — the updated <layer> dict.

Errors: 400 body missing or to not one of the four words; 404 drawing or layer not found; 500 response build failed.

POST /v1/drawings/{D}/layers — create a layer

Adds a new layer at the front of the stack (top of the Layers table) and, by default, makes it the active layer so the next inserted content lands on it. Mirrors the UI New Layer command (-[DKDLayersDataSource addLayerAction]): the new layer inherits the active layer's dkdScale. Use it to stack content — e.g. create a layer for an incoming PDF backdrop, or the layer a template needs above a PDF for its fill slots.

Request body (optional — send {} or no body for defaults):

{
  "name":   "<string>",   // optional; auto-assigned if omitted, de-duplicated either way
  "active": <Bool>        // optional; default true — make the new layer active
}

Backed by -[DKDDocument insertLayer:atIndex:] (registers its own undo inverse) at index 0, plus dkdAPISetActiveLayer: when active (self‑registering inverse to the prior active); wrapped in a discrete undo group with a screen + Layers‑drawer refresh and dirty mark. Action name "New Layer". The new layer is assigned a layerUUID so it is immediately addressable. Not gated by layerLock.

Success — 201 Created. Body is the new <layer> dict.

Errors: 400 drawing UUID missing, or body present but not a JSON object; 404 drawing not found; 500 response build failed.

PATCH /v1/drawings/{D}/layers/{L} — rename a layer (layerName)

The layer PATCH takes layerState (documented under Layer visibility) and/or layerName; at least one is required, and when both are sent the rename is applied first.

{ "layerName": "<new name>", "uniquify": <Bool> }   // uniquify default false

Layer names are unique within a drawing. The name goes through the same validator the Layers table uses (-[DKDDocument isAcceptableLayerName:postMessage:], silent here): empty, leading white space, illegal or control characters, the -- duplicate marker, and the reserved names are all 400; a name already in use is 409 unless uniquify is true, which appends -2, -3, … ("Notes""Notes-2"). The Paper and Guides layers cannot be renamed (422). Undoable (action name "Rename Layer"); the Layers drawer is refreshed on undo and redo too.

Success — 200 OK. The updated <layer> with the final layerName.

Python: ed.rename_layer(d, l, name="Notes", uniquify=True). MCP: rename_layer(layer, name, uniquify?).

DELETE /v1/drawings/{D}/layers/{L} — delete a layer

Removes the layer and every graphic on it — the Layers table's Delete. Backed by -[DKDDocument removeLayer:], which registers its own inverse (insertLayer:atIndex:), so one Undo brings the layer *and its content* back at the same position; if the deleted layer was active, the layer below (or above, for the top layer) becomes active. Action name "Delete Layer".

Gates: the drawing's only layer and the Paper layer cannot be deleted (422). A layerLock on the layer, or a deleteLock on any graphic on it, is 423 with the usual locks body unless ?force=true.

Success — 200 OK. The pre-delete <layer> dict (graphicsCount tells how much went with it). The UUID no longer resolves afterwards (until an Undo restores it — the same layer object comes back, same UUID).

Python: ed.delete_layer(d, l, force=False). MCP: delete_layer(layer, force?).

Layer configurations — GET .../layer-configurations and apply via PATCH /v1/drawings/{D}

A layer configuration (DKDLayersConfiguration) is a saved, named snapshot of a whole layer arrangement — each layer's on/off state, hide-dimensions, lock, and color modification, plus the layer order and the document's layerSelect mode. Users build them in the Layers drawer ("draft", "final", "client-review", …) to switch a drawing between presentations. These endpoints let an agent list them and apply one, so a user can set up and *see* a configuration with AI help.

GET /v1/drawings/{D}/layer-configurations → the saved configs:

[
  {
    "name": "client-review",
    "layerSelect": "show-others",
    "layers": [
      { "layerName": "Base",     "layerState": "On",  "hideDimensions": false, "layerLock": false },
      { "layerName": "Markup",   "layerState": "Off", "hideDimensions": true,  "layerLock": false }
    ]
  }
]

layerState is the un-localized name (On/Off/Active); layerSelect uses the API vocabulary (show-others, …). The layers order is the configuration's layer order.

PATCH /v1/drawings/{D} { "layerConfiguration": "<name>" } — apply it. This is the HTTP analog of the UI's Load Layer Configuration: it calls -[DKDLayersConfiguration applyToDocument:], setting every layer's state / hide-dimensions / lock / color-mod, reordering the layers, and setting layerSelect. It changes the live document (visible on screen) and is undoable — wrapped in a self-registering helper (dkdAPIApplyLayersConfiguration:) that snapshots the current arrangement as a config, registers the inverse, then applies the new one, so undo and redo both restore the full layout. Action name "Apply Layer Configuration". Render afterward (export png) to *see* the result. 200 OK — the updated <drawing>.

layerSelect and layerConfiguration may be sent together (config applied first); at least one is required. 404 if no saved config has that name. (Saving and deleting configurations is not exposed; create them in EazyDraw's Layers palette.)

<layer> shape

{
  "layerName":      "<DKDLayer.layerName>",
  "layerState":     "<nameForLayerState(state, NO)>",
  "graphicsCount":  <NSUInteger>
}

layerState values are the un-localized names: NotSet, On, Off, Active. graphicsCount is [layer.layerGraphics count].

If a layer has no UUID at the time it is requested, one is assigned via [layer setLayerUUID:] so subsequent calls are stable.

<graphic> shape (entry in /layers/{uuid}/graphics)

{
  "index":            <NSUInteger>,
  "graphicUUID":      "<DKDGraphic.graphicUUID>",
  "type":             "<stable kind: text|image|pdf-image|group|rectangle|oval|line|...>",
  "typeName":         "<localized display name, e.g. Text>",
  "nameGraphic":      "<DKDGraphic.nameGraphic>",
  "hiddenBounds":     { "x": <Number>, "y": <Number>, "width": <Number>, "height": <Number> },
  "graphicsCount":    <NSUInteger>,
  "locks":            { "deleteLock": <Bool>, "moveLock": <Bool>, "sizeLock": <Bool>, "layerLock": <Bool> }
}

Array order in /graphics is [layer layerGraphics] order; this is the layer z-order / draw order. index is the position in that array (0 is drawn first, highest index is drawn last / on top within the layer).

nameGraphic is omitted if the graphic has no name set.

type is the stable, locale-independent API kind — clients and agents match logic on this, never on an internal class name. Semantic families resolve by class ancestry (text for any DKDTextArea, image for any DKDImage, plus pdf-image and group); every other graphic uses its class name kebab-cased with the DKD prefix dropped (DKDOvaloval, DKDRoundedRectanglerounded-rectangle). typeName is the localized human label from +[<class> typeNameForMenu] — the same word EazyDraw shows in its menus (e.g. Text, Image) — for display only; do not match on it. (The raw Obj-C class name is never exposed.)

graphicsCount is only present when the graphic is a DKDGroup (or subclass thereof), and reports [[g groupArray] count] — the number of immediate children in the group. Non-group graphics omit the key entirely. The presence of the key is itself the "this is a group" signal; in Python, g.get("graphicsCount") returns None for leaf graphics and an integer for groups. An empty group reports 0.

locks is always present. Three flags come from the graphic's DKDLock (deleteLock, moveLock, sizeLock); the fourth (layerLock) is the containing layer's lock. Clients can read this dict before attempting a mutation to know whether it will be blocked — see Lock policy below. (sizeLock is the general-purpose size lock that the API checks. DKDLock also carries DKDLine Pin flags — LockFlag_Lock_Angle, LockFlag_Lock_Length, LockFlag_Lock_Center — which are intentionally not exposed or enforced by the API; see Pin is not an API lock under Lock policy.)

If a graphic has no UUID at the time it is requested, one is assigned via [g setGraphicUUID:] so subsequent calls are stable.

Sub-resource bodies

pages/setup, pages/layout, grids, properties, scale, window return the corresponding model object's propertyListRepresentation (or docWindowConfiguratonDictionary for window) verbatim. Keys and value formats match the on-disk drawing file format.

pages also answers on its own: GET /v1/drawings/{uuid}/pages is the derived page-geometry summary (see page geometry above), while pages/setup and pages/layout remain the verbatim model dicts.

/state — full model state (read backstop)

The /state endpoints return an object's complete propertyListRepresentation as JSON — the same dictionary the on-disk file format embeds. They are the backstop: when a structured endpoint doesn't expose a particular attribute, GET the full state and read whatever you need. Keys are EazyDraw on-disk names (PascalCase / mixed), not the camelCase HTTP vocabulary — they are human/AI-readable but not Python-idiomatic.

EndpointBodyLookup
GET /v1/drawings/{D}/layers/{L}/state[layer propertyListRepresentation]404 Layer not found
GET /v1/libraries/{L}/state[lib propertyListRepresentation]404 Library not found
GET /v1/libraries/{L}/elements/{E}/state[element propertyListRepresentation]{E} matched case-insensitively against each DKDLibElement.libraryUUID; 404 Library or element not found

DKDGraphic / DKDGroup / DKDDocument full state is already reachable via GET .../export/native (DKDFileType_EZDJSON), so these three close the remaining gaps (layer, library, library element).

Flat (inline attributes, no hash refs). The dumps resolve attributes inline so a reader never has to chase hash references into an archive store:

  • Layer and element: a bare propertyListRepresentation serializes graphics with a nil archiveStoreRef, so attributes are already inline — no special handling.
  • Library: DKDLib.propertyListRepresentation builds a hash-ref archive store when its serialization is Optimized. The handler therefore saves the lib's serialization, forces DKDSerialization_Flat, builds the dict, and restores — the same flat-forcing export/native uses on documents.

These are GETs: read-only, never blocked by locks, no document mutation.

Binary data → Base64. A library element can carry a button-icon image (DKDImageNSData), and some graphics embed image data. All API JSON responses funnel through one encoder (_dkd_jsonResponseWithObject:): JSON-safe payloads use NSJSONSerialization unchanged, but a dictionary containing NSData (which NSJSONSerialization rejects) falls back to EazyDraw's jsonDataWithDictionary, which Base64-encodes the bytes — the same representation the on-disk .ezdjson and export/native produce. So image bytes come back as Base64 strings, never an error.

PATCH /v1/drawings/{D}/properties — set document properties

> Status: implemented and verified on Version-26 (tested via tools/python/test_properties.py). The API's first non-graphic mutation and the template for read+write on flat (non-hash-optimized) model entities.

Sets document metadata on [doc docProperties]. Merge semantics — only the keys present in the body are changed; omitted keys are left untouched. Companion to GET /v1/drawings/{D}/properties, which returns the same dict shape, so you can read, edit, and send the same dict back.

Request body — any subset of the writable metadata keys. Keys are the verbatim propertyListRepresentation keys (PascalCase, on-disk form), the same vocabulary GET returns. (A clean camelCase external vocabulary with a mapping layer is a known future revision; until then the on-disk keys are the contract.)

JSON keyJSON typeModel setter
TitlePropertystringsetTitleProperty:
AuthorsPropertyarray of stringssetAuthorsProperty:
KeywordsPropertyarray of stringssetKeywordsProperty:
DescriptionPropertystringsetDescriptionProperty:
CommentPropertystringsetCommentProperty:
OrganizationsPropertyarray of stringssetOrganizationsProperty:
CopyrightPropertystringsetCopyrightProperty:
ProjectsPropertyarray of stringssetProjectsProperty:
VersionPropertystringsetVersionProperty:
PATCH /v1/drawings/{D}/properties
{
  "TitleProperty":   "Quarterly Schematic",
  "AuthorsProperty": ["Dave Mattson"],
  "CopyrightProperty": "2026"
}
  • Omit a key to leave it unchanged. Clear a field by sending "" (string fields) or [] (array fields).
  • Array fields must be JSON arrays whose elements are all strings.

Implementation note — do NOT route through loadPropertyListRepresentation:. The handler must apply changes via the individual setters. DKDProperties.loadPropertyListRepresentation: (DKDProperties.m:418) is a full-replace that resets QuickLook settings and the serialization mode to defaults when their keys are absent — a metadata-only PATCH sent through it would silently clobber QuickLookContent / QuickLookFormat and force Serialization to Flat. Setter-based merge avoids this.

Read-only keys — accepted, ignored. The GET response also carries QuickLookContent, QuickLookPage, QuickLookFormat, QuickLookUseSmallerOf_JPG_PDF, Serialization, and uuid. So a client can GET, edit one metadata field, and PATCH the whole dict back. These six keys are accepted and ignored (not an error, so round-trips don't break) but not written. They are behavior switches rather than metadata: QuickLook configuration; the Optimized↔Flat Serialization toggle (the on-disk attribute-optimization store); and uuid (DKDProperties.uuidProperties, the yes/no for whether the document assigns persistent UUIDs to graphics and layers). They are not writable through the API.

Success — 200 OK. Body is the full updated DKDProperties.propertyListRepresentation (same shape as GET), reflecting post-merge state.

Undo / persistence. Mirror the Properties inspector (DKDPropertiesPanel OK path): snapshot the full propertyListRepresentation before the merge, apply via the setters, snapshot it after, then register a symmetric swap on [doc undoManager] with action name "Change Drawing Properties" — undo restores the before-snapshot, redo the after. Registering the undo marks the document dirty (the panel's immediate-apply toggles also call [doc updateChangeCount:NSChangeDone] explicitly; doing both is harmless). The inspector's -swapProperties:oldProperties: lives on the panel and is not reachable headlessly, so the handler needs an equivalent swap target — recommended: a small DKDDocument-level swap method as the prepareWithInvocationTarget: target. That swap applies the full before/after snapshots via loadPropertyListRepresentation: (safe — complete dicts), which is distinct from the partial PATCH apply that uses the individual setters.

Errors:

  • 400 Bad Request — body empty or not a JSON object; a string field given a non-string; an array field given a non-array or an array containing a non-string element; or an unrecognized key (not one of the nine writable keys or the five accepted-and-ignored keys — this guards against PascalCase typos silently no-op'ing).
  • 401 Unauthorized — bearer token missing or wrong.
  • 404 Not Found — no open drawing with that UUID. Body: { "error": "Drawing not found" }.
  • 500 Internal Server Error — response build failed.

No lock checks apply — DKDProperties is document metadata, not a graphic or layer, so there is no 423/force path.

Threading. Lookup, setter application, undo registration, and response build run inside one dispatch_sync(dispatch_get_main_queue(), …) block, like the other mutating handlers.

curl:

curl -i -X PATCH -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"TitleProperty":"Quarterly Schematic","AuthorsProperty":["Dave Mattson"]}' \
  http://localhost:52737/v1/drawings/$D/properties

Python:

props = ed.set_properties(d_uuid, {
    "TitleProperty":   "Quarterly Schematic",
    "AuthorsProperty": ["Dave Mattson"],
})
print(props["TitleProperty"])

Lock policy

Every DKDGraphic carries a DKDLock exposing three boolean flags the API enforces (deleteLock, moveLock, sizeLock), and every DKDLayer carries layerLock. These are deliberate user-set protections — set in the EazyDraw UI to keep a graphic or whole layer from being changed accidentally. The API respects them by default and rejects mutations that would violate a lock, returning 423 Locked with a body that explains which lock is in the way:

HTTP/1.1 423 Locked
Content-Type: application/json

{
  "error": "Graphic has delete-lock. Retry with ?force=true to override.",
  "locks": { "deleteLock": true, "moveLock": false, "sizeLock": false, "layerLock": false }
}

The same locks sub-dict appears in every <graphic> response, so a polite client can read locks before attempting a change and skip the round-trip on known-locked items.

Per-endpoint lock checks:

EndpointChecks
POST /v1/drawings/{D}/layers/{L}/graphicsdestination layer's layerLock
POST /v1/drawings/{D}/layers/{L}/shapesdestination layer's layerLock
PATCH .../graphics/{G_chain}/stylegraphic's layerLock
PATCH .../graphics/{G_chain} (bounds)moveLock (when origin changes), sizeLock (when size changes), graphic's layerLock
DELETE .../graphics/{G_chain}deleteLock, graphic's layerLock
DELETE /v1/drawings/{D}/layers/{L}the layer's layerLock, and deleteLock on any graphic on it
PATCH .../graphics/{G_chain}/lockgraphic's layerLock (the lock endpoint is how deleteLock / moveLock / sizeLock are set)

The PATCH check is granular: a pure translation against a graphic with only sizeLock set is allowed, and a pure resize against a graphic with only moveLock set is allowed. The check fires only when the requested change touches the locked dimension.

Setting locks: GET / PATCH …/graphics/{G_chain}/lock reads and sets the three graphic locks (see its section); layerLock is set in the Layers table only.

Override: every lock-checking endpoint accepts the query parameter ?force=true to bypass. The token is the authorization boundary; force is the explicit "I know what I'm doing" gesture. Use it deliberately in scripts that genuinely need to write through locks (bulk cleanup, migration). The Python client exposes this as a force=False keyword argument:

ed.delete_graphic(d, l, g_uuid)                # 423 if delete-locked
ed.delete_graphic(d, l, g_uuid, force=True)    # always deletes
ed.morph_graphic(d, l, g_uuid, x=10, y=10, width=20, height=20, force=True)
ed.use_library_element(d, l, lib, el, force=True)  # add to a locked layer

GET endpoints are never blocked by locks. Read-only by definition.

Pin is not an API lock. DKDLine (and line-like geometry) supports a UI concept called Pin that constrains interactive endpoint editing along three axes — Angle, Length, and Center — stored on DKDLock as LockFlag_Lock_Angle (0x04), LockFlag_Lock_Length (0x08), and LockFlag_Lock_Center (0x10). These are deliberately not part of API lock enforcement: pinning a line's angle does not make a rotating morph return 423, and pinning its center does not block a move. Pin is an *interactive editing aid* — it shapes how handle drags behave in the EazyDraw UI — not a formal "protect from change" lock like moveLock / sizeLock / deleteLock / layerLock. The API treats Pin as out of scope by design; a script that must respect a line's pin should read it from the graphic's /state and honor it client-side. (Mapping Pin onto API semantics — e.g. should pinned-length veto a non-uniform scale? — is intentionally not enforced by the API; it has no clean, unsurprising answer.)

Status codes

  • 200 — found and serialized (GETs); or, for POST /v1/drawings, the file was already open.
  • 201 CreatedPOST /v1/drawings opened a new window for a file that wasn't already open.
  • 400 Bad Request — for the recursive group-children endpoint, the UUID chain was empty. For POST /v1/drawings and POST /v1/libraries, the body was missing/malformed, the path field was missing/empty, or path was not absolute/tilde-prefixed. For DELETE /v1/drawings/{uuid} and DELETE /v1/libraries/{uuid}, missing UUID in the path (defensive — the regex normally guards this).
  • 401 Unauthorized — missing Authorization header or token doesn't match the configured value. Response includes WWW-Authenticate: Bearer realm="EazyDraw". Message: Missing or invalid bearer token.
  • 404 — no open drawing with that UUID, no layer with the given UUID inside the drawing, no library with that UUID in either _libMenus or open palette windows, or (for the recursive group-children endpoint) some UUID along the chain did not resolve / pointed at a non-group graphic. For POST /v1/drawings and POST /v1/libraries, the supplied path did not exist on disk. Body is the server's default error HTML with message Drawing not found, Layer not found, Library not found, or Group not found, or graphic is not a group; for POST endpoints the body is JSON { "error": "...", "path": "..." }.
  • 422 Unprocessable EntityPOST /v1/drawings or POST /v1/libraries: file exists but NSDocumentController could not open it, or the opened object was not the expected document subclass. JSON body carries the NSError's localizedDescription. For POST /v1/drawings/{D}/layers/{L}/graphics: library element is arrange-tool or attribute-action, or the use action otherwise produced no graphic. For PATCH /v1/drawings/{D}/layers/{L}/graphics/{G_chain}: graphic does not accept scaling and has no conversion class, source bounds are non-positive, or a nested graphic would require a class swap (top-level graphics only).
  • 423 LockedPOST /v1/drawings/{D}/layers/{L}/graphics, PATCH .../graphics/{G_chain}, or DELETE .../graphics/{G_chain}: target graphic or its layer is locked against the requested mutation. JSON body: { "error": "...", "locks": { ... } }. Retry with ?force=true to override. See Lock policy above.
  • 500 — the response encoder (_dkd_jsonResponseWithObject:) could not produce JSON. Note NSData (e.g. image bytes) is not a 500 — it is Base64-encoded via EazyDraw's jsonDataWithDictionary; a 500 here means even that fallback failed. Message: Failed to serialize JSON response. For POST /v1/drawings / POST /v1/libraries, also: open completion timed out (30s) or response build failed; JSON body.

Threading

Request handlers run on a background GCD queue. All AppKit / model access is funnelled through dispatch_sync(dispatch_get_main_queue(), …) inside the shared helper _dkd_extractFromDocumentUUID:extractor:, so extractor blocks always run on the main thread and the response is serialized off-main only after the dictionary is built.

Naming conventions (authoritative)

These rules apply to all endpoints and JSON keys. Deviations are legacy compatibility, not patterns to copy.

URL paths

  • All lowercase. No PascalCase or camelCase in path segments. Example: /v1/drawings/{uuid}/layers/{uuid}, never /Layers/.
  • kebab-case for compound words. Multi-word path segments are joined with hyphens. Example: /color-modification, not /colorModification or /color_modification.
  • Plural collection nouns; UUID for the singular item. /drawings (collection) ↔ /drawings/{uuid} (one). /layers/layers/{uuid}. The collection segment never appears in the singular form.
  • Sub-resources hang off the singular item. /v1/drawings/{uuid}/layers/{uuid}/scale, never /v1/drawings/{uuid}/scale-of-layer/{uuid}.
  • Versioning prefix /v1 for all resource endpoints. /status is intentionally unversioned (liveness probe); if version/build semantics ever change, add /v1/status alongside rather than breaking /status.

JSON response keys

  • camelCase for all new keys: layerName, layerState, graphicsCount, graphicUUID, nameGraphic, hiddenBounds, typeName, displayName (when newly minted).
  • Keys match the EazyDraw model property names when the key reflects a model attribute: graphicUUID, hiddenBounds, layerName.
  • Single-word keys are lowercase: index, type, status, version, build, drawings, layers, graphics, x, y, width, height.
  • Legacy keys retained for stability (do not propagate the style — these predate the convention):
    • "uuid" (lowercase) — used at every level for the resource's own UUID
    • "DisplayName" (PascalCase) — on the <drawing> and <library> dicts
    • "DocumentFileName" (PascalCase) — the file path, on the <drawing> and <library> dicts

New keys at any level must be camelCase even when they sit alongside these.

Value formats

  • Bounds and rects in JSON use the sub-dictionary form { "x", "y", "width", "height" } with NSNumber doubles. This is the API-facing format. The on-disk propertyListRepresentation may continue to use svgStringWithRectangle strings — that is a serialization detail, not the HTTP contract.
  • Layer-state strings are the un-localized names: NotSet, On, Off, Active. Never localized in API responses.
  • Graphic type is a stable, locale-independent kebab-case kind (text, centered-text, image, pdf-image, group, oval, …) — match logic on it. typeName is the localized human label (the EazyDraw menu word) and is display-only. The raw Obj-C class name is never exposed in API responses. centered-text (added 2026-07-11) is the contained-text form that re-centers with its host; plain text boxes keep their value — the addition is non-breaking.
  • UUIDs are uppercase hex with dashes, as produced by [NSUUID UUIDString]. Matching against client-supplied UUIDs is case-insensitive.
  • Numbers: counts and indices are plain JSON numbers (NSNumber unsignedInteger); coordinate/dimension values are doubles.

Not exposed by the API

Capabilities of EazyDraw that the API does not cover in this release. Each returns a clear error (usually 422 or 501) rather than a partial result.

  • Structured text import — the text endpoints work on whole boxes plus the run-level endpoints (/text-runs, set_text_attributes). There is no Markdown or HTML import.
  • Text measure without commit — the PATCH .../text response returns requiredSize/fits, which covers the fit loop; there is no measure-only call.
  • Annotation styling — the /annotation/text endpoint is plain-text only (no uniform overrides); size/fit is not a contract (text may follow a curve). Range styling of annotation text is available via the rich-text runs endpoints, which resolve the host graphic to its annotation (2026-07-10).
  • Rotation about a custom pivotPATCH .../graphics/{chain} rotation is about each graphic's geometric center. A caller-supplied pivot is not available.
  • Class swap for nested graphics during morph (only top-level class swap is supported, for both scale and rotation; a nested graphic that would need a class change returns 422)
  • Attribute-action transfer onto a nested (grouped) graphic — POST .../graphics/{uuid}/attributes is implemented for top-level layer graphics; a nested target returns 422 (like the morph nested class-swap limitation)
  • Per-graphic resources addressable by graphicUUID (only the array form is exposed)
  • iOS / visionOS — the API is macOS only.
  • Library element export — returns 501.
  • Layer-specific filtered export — the layer export endpoint returns the full drawing render.
  • Detailed export parameters (DPI, color space, SVG profile/glyphs, JPG compression, PNG alpha/bits-per-color) — exports use the document's current export settings; there are no query-string overrides
  • Document properties writePATCH /v1/drawings/{uuid}/properties covers the nine metadata fields only
  • QuickLook settings, the Serialization (Optimized↔Flat) toggle, and the uuid (persistent-UUID yes/no) toggle write — accepted and ignored by the properties PATCH
  • camelCase JSON key mapping layer — properties (and the other verbatim-plist sub-resources) currently use on-disk PascalCase keys; the API does not remap them