Files
OneCAD-Draft/docs/automation/README.md
T
Karim Jerbi 7b68d274ad feat(mcp): add batch summaries with read-only detail retrieval
Add opt-in response_detail=summary with step and change counts, compact state, warnings, and actionable errors or prompts. Return an ocs_read batch_result request for full cached details without resuming mutations.

Keep compact/full behavior and idempotent batch retries intact. Preserve active-step context and report unavailable cached results explicitly. Document cache scope and retention; update schemas and digest.

Validation: 47 MCP unit tests, live stdio smoke test, rustfmt, and diff checks passed.
2026-10-12 09:39:54 +01:00

51 KiB
Raw Blame History

OpenCADStudio MCP control

Client developers start here: API-SPEC.md is the consolidated, transport-neutral API specification — conventions, envelope, error codes and the full operation catalogue in one document. This README remains the operator-facing guide (setup, smoke tests, walkthroughs).

Every native OpenCADStudio build contains the same MCP server as the editor. OpenCADStudio --mcp starts it over stdio, opens the desktop editor when needed, and exposes the live document without Python, a package manager, a sidecar service, or client-specific code.MCP lets an AI client inspect the open drawing, execute editor commands, and verify the result through a shared protocol. Install OpenCADStudio, then add a local MCP server in the client and set its command to:

OpenCADStudio --mcp

The client must start that command over standard input/output. If it asks for the executable and arguments separately, select the installed OpenCADStudio executable and enter --mcp as its only argument. The exact registration screen or configuration location belongs to the client. Once connected, the four tools below should appear in the client's MCP tool list.

The server provides four tools:

  • ocs_sessions finds running editor sessions and opens OpenCADStudio when none exists.
  • ocs_read discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status.
  • ocs_execute performs one operation, an atomic record update, or a sequential batch against the real editor.
  • ocs_capture returns a bounded PNG of the drawing viewport or complete window.

Stale sessions clean themselves up

Dead editor sessions used to leave descriptor files behind that slowed down session discovery. Discovery now skips processes that are gone (and removes their leftover files), and a bridge started from an older build exits on its own once it detects a rebuild so the client starts a fresh one. No action is needed on your side; if a client reports version errors after an upgrade, reconnecting its MCP servers picks up the new build.

Run python docs/automation/mcp_acceptance.py target/debug/OpenCADStudio.exe for a repeatable end-to-end acceptance. It creates visible geometry through MCP, audits and verified-saves DWG 2000/2013/2018 plus DXF 2000, and records a viewport PNG and JSON report below target/acceptance/.

On a Windows workstation with a reference CAD application's command-line console, run docs/automation/reference_acceptance.ps1 -Directory target/acceptance/TIMESTAMP -ConsolePath <console.exe>. It opens the three generated DWGs read-only in isolated console profiles, runs AUDIT without repairs, and keeps one log per version. A console that stays alive after QUIT is terminated after the timeout (only the process the runner launched), and ForcedTermination is recorded separately from the audit result.

Native builds also accept a second, headless entry point (OpenCADStudio --serve, one JSON request per line) for scripts and CI; the two native channels, their startup dialogs and their limits are described in native.md.

The normal flow is to call ocs_sessions, pass its returned session_id as ocs_session_id to the other tools, read the chosen session with ocs_read, then pass the returned document state into ocs_execute. For example, an undo request has this shape:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "undo",
    "request_id": "undo-1",
    "document_id": 1,
    "revision": 12
  }
}

Call ocs_read with op: "capabilities" before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The records.collections list gives every available database collection, its record count, and whether it can be edited.

Before delivery, call ocs_read with op: "audit" and the intended target_format / target_version. The report includes entity manifests, layers, blocks, finite bounds, duplicate entity handles, DXF dangling handle references, source hash, and dropped_on_save. A clean in-memory document can still be lossy for a different format, so target selection is part of the audit:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "audit",
  "parameters": {"target_format": "dwg", "target_version": "2000"}
}

Use save_verified for the delivery write. It requires an explicit absolute path, refuses overwrite and lossy conversion unless separately acknowledged, writes the requested version, reopens it, checks the actual version, compares the semantic entity manifest, audits raw DXF handle references, and returns a SHA-256 hash. A verification failure preserves the output for diagnosis and reports it as failed instead of silently declaring success.

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "save_verified",
    "request_id": "deliver-2026-09-22-1",
    "path": "C:\\drawings\\issued\\plan.dwg",
    "target_format": "dwg",
    "target_version": "2000",
    "overwrite": false
  }
}

Call ocs_read with op: "record_schema" before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both collection and type returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "record_schema",
  "parameters": {
    "collection": "entities",
    "type": "Insert"
  }
}

Sequence fields include an item_path_template such as /attributes/{index}. Replace {index} with an index returned by records before using the path in set_properties. When a matching record exists, runtime_schema adds its exact JSON shape and bounded scalar examples. The embedded registry is generated from all 48 entity variants, all 36 non-graphical object variants, symbol-table records, header data, summary data, and the remaining stored record roots at build time.

op: "records" returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting collection returns the same collection manifest; use collection: "all" for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and properties. Entity records also include their display and file record type names.

Property filters and projections use RFC 6901 JSON Pointer paths relative to properties. Multiple where filters are combined with AND. Supported operators are eq, ne, lt, lte, gt, gte, contains, starts_with, ends_with, in, exists, and not_exists:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "records",
  "parameters": {
    "collection": "entities",
    "type": "Insert",
    "where": [
      {"path": "/common/layer", "value": "Equipment"},
      {"path": "/attributes/0/tag", "value": "COMPANY"}
    ],
    "paths": ["/block_name", "/insert_point", "/attributes"]
  }
}

Use set_properties to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references.

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "set_properties",
    "request_id": "attributes-1",
    "document_id": 1,
    "revision": 12,
    "collection": "entities",
    "handle": "2A",
    "updates": [
      {"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"},
      {"path": "/insert_point/z", "value": 3.0}
    ]
  }
}

The same operation edits entries in objects, every symbol table, header, and summary_info. Collections marked mutable: false are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently.

To discover command syntax without reading application source, call ocs_read with op: "commands". The unfiltered response lists commands and actions. Add parameters: {"name": "PLINE"} for a command's batch examples and interactive guidance.

A complete command can be sent with run. Prompt answers are separated by spaces, points use x,y or x,y,z, and option answers use their displayed token:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "request": {
    "op": "run",
    "request_id": "polyline-1",
    "document_id": 1,
    "revision": 12,
    "cmd": "PLINE 0,0 10,0 10,10 C"
  }
}

Use batch when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. completed_steps and next_step show exactly what committed:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "response_detail": "changed_entities",
  "request": {
    "op": "batch",
    "request_id": "shape-1",
    "steps": [
      {"op": "run", "cmd": "LINE 0,0 10,0"},
      {"op": "run", "cmd": "CIRCLE 5,5 2"}
    ]
  }
}

Execute responses use response_detail: "compact" by default and return only the state needed for the next edit. Use changed_entities to receive the current geometry of affected handles in the same response, or full when the complete editor state is needed.

For large batches, use response_detail: "summary". It omits the full step results and change list, returning step counts, failed_steps, change_count, compact final state and a details tool request. change_count counts change records, not unique entities. Warnings, failed or waiting-input steps in attention, and any active_step are retained so the next action remains clear. For non-batch operations, summary behaves like compact.

To inspect the retained full result, call the tool and arguments in details: ocs_read with the same ocs_session_id, op: "batch_result", and parameters: {"request_id": "<original batch request_id>"}. This reads a cached snapshot without executing or resuming any step. Results are retained in the originating MCP connection, for up to 64 batches per session; after eviction or reconnection the read fails explicitly. Do not replay a mutation merely to retrieve expired details. A running snapshot is not completion; resume work through the existing batch retry/task workflow.

ocs_read query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. detail: "full" adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:

{
  "ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
  "op": "query",
  "parameters": {"intersections": ["2A", "31"]}
}

For unfamiliar or conditional commands, use start, then inspect state.command in every response. Its accepts array gives the valid MCP input kinds, options gives the current tokens, and input_example gives the next request shape. Add the current state fields and a new request_id to each step.

Every ocs_execute request requires a caller-generated request_id. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report waiting_input while more input is required and asynchronous work remains running until its real callback finishes.

A run reply also carries two fields that describe what happened to the line:

  • blocked_by — null when the command finished, otherwise what this line left open and waiting: "command" (an interactive command), "text_editor" (the in-place editor opened by the TEXT content step), "mtext_editor", or "modal:<Kind>" for a dialog. A surface that was already open when the line started (an earlier line's editor, another tab, a startup dialog) is not attributed to this line, so a caller polling for completed is never stuck waiting on someone else's editor. blocked_by is set whenever status is waiting_input.
  • unconsumed — the tokens of the line that no prompt ever asked for, in order. A fully consumed line reports []; CIRCLE 0,0 5 9 reports ["9"] because the radius step ends the command. Over-typed input is reported instead of being dropped on the floor.

When a line ends at the TEXT content step (TEXT 0,0 5 0 hello) the text is typed into the in-place editor and committed, so the line behaves exactly as the same input typed into the GUI command line would: one Text entity, nothing unconsumed, and the command closed rather than left armed for a second line. Geometry stays in OpenCADStudio and its geometry kernel.

ocs_capture defaults to the drawing viewport and a longest edge of 1600 pixels. Set scope to window for the full interface or change max_dimension between 256 and 4096.

With diff: true, an unchanged comparison returns metadata only, including diff.changed: false, the hash and resource URI; it does not resend image or resource content blocks, regardless of delivery. The initial baseline (also after reset_diff_baseline: true) still uses the requested image delivery. Changed captures retain the requested diff mode, including cropped patches. Use diff: false or read the returned resource URI to retrieve the full image when an unchanged comparison omitted it.

Clients using MCP 2026-07-28 can advertise io.modelcontextprotocol/tasks. OCS then returns a standard task handle when an operation is still running and accepts tasks/get, tasks/update, and tasks/cancel. Other clients continue to receive the existing operation status and can read it with ocs_read.

The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface:

cargo build
python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio

The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes:

python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio

The web build is driven through two JavaScript functions instead of a process; see web.md.

Headless automation server (--serve)

Application clients that do not speak MCP spawn OpenCADStudio --serve and exchange one JSON object per line over stdin/stdout (--port N switches to 127.0.0.1:<N>, one client at a time). The greeting line carries the build version and the session identity:

{"ok":true,"ready":true,"version":"2026.38","session_id":"…"}

Legacy ops (new, open, run, entities, query, records, layers, header, save, …) act on the active document and need no envelope. Mutation ops of the shared control protocol — wblock, plot, embed_image, set_properties, undo, … — are addressed with an envelope: "protocol":1, a caller-generated request_id (≤128 bytes), and the document_id from {"protocol":1,"op":"state"}. An absent session_id is accepted; only a wrong one is refused. Settled responses carry {ok, status, request_id, result, changes, state}; status stays accepted/running for asynchronous work, polled with {"protocol":1,"op":"operation","request_id":…}.

wblock — export entities or a block to a new DWG/DXF

Either a handle list or a block name, never both. The open document is not modified; the output format follows the path extension (.dwg or .dxf).

{"protocol":1,"op":"wblock","request_id":"clone-1","document_id":1,"path":"C:/out/page-01.dwg","handles":["2A","31"]}
{"protocol":1,"op":"wblock","request_id":"clone-2","document_id":1,"path":"C:/out/cover.dwg","block":"A_CPT"}

The result reports {"path":…, "entities":<count>}. Referenced layer definitions travel with the entities; block definitions export flattened into model space.

plot — render to PDF

Plots the active document without any dialog. layout selects "Model" (default), one layout name, or "all". area is "extents" (default), "display", "limits", "window" (needs window:[x0,y0,x1,y1]), or "layout" (the laid-out sheet, layouts only). paper names a catalog sheet, orientation is "Portrait"/"Landscape", sizing is "fit":true (default) or "scale":"1:100", plus center, offset_x/offset_y, upside_down, plot_style (CTB path or discovered name) and the output toggles transparency, lineweights, merge_lines, stamp:

{"protocol":1,"op":"plot","request_id":"plot-1","document_id":1,"path":"C:/out/pages.pdf","layout":"all","paper":"ISO_A4_(210.00_x_297.00_MM)","fit":true,"center":true}

Explicitly supplied fields override a layout's stored page setup; unspecified fields keep it. The result reports {"path":…, "pages":<n>, "page_sizes":[[w,h],…]} in millimetres.

embed_image — attach a picture

Places the picture at at:[x,y] with width in drawing units (default ≈ pixels/100). The default embeds the raster into the drawing as a self-contained OLE2FRAME ("kind":"Ole2Frame"). "linked":true stores a RasterImage + ImageDefinition referencing the file path instead ("kind":"RasterImage") — the image file must then travel with the drawing.

Embedded vs linked — when images can "break"

The embedded default carries the image bytes inside the DWG. There is no path to lose, so the classic broken-image failure (PNG moved, renamed or deleted after placing) cannot happen: the drawing renders the same on any machine, and embedded images travel normally through block_define, wblock and entities_copy_to like any other entity. Barcode/QR placement (the SPM flow: attach → wrap into a block → plot → clone) should stay on this default. Two deliberate trade-offs: the DWG grows by the image's file size, and the picture never updates when the source PNG changes — which is exactly what self-contained means.

"linked":true keeps the path and therefore keeps the failure mode. It exists for images that must be swappable without touching the drawing. Legacy SPM.ACAD files arrive with linked QR images, so a broken one is still possible there; repair is a three-step composition, no dedicated op needed:

  1. Find it: query with type:"RasterImage" (or the records op) — detail:"full" exposes the definition path and the entity bounds.
  2. Erase the broken reference by handle (entities_delete).
  3. Re-place a fresh embed_image (embedded, so it cannot break again) at the same at/width read from the old bounds.

An image inside a block definition is reached the same way after opening the block's content — SPM's FindAndChangeRasterImagePath (Catalog §5.3) maps onto these three steps rather than a native op.

Text content and bounds

query entities of type TEXT/MTEXT return the raw stored string in value plus a formatting-free rendering in text (MTEXT inline codes such as \A1; or \P are resolved; %%d-style TEXT codes become their glyphs). With detail:"full", degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so bounds-based region filters stay usable.

Text operations — search, audit and replace

Three dedicated text operations go beyond query for bulk text work:

text_search (read) scans all Text, MText, and Insert attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set.

{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true}

text_audit (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call:

Parameter Effect
pairs [{"find":"X","replace":"Y"}, …] — simulates a batch replacement and reports what would change, without touching the document
check_terms ["CIRTUITS","NON TRAITE"] — flags entities that contain suspect strings
dictionary ["EAU","POMPE","VANNE"] — any word ≥ 3 letters not found in this list is reported as unrecognised
system_spellcheck true to use the OS native spell-checker alongside or instead of the agent dictionary
language BCP-47 tag for the system speller, e.g. "en-US" or "fr-FR" (defaults to the OS default)
suggest true (default) to include spelling suggestions for unrecognised words

The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled.

System spell-check backends:

Platform Backend Notes
Windows 8.1+ Win32 ISpellChecker COM API Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default.
macOS NSSpellChecker Uses the system language or the language parameter
Linux Hunspell .dic files or hunspell CLI Reads /usr/share/hunspell/ dictionaries; falls back to pipe mode

Example combining an agent dictionary with system spell-check:

{
  "op": "text_audit",
  "dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"],
  "system_spellcheck": true,
  "language": "fr-FR",
  "suggest": true
}

The response includes a system_speller object with enabled, available, backend, and language fields, plus an unrecognized_words array with each word's handle, position, and optional suggestions.

text_replace (execute) performs the actual batch replacement with single-transaction undo. Add "dry_run": true to preview the result without mutating the document or pushing an undo entry.

Smoke test

The stdio path is covered black-box by a Python script that draws two entities, exports them with wblock, plots them with plot, and checks the capability advertisement:

python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio

Capability areas

The automation surface is organised around the capability areas a CAD automation client needs — application and documents, database objects, the editor, events and plotting.

REST API (--http) — for ordinary HTTP clients

OpenCADStudio --http 8090 runs the same headless session behind a resource-oriented REST surface on http://127.0.0.1:<port>/api/v1. Any HTTP client works — curl, Python, C# HttpClient, JS fetch; there is no SDK and nothing AI-specific. The server keeps one drawing session alive across requests, binds loopback only, and serves its machine-readable description at GET /api/v1/openapi (OpenAPI 3, embedded from src/rest_openapi.json).

Every request carries Authorization: Bearer <token>; anything else is answered 401 {"code":"unauthorized"}. Loopback is shared by every process and user on the machine, so the port alone proves nothing. Set the token yourself with OCS_API_TOKEN before launching, or read the random one the server prints on stderr at startup. No CORS headers are sent, so a web page cannot drive or read the session. The same token guards --serve --port (as a "token" field in each JSON line) and the GUI-hosted channel below.

export OCS_API_TOKEN=$(openssl rand -hex 32)
OpenCADStudio --http 8090            # start the REST server (headless)
curl -H "Authorization: Bearer $OCS_API_TOKEN" http://127.0.0.1:8090/api/v1/ready

GUI-hosted REST channel (--http + file)

OpenCADStudio.exe "<file>" --http 8090 boots the normal editor and hosts a loopback REST channel on this very process, bound to http://127.0.0.1:8090/api/v1: the full REST API aimed at the live session — control the drawing (create, move, plot, query, records, …) while the person keeps working, plus two person-in-the-loop picks only this process can answer. Routes resolve through the same table as the headless server, with the same ergonomics.

GET /api/v1/state caches the active document_id and later mutations address the drawing without the caller repeating it; a stale cache (the person switched documents) costs one state refresh and a retry, not an error. session_id is not needed (the channel has no descriptor handshake; a guessed value is stripped). Unknown POST paths are treated as automation ops and forwarded — MCP-style, any op works; truly unrouted paths answer 404 {"code":"unknown_route"}.

On top of the shared surface, two person-in-the-loop picks exist only on this channel — they need the GUI and the person at the screen:

Call Purpose
POST /api/v1/getpoint Ask the person to pick one point; the connection stays open until they click (or Esc).
POST /api/v1/user_select Ask the person to pick a sample set; the connection stays open until they press Enter (or Esc).

Requests run one thread per connection, so a parked getpoint/user_select never blocks other calls; a pick may park up to 30 minutes before the bridge answers response_timeout.

getpoint — {"prompt"} is optional (shown on the command line):

{"ok":true,"status":"completed","result":{"point":[125.5,64.25,0.0]}}

The next left-click in the drawing answers with the same snapped world point a command would receive; Escape (or {"op":"cancel"}) answers {"ok":true,"status":"cancelled","result":{"cancelled":true}}. While the pick is pending, other mutations wait (busy), so status:"running" on a poll means the person has not clicked yet.

user_select — interactive selection sampling: the person picks entities with the normal gestures, Enter confirms, Escape cancels. The request body is the user_select op verbatim (type/layer filter what counts as picked, prompt replaces the command-line hint, detail is summary/geometry/ full, clear starts a fresh selection — all optional):

POST /api/v1/user_select
{"request_id":"sample-1","type":"LINE","prompt":"Chọn đối tượng mẫu","detail":"full","clear":true}

The connection stays open while the person picks and answers with the same payload the MCP/native callers see:

{"ok":true,"status":"completed","result":{"cancelled":false,"count":1,"ignored":0,
 "handles":["2A"],"entities":[{"handle":"2A","type":"Line","layer":"0","start":[0,0,0],"end":[10,10,0],…}]}}

Escape (or {"op":"cancel"}) answers status:"cancelled" with {"cancelled":true,"count":0}. Picks outside the type/layer filter are deselected and reported in ignored.

get_selection response — handle/type/layer/bounds match the query output exactly so clients share one parser; text is the MTEXT-stripped string and value the raw one (both null for non-text entities), and block/position identify an INSERT's block definition and insertion point (null otherwise):

{"ok":true,"status":"completed","result":{"count":2,"entities":[
  {"handle":"2A","type":"LwPolyline","layer":"CUT","bounds":{"min":[0,0,0],"max":[100,50,0]},"text":null,"value":null,"block":null,"position":null},
  {"handle":"31","type":"Block Reference","layer":"TITLE","bounds":{"min":[0,0,0],"max":[420,297,0]},
   "text":null,"value":null,"block":"A3","position":[0,0,0]}]}}

The read is passive — it never changes the selection, and the person can keep working while the client polls.

Endpoints

Method & path Purpose
GET /api/v1/ready Version, session id, active document id.
GET /api/v1/state Full editor state (documents, selection, command, camera).
GET /api/v1/capabilities Feature/collection advertisement incl. operations.
GET /api/v1/openapi OpenAPI 3 description of this whole surface.
GET /api/v1/documents · POST /api/v1/documents List; open {"path":…}, start from a template {"template":…} (a .dwt/.dwg file; the active drawing is replaced), or Add() a fresh untitled document (empty body) → 201.
DELETE /api/v1/documents/{id}?discard=true Close a document; discard=true drops unsaved changes, otherwise a dirty document is refused (document_dirty).
GET /api/v1/entities Query — type, layer, detail, offset, limit, fields, handles, bounds, near, contains_point, intersections (comma-separated) and where (a JSON-encoded property filter array, e.g. [{"path":"/radius","op":"gt","value":2}]).
GET /api/v1/entities/{handle} One entity, detail:"full".
POST /api/v1/entities Create a typed batch → 201 with handles.
DELETE /api/v1/entities?handles=a,b Erase by handle (body {"handles":[…]} also accepted).
POST /api/v1/entities/transform move/copy/rotate/scale/mirror/array.
POST /api/v1/entities/copy-to Copy entities into another open document: {"handles":[…],"document_id":target} → 201 with the new handles.
GET /api/v1/entities/{h}/xdata?app= · PUT …/xdata/{app} · DELETE …/xdata/{app} Read / replace / remove extended data.
POST /api/v1/blocks Define a block from entities (+ Insert); "replace":true drops a same-named definition first.
DELETE /api/v1/blocks/{name} Delete a block definition with its children, markers and every Insert referencing it.
POST /api/v1/file-identity Stable per-document GUID ({"renew":true} mints a fresh one).
POST /api/v1/groups Create a named group from handles → 201.
POST /api/v1/selection-sets · GET /api/v1/selection-sets/{name} Save a named selection set → 201; recall it (?select=true also makes it the current selection).
POST /api/v1/get_selection Read-only snapshot of the current selection (see the GUI-hosted channel below).
GET /api/v1/sysvars?names=a,b · POST /api/v1/sysvars Read sysvars ({"get":[…]} in the body also works) / set them ({"set":{"ltscale":2.5}}).
POST /api/v1/layouts Create a layout with default page setup → 201.
PUT /api/v1/layouts/{name}/page-setup Write a layout's plot configuration (paper, orientation, fit/scale, center, window, plot style).
POST /api/v1/plot · /api/v1/wblock · /api/v1/images PDF export ("layout":"all","per_page":true writes one PDF per layout) · DWG/DXF export ("template" bases the new database on a file, "normalize":true shifts the export to the origin) · attach picture.
POST /api/v1/commands Run one command line ({"cmd":"LINE 0,0 10,10"}).
POST /api/v1/undo · /redo · /save Lifecycle (/save to a *.dwt path writes the template — DWG bytes).
GET /api/v1/layers · /header · /records?collection=… Database reads.
POST /api/v1/{op} Generic passthrough for any automation op.

Status codes: 200/201 on success; 400 validation; 404 unknown route or handle (code:"entity_absent"); 409 stale state or GUI required; 503 busy. Every failure body keeps the symbolic code and message, so clients can branch on either. The server-generated request_id and the retry-on-stale-state dance happen inside the REST layer — callers just do HTTP.

Walkthrough with curl

# 1. Create entities (the missing layer "FRAME" is created automatically;
#    the whole batch commits as one undoable step or not at all).
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities \
  -H "Content-Type: application/json" \
  -d '{"entities":[
        {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"},
        {"type":"LwPolyline","vertices":[[0,0],[100,0],[100,60],[0,60]],"closed":true},
        {"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0},
        {"type":"Circle","center":[80,30],"radius":5}
      ]}'
# → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}}

# 2. Verify: query it back.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full"
# → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…]

# 3. Move the circle 10 units right.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \
  -H "Content-Type: application/json" \
  -d '{"handles":["66"],"action":"move","vector":[10,0]}'

# 4. Mark the text with extended data (RegApp "SPM" registered implicitly).
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \
  -H "Content-Type: application/json" \
  -d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM"

# 5. Turn the two frame lines into a block definition + Insert.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \
  -H "Content-Type: application/json" \
  -d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}'
# → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}}

# 6. Save and prove persistence.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
  -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}'

# 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators).
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D"

# 8. Set and read back drawing sysvars.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \
  -H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext"

# 9. Provision a sheet: create the layout, write its page setup, then
#    plot every layout to its own PDF (one file per entry in result.files).
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \
  -H "Content-Type: application/json" -d '{"name":"PLAN"}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \
  -H "Content-Type: application/json" \
  -d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \
  -H "Content-Type: application/json" \
  -d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}'

# 10. Save the drawing as a template (a .dwt is DWG bytes — same writer,
#     no lock held on the file) and start a fresh drawing from it.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \
  -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents \
  -H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}'

# 11. Second document, then copy entities across documents.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}'   # a fresh document
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \
  -H "Content-Type: application/json" \
  -d '{"handles":["63","67"],"document_id":2}'

# 12. Group the copies, save them as a named selection set, recall it.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \
  -H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/selection-sets \
  -H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}'
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true"

# 13. Close a document, discarding unsaved changes.
curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true"

The same lifecycle — create → verify → transform → xdata → block → save → reopen → verify persisted → erase → undo, then where filters, sysvars, layout + page setup + per-page plotting, a .dwt template → new-from-template round trip, a cross-document copy, groups and selection sets, and a close-with-discard — runs as an automated black-box check:

python3 docs/automation/rest_smoke.py target/debug/OpenCADStudio

Entity operations reference (protocol ops)

These are the underlying operations the REST routes call; every native transport (stdio --serve, TCP, MCP, wasm) exposes them under the same names and semantics. Angles are degrees on the wire; points accept [x,y] (z=0) or [x,y,z]; handles are hexadecimal strings.

entities_create — add entities

Batch of typed definitions, validated in full before anything commits; one undo step; missing layers are created and reported in layers_created.

type fields
Line start, end, optional thickness
Circle center, radius (≥ 0)
Arc center, radius (> 0), start_angle_deg, end_angle_deg
Spline control_points (more than degree) or fit_points (≥ 2), optional degree (1–10, default 3), knots (control points + degree + 1, non-decreasing), weights (one positive value per control point), closed, periodic, rational, planar, linear
LwPolyline vertices (≥ 2), closed, optional constant_width
Point location
Text value, position, optional height (default 2.5), rotation_deg, style
MText value, position, optional height, width, rotation_deg, style
Insert block (must exist), position, optional scale (number or [x,y,z], nonzero), rotation_deg
Solid corners (3 or 4 [x,y,z])
Hatch boundary (≥ 3 points, closed), solid, or pattern (catalog name, default ANSI31), pattern_scale, pattern_angle_deg

Every definition also accepts layer and color (ACI index). Response: {"handles":[…], "created":N, "layers_created":[…]}. An invalid definition (unknown_entity_type, invalid_radius, invalid_vertices, …) aborts the whole batch with nothing committed.

entities_delete — erase entities

{"op":"entities_delete","handles":[…]} — every handle must exist (entity_absent lists the missing ones and nothing is erased). One undo step; result.erased counts.

entities_transform — move, rotate, scale, mirror

{"op":"entities_transform","handles":[…],"action":…} with, per action:

  • move / copy — vector:[dx,dy(,dz)]; copy keeps the originals and returns the new handles in result.created.
  • rotate — center:[x,y], angle_deg (CCW positive).
  • scale — center, factor (nonzero).
  • mirror — axis:[[x1,y1],[x2,y2]] (points must differ); optional "copy":true to keep the originals.
  • array — rows, columns, row_spacing, column_spacing; creates rows×columns−1 copies (the original counts as cell 0,0).

result.affected counts the addressed entities; result.created lists new handles for copy/mirror-copy/array.

xdata_set / xdata_get — extended data

{"op":"xdata_set","handles":[…],"app":"SPM","data":[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":7}]} — replaces that application's record on every handle and registers the APPID table entry implicitly. Supported codes: 1000 string, 1003 layer name, 1004 hex bytes, 1005 hex handle, 1010–1013 [x,y,z] (point, position, displacement, direction), 1040/1041/1042 real, 1070 int16, 1071 int32. An empty (or absent) data list removes the record — that is the clear operation.

xdata_get is a read: {"op":"xdata_get","handles":[…],"app":"SPM"} → {"ok":true,"items":[{"handle":"65","xdata":{"SPM":["PAGE-01",7]}}]} — no request_id, no document_id needed.

block_define / block_delete — block definition lifecycle

{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["63","67"],"insert_at":[0,0,0]}
{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["6C"],"replace":true}
{"op":"block_delete","name":"MARK"}

BLOCK semantics: the sources move into the definition (flattened at base = block origin) and one Insert is placed at insert_at (default base), so the drawing looks unchanged while the entities became one reusable block. Response: {"block":"MARK","insert":"6B"}. Names must be unique and may not start with *. "replace":true drops a same-named definition — its children, markers and inserts — inside the same undo step before creating the new one: that is SPM's barcode re-import flow, so block_define never fails with "already exists" on a re-import. block_delete removes a definition completely (children, Block/BlockEnd markers and the table record). A definition anything still points at — an INSERT, a dimension-style arrow block, a multi-leader's block content — is refused with block_referenced and the shared message text; add "force":true to erase the referencing INSERTs in the same undo step and get result.references with the number that were overridden. result.erased counts every entity the step removed. Unknown names are block_missing, *-prefixed layout records, anonymous and xref definitions are block_protected, and a block with an open BEDIT session is block_in_edit. Definitions survive save/open round trips and are consumable by wblock (by block name) and INSERT creation via entities_create.

file_identity — per-document GUID

{"op":"file_identity"}                     → {"identity":"<guid>","created":true}
{"op":"file_identity"}                     → {"identity":"<same guid>","created":false}
{"op":"file_identity","renew":true}        → {"identity":"<new guid>","created":true}

A stable RFC 4122 v4 GUID per drawing, minted on first call and rewritten only with "renew":true. The identity lives on a single invisible marker text (height 0.0001) at the origin carrying the XData application SPMDOCUNIQUE — exactly how SPM.ACAD marks files — so it survives save/open round trips and travels with clones that include the marker. GET /api/v1/state also reports the document's hand_seed (the DWG handle seed, hex).

view_focus — GUI sessions

{"op":"view_focus","handles":["65"],"highlight":true}

Fits the entities into the view and selects them. Headless servers refuse with code:"gui_required"; REST clients get HTTP 409.

Document, sheet and organization operations (protocol ops)

Same envelope conventions as the entity operations: protocol:1, caller-generated request_id, document_id addressing, one undo step. Angles stay degrees on the wire everywhere below.

close — close a document

{"protocol":1,"op":"close","request_id":"cl-1","document_id":1,"discard":true}

Closes the addressed document. A document with unsaved changes is refused with code:"document_dirty" unless "discard":true; an unknown document id is document_closed. The last tab is replaced by a fresh empty drawing, so the session always keeps one document. result.closed reports the outcome. activate, close and entities_copy_to are exempt from the active-document guard — they may address any open document.

sysvar — read and write system variables

{"protocol":1,"op":"sysvar","request_id":"sv-1","document_id":1,"set":{"ltscale":2.5,"clayer":"Walls"}}
{"protocol":1,"op":"sysvar","request_id":"sv-2","document_id":1,"get":["ltscale","extmax"]}

set applies a JSON object of name → value pairs atomically (unknown names → unknown_sysvar, wrong values → invalid_sysvar_value, nothing partial). get (also with an omitted list = every readable name) returns result.values. The registry:

var meaning writable
ltscale global linetype scale (positive) ✓
celtscale current-entity linetype scale ✓
clayer current layer (must exist) ✓
ctextstyle current text style (must exist) ✓
textsize default text height ✓
filletrad fillet radius (≥ 0) ✓
mirrtext mirror-text flag (0/1) ✓
insunits insertion units (integer) ✓
osmode object-snap bitmask (integer) ✓
pdmode / pdsize point display mode / size ✓
extmin / extmax model-space extents [x,y,z] read-only

layout_create / page_setup_set — layouts and page setups

{"protocol":1,"op":"layout_create","request_id":"lc-1","document_id":1,"name":"PLAN"}
{"protocol":1,"op":"page_setup_set","request_id":"ps-1","document_id":1,"layout":"PLAN",
 "paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true,
 "scale":"1:100","window":[0,0,210,297],"plot_style":"monochrome.ctb"}

layout_create adds a layout with the default page setup and a sheet viewport; duplicates are refused (layout_exists). page_setup_set writes the named layout's plot configuration — explicit fields win, the rest keeps the stored setup. paper resolves through the paper catalog (invalid_paper lists nothing — use a canonical catalog name); orientation is portrait/landscape (degrees of sheet rotation); sizing is "fit":true or "scale":"paper:drawing" like "1:100" (both parts positive — nonsense is refused with invalid_scale, it does not silently mean 1:1); center centers the plot; window sets a plot window; plot_style names a CTB/STB. Unknown layouts are layout_missing. The stored setup is what plot consumes when the op's own fields are absent.

Templates — start from and save as .dwt

{"protocol":1,"op":"new","request_id":"nt-1","template":"C:/tpl/base.dwt"}
{"protocol":1,"op":"wblock","request_id":"wb-1","document_id":1,"path":"C:/out/x.dxf","handles":["2A"],"template":"C:/tpl/base.dwg"}

new with "template" starts an untitled drawing from a .dwg/.dxf/ .dwt file: tables, styles and entities come from the template and the document keeps no source path (result.total counts the imported entities). A .dwt file is DWG bytes — the same binary DWG writer produces it through a hidden scratch file that is renamed into place, so no lock is held on the target and a save can never leave a half-written template. wblock with "template" bases the exported database on the template's tables/styles instead of the source drawing's.

entities_copy_to — copy into another document

{"protocol":1,"op":"entities_copy_to","request_id":"cp-1","document_id":2,"handles":["2A","31"]}

Copies the entities from the active document into the open document addressed by document_id. Every handle must exist in the source (entity_absent otherwise); the target must be open (document_closed). Referenced (missing) layer definitions travel along. result.created lists the new handles in the target, result.count their number. Combined with an empty POST /documents (REST) this is the multi-document assembly workflow.

group_create — named groups

{"protocol":1,"op":"group_create","request_id":"g-1","document_id":1,"name":"FRAME","handles":["2A","31"]}

Creates an anonymous-group-record-backed named group over existing handles (entity_absent if any is missing). result reports the group name and its object handle.

selection_set_save / selection_set_load — SelectionSet reuse

{"protocol":1,"op":"selection_set_save","request_id":"s-1","document_id":1,"name":"frame-set","handles":["2A","31"]}
{"protocol":1,"op":"selection_set_load","request_id":"s-2","document_id":1,"name":"frame-set","select":false}

Named handle sets are session-scoped by design (like a held SelectionSet object id, they live while the session lives). save stores and returns the handle list; load recalls it and, with "select":true (the default), also makes the entities the current selection (result.selected counts). Unknown names are selection_set_missing.

user_select — ask the person at the screen to pick entities

{"protocol":1,"op":"user_select","request_id":"us-1","document_id":1,"type":"CIRCLE","layer":"Equipment","prompt":"Pick the equipment circles","detail":"full","clear":true}

Hands the screen to the person operating the GUI: they pick entities with the normal gestures and Enter confirms, Escape cancels. The operation resolves only after that answer — pollers see status:"running" the whole time, and every other mutation waits, so treat running as "the person is still picking", not as a hang. When they confirm, result carries cancelled:false, the picked handles and the matching entities with their attributes (detail: summary, geometry or full — default full, the whole entity object). Escape answers cancelled:true with empty lists; an immediate Enter with nothing picked is an empty answer.

Optional filters: type / layer gate the answer, not the gestures — picks outside the filter are deselected at confirm time and counted in result.ignored. prompt replaces the on-screen instruction, clear (default true) starts from an empty selection, and detail controls the attribute depth. Requires the desktop GUI (headless --http/--serve sessions answer gui_required — there is nobody at a screen to ask).

plot per-page publishing

{"op":"plot", …, "layout":"all","per_page":true} writes one PDF per layout instead of one multi-page document, returning result.files ([{path,…},…]) — one entry per layout, ready for sheet-by-sheet delivery. Without per_page the result stays {path, pages, page_sizes}.

query where — property filters

{"op":"query","type":"Circle","detail":"geometry",
 "where":[{"path":"/radius","op":"gt","value":2},{"path":"/common/layer","op":"eq","value":"Walls"}]}

where filters query results on serialized entity properties using RFC 6901 JSON Pointer paths (/radius, /common/layer, …) and the operators eq, ne, lt, lte, gt, gte, contains, starts_with, ends_with, in, exists, not_exists. Filters combine with AND and are validated up front (a bad pointer or unknown operator fails the query before any entity is inspected). The same filter shape works over the REST GET /entities?where=<json-encoded> parameter. Hatch queries expose their loop definitions under properties so boundaries can be read back.