VectorCraft MCP server
vectorcraft-cli mcp runs a Model Context Protocol server on stdio
(newline-delimited JSON-RPC 2.0; see Protocol for version negotiation). Agents use it to
draw, inspect and look at VectorCraft documents.
It has two backends:
| Mode |
How |
What works |
| Remote |
vectorcraft-cli mcp --connect 127.0.0.1:7979 (the app must run with vectorcraft --control 7979) |
Everything. Tool calls are forwarded over the control protocol, so you watch the app change live. |
| Headless |
vectorcraft-cli mcp --headless |
An in-process engine session with a CPU renderer. Everything except the UI-only tools (inspect_ui or ui_inspect, type_text, answer_dialog, open_panel, screenshot {window:true} or ui_screenshot). |
With no flag, the server tries 127.0.0.1:7979 and falls back to headless. Logs go to stderr; stdout carries
only protocol messages.
Install and register
From the repository root, with Rust 1.95+ and the platform’s source-build prerequisites installed:
cargo install --path apps/vectorcraft-cli --locked
# POSIX shells, for Cargo's default install location:
export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
vectorcraft-cli --help
claude mcp add vectorcraft -- vectorcraft-cli mcp
# or pin a mode:
claude mcp add vectorcraft-headless -- vectorcraft-cli mcp --headless
claude mcp add vectorcraft-app -- vectorcraft-cli mcp --connect 127.0.0.1:7979
Cargo installs a binary, not a symlink into target/, so it survives cargo clean.
Re-run the install command after updating the checkout.
By default the executable goes in $CARGO_HOME/bin (~/.cargo/bin when unset), or
%USERPROFILE%\.cargo\bin on Windows. If you set --root, CARGO_INSTALL_ROOT, or
Cargo’s install.root, use that installation root’s bin directory instead. Add it
to the PATH inherited by the MCP client, not just an interactive shell, and restart
the client after changing its environment. Installing does not start or register a server.
Other clients use the same command in their JSON config:
{"mcpServers": {"vectorcraft": {"command": "vectorcraft-cli", "args": ["mcp"]}}}
If a client reports ENOENT or “Executable not found in $PATH”, verify the install with
command -v vectorcraft-cli (PowerShell: Get-Command vectorcraft-cli) in the client’s
environment. If that environment cannot be changed, set JSON command to the absolute
installed binary path, not a literal ~ or $HOME expression. Avoid symlinks into
target/release: cleaning the build directory breaks them. A source build alone does
not put the CLI on PATH.
For a live session, start the app first: cargo run --release -p vectorcraft -- --control 7979.
From an installed release
The release packages ship vectorcraft-cli alongside the desktop app, so no build is needed:
| Install |
CLI |
| Windows (MSI) |
%LOCALAPPDATA%\Programs\ArtCraft\VectorCraft\vectorcraft-cli.exe by default (C:\Program Files\ArtCraft\VectorCraft\vectorcraft-cli.exe for an all-users install, under Program Files (x86) instead for the 32-bit build, or wherever you installed it otherwise), not on PATH |
| Linux (deb, rpm) |
/usr/bin/vectorcraft-cli |
| macOS |
the separate vectorcraft-cli-<version>-macos-<arch>.zip release asset (the .app holds only the desktop app) |
# Windows (PowerShell), default install folder
claude mcp add vectorcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\VectorCraft\vectorcraft-cli.exe" mcp
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add vectorcraft -- vectorcraft-cli mcp
For a live session, start the installed app with the control port before the MCP client starts the server:
# macOS: quit VectorCraft first, since `open -a` passes no arguments to an app that is already running
open -a VectorCraft --args --control 7979
# Windows (PowerShell), default install folder
& "$env:LOCALAPPDATA\Programs\ArtCraft\VectorCraft\vectorcraft.exe" --control 7979
# Linux (deb, rpm)
vectorcraft --control 7979
The Dock, Start menu and desktop launchers start the app without --control, so the app opens no control
port unless VECTORCRAFT_CONTROL_PORT is set. vectorcraft-cli mcp with no flag tries 127.0.0.1:7979
once, when it starts: a server that started before the app stays headless until the MCP client restarts
it. open -a and PowerShell’s & return before the app opens the port, so wait until the VectorCraft
window is open before you start the MCP client. With --connect, a server that finds no app stops and
prints vectorcraft-cli: cannot connect to 127.0.0.1:7979: followed by the system’s connection error, for
example connection timed out.
Confining file access
vectorcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
vectorcraft-cli mcp --automation-read-root /work/project --automation-read-root /work/shared-assets \
--automation-write-root /work/project/out --automation-write-root /tmp/scratch
{"mcpServers": {"vectorcraft": {"command": "vectorcraft-cli",
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
The two flags (PhotoCraft and the other Craft apps take the same ones, so one client config works across
the suite) keep the agent to the folders you give it:
- Every file a command reads must lie inside a read root:
open_file, file.place, relinking and
updating links, the images an SVG links to, swatch library, graphic style library, symbol library, brush library and colour-profile loads, plug-ins,
file.newFromTemplate, preset imports, shortcut sets (shortcutSet.read), the folders text.findFontFiles and links.findFiles search, the
scripts script.check reads and the files they include.
- Every file a command writes must lie inside a write root:
save_file, export, the save and
export commands, Export for Screens, Package, Export for Web, print to file, screenshot {path},
library and preset saves to a path, the copies links.relink {copy} makes.
file.list {path} (through run_command) lists a folder’s files and folders, held to the read roots like any read, so an agent can see what it was given (#1163).
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
counts once.
- Read and write access are separate. Give the same folder to both flags for both; a kind left out grants none
of its access (with only
--automation-read-root, nothing can be written to disk, though export and
the save commands without a path still return the bytes). Without either flag nothing changes.
- A path is made absolute against the server’s working directory, then its links are followed: a file
(or folder) link that leads out of a root is outside it. For a file that doesn’t exist yet, its nearest
existing folder’s links are followed and the rest must be plain names, so
.. below a folder that
doesn’t exist is refused, as is a link that leads nowhere (writing through it would create its target).
The result is compared with the root by whole folder names: /work2/x is not inside /work. On
Windows case is ignored, UNC paths (\\server\share\…) are resolved like drive-letter ones, and
device names (CON, NUL, COM1…) are refused.
- With
--connect, the server makes the paths of open_file, save_file, export (path or
options.path) and screenshot {path} absolute against its own working directory before it sends
them to the app. Paths inside run_command and command_batch params go to the app as given, and the
app resolves them against its own working directory.
- A refused path is an ordinary tool error the agent can read and correct, for example
automation path rejected: outside the write root /work/project/out: /etc/x.png (with several,
outside the write roots /work/project/out, /tmp/scratch: …) or
automation filesystem access is not granted: write authority is absent: /work/project/out/x.png.
Links in an opened document that point outside the read roots show as missing.
- The preferences that move folders the app reads or writes on its own (
fontsFolder,
pluginsFolder, recoveryFolder, templatesFolder) can only be set inside the roots. The app’s own folders
(preferences, Data Recovery, the User Defined library folders, VectorCraft’s Fonts folder) are not
confined: no agent names a path there.
- The roots must be existing folders; a missing one stops the server with an error. Each flag takes one
folder per use (
--flag <dir> or --flag=<dir>) and may be used again for more.
The flags confine the headless server and imply --headless; --connect with them is an error, since
the server would otherwise talk to an app that isn’t confined. A running app confines itself: start it
with the same flags, `vectorcraft –control 7979 –automation-read-root
--automation-write-root
`, and connect as usual (see [Control protocol](/docs/control-protocol.html#confining-file-access)). The
server then writes nothing itself: `screenshot {path}` asks the app to write the file.
The checks hold for every thread of the server process. A path is checked, then opened: another program
that swaps a link in between can still win that race, so the roots keep an agent to the files it was
given rather than guarding against other software on the computer.
## Protocol
Newline-delimited JSON-RPC 2.0 on stdio. The revision is **`2025-06-18`**; `2025-03-26` and `2024-11-05` are
accepted too. Every request and notification must carry `"jsonrpc": "2.0"`: anything else is
refused with `-32600` (requests) or ignored (notifications, which have no id to address a reply
to) before it is dispatched. When a tool call reaches a running app, it is one control-channel
request, and the app echoes its `id` in the reply: a reply with any other `id` is rejected, the
connection dropped, and the next call starts a fresh one — the call reports uncertainty like any
reply that never arrived whole. A malformed JSON reply also drops the connection and reports that
uncertainty; the failed operation is never replayed automatically. The app's refusals carry no `id`
(a connection past its 16, a line it would not read): the call reports the app's reason and says nothing was run. Before each call the
server checks that the app still holds the connection and opens a new one when it doesn't (the app
closes a connection left idle for 5 minutes, and a restarted app is a new one), so a request is
never written to a closed socket; when the app can't be reached, the call says that nothing was
sent. Clients requesting `2026-07-28` through `initialize`, or through a request's
`_meta["io.modelcontextprotocol/protocolVersion"]`, receive `resultType: "complete"`,
`ttlMs` and `cacheScope: "private"` on tool/resource/template/prompt lists and resource/prompt reads.
List TTL is 600000 ms; live reads use zero. Older clients keep the original response shapes.
This does not add streaming or input requests — see [Not implemented](#not-implemented).
One message is one line of at most 16 MiB: a longer line is answered with `-32600` and skipped,
and the server keeps serving what follows. A batch holds at most 64 messages. `command_batch`
holds at most 256 steps, and the step results it keeps (for later steps' `"$N.path"` references
and the reply) total at most 8 MiB — past that the batch stops with the reason, closing its atomic
undo group as usual. Replies from a connected app are refused past 256 MiB and the connection
dropped; the call reports uncertainty like any reply that never arrived whole.
`initialize` advertises:
| Capability | What it covers |
|---|---|
| `tools` | The tools below |
| `resources` | Three fixed resources and four templates |
| `prompts` | Five ready-made workflows |
| `completions` | `completion/complete` for prompt arguments and template variables |
| `logging` | `logging/setLevel` and `notifications/message` |
### Prompts
`prompts/list` offers five workflows; `prompts/get` returns one templated user message with its
arguments filled in. A missing required argument is `-32602`.
| Prompt | Arguments (required first) |
|---|---|
| `poster` | `brief`, `palette`, `text` |
| `icon-set` | `subject`, `count`, `detail` |
| `recolor` | `palette`, `method` |
| `trace-and-style` | `path`, `preset`, `effect` |
| `export-set` | `formats`, `directory`, `scale` |
```sh
echo '{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}' \
| vectorcraft-cli mcp --headless
```
### Resource templates
`vectorcraft://document` (summary), `vectorcraft://document/json` (the whole model), and
`vectorcraft://commands` (the live command catalog with enabled state) are the fixed resources. The templates read one thing at a time, which matters on a real document: the full model
is thousands of lines an agent pays for again on every change.
| `uriTemplate` | Reads |
|---|---|
| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint: `document.node {id, summary: true}`, the node as `document.inspect` lists it. A large container can be sliced with `run_command document.node {id, summary: true, depth?, childLimit?}` (a level that shows fewer children reports `childCount`) |
| `vectorcraft://command/{id}` | One command: label, menu path, shortcut, parameter description, enablement |
| `vectorcraft://effect/{id}` | One live effect with its parameters and defaults |
| `vectorcraft://swatch/{name}` | One swatch, colour, gradient or pattern swatch (percent-encode spaces) |
An unknown URI is `-32002`; a template with no value, or a value that names nothing, is `-32602`.
The same reads work as query commands through `run_command`: `document.node {id}` returns the object's full model
JSON (geometry, appearance, every attribute) and `document.node {id, summary: true}` the compact summary
`document.inspect` gives for it (id, name, kind, bounds, paint labels, children), without summarizing the whole
document. A summary read slices a large container: `depth` is how many child levels it includes (`0`: the node
alone) and `childLimit` how many children each node shows, top of the stack first (both default to all). A level
that shows fewer children than it has reports their total as `childCount`, so truncation is never silent; with
nothing truncated the reply is the plain summary. Both must be non-negative integers and need `summary: true`
(the full object JSON is never truncated); anything else is an error.
In the summary an object reports its paint as `fill`, `stroke` (labels) and `strokeWidth`. So does a compound path,
whose paint is its own: its member paths report `compoundMember: true` instead of a paint they don't have. Every
node (layers and groups too) reports its opacity when it is below 1, in both scales: `opacity` is the 0–1 fraction
the document stores and `opacityPct` the 0–100 % that `object.setProps`, `transparency.set`, `draw_path` and
`draw_shape` take (so an agent passes `opacityPct` back, never `opacity`); and `blend` when it isn't Normal, as
the label `object.setProps {blend}` takes (`"Multiply"`, `"Color Burn"`).
On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect
{depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command
document.find {name?, kind?, text?, within?, intersects?, minArea?, …, select?, limit?}` locates nodes across the
whole tree, and `document.node` reads the
interesting ones sliced. `document.inspect` and the `doc_inspect` / `inspect_document` tools also scope the
reply themselves: `ids: [id…]` lists just those nodes (any level, in the order given, an unknown id is an error,
at most 10000), `kind` lists every node of one panel label (ignoring case; within the subtrees of `ids` when both
are given) with its ancestor `path`, and `limit` / `offset` page the list. A scoped reply carries the nodes as
`nodes` instead of `layers` (each the summary the full tree gives it, sliced by `depth` / `childLimit`) plus
`total`, the entries before paging; artboards, selection and history stay whole. `limit` / `offset` alone page the
top layers (`layers` and `total`). Without these options the reply is the whole layer tree as before.
`document.find` needs at least one filter and all given filters must match; matching
ignores case, `name` is a substring of the Layers panel name, `text` a substring of type content and `kind` the
exact panel label (`Layer`, `Group`, `Clip Group`, `Path`, `Compound Path`, `Type`, `Image`, `Rectangle`, ...).
The geometric filters read each object's geometric bounds, in the document points `document.inspect` reports
(groups included; layers, sublayers and a node without bounds do not match): `within: [x, y, width, height]` keeps
objects inside that rectangle (edges included), `intersects: [x, y, width, height]` objects touching or overlapping it,
`minWidth` / `maxWidth` and `minHeight` / `maxHeight` limit the bounds' size, `minThickness` / `maxThickness`
the smaller of width and height (thin slivers of either orientation), and `minArea` / `maxArea` width × height.
`minAnchors` / `maxAnchors` count a path's anchor points, a compound path's members together; other nodes do not
match them. Limits are inclusive, not negative, and a minimum above its maximum is an error. `select: true`
also selects the matches, leaving out hidden and locked ones, those inside another match, and a compound path's
members unless the compound path is isolated; Select › Reselect repeats the find, and an action being recorded
records it as a step (a find without `select` is not recorded). For example,
`document.find {maxThickness: 1, kind: "path", select: true}` followed by `edit.clear` deletes hairline slivers.
It answers `{matches: [{id, name, kind, path}], total}` top of the stack first, where `path` is the ancestor
chain (layer first) and `limit` caps `matches` (default 100; `0` only counts); with `select` the reply adds
`selected`, the number of objects selected. `document.json` stays whole: it is the fidelity path, not the way to
look around.
### Completions
`completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable
(`ref/resource`), ranked by prefix and then by substring, capped at 100 values with `total` and
`hasMore`. The values are read from the live catalogues, so they follow the app: `formats` suggests
what the engine writes, `preset` the Image Trace presets, `effect` the effect ids, `{id}` the layer
and object ids in the document, `{name}` its swatch names. An unknown reference comes back empty
rather than as an error, since a completion is asked for mid-typing.
### Logging
`logging/setLevel` accepts any RFC 5424 severity — `debug`, `info`, `notice`, `warning`, `error`,
`critical`, `alert`, `emergency` — and an unknown one is `-32602` with the previous level left in
place. From then on the engine's own `log` records arrive as `notifications/message` with `level`,
`logger` and `data`. A `null` level turns it back off. Nothing is sent before the client asks, and
records queued at the old level are dropped rather than replayed at the new one.
Installing the logger that receives those records is `vectorcraft-cli`'s job, not the library's: a
crate that installed one would override whatever logger an embedder had already set up. Embedding
`vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want
log records to reach your client.
### Long calls: cancellation, progress and a busy engine
A call runs to the end before its reply, and the engine runs one call at a time, in the order the
lines arrived. A thread of its own reads the stream meanwhile, so the server still hears the client
while an export runs (#2165, #2171, #2172):
- **Cancellation.** `notifications/cancelled {requestId}`
([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation))
takes a request out of the queue, or stops the one that is running, and **that request gets no
reply**. A running export, flatten, bake or render notices at its next checkpoint (between pages
and artboards, between flattened groups, while drawing objects), returns, and writes nothing: the
document is exactly as it was and the engine is free for the next call. An unknown, finished or
malformed `requestId` is ignored, and so is a cancel of `initialize`. A call without checkpoints
(every ordinary edit is over in milliseconds) runs to its end, but its reply is dropped all the
same. Against a running app (`--connect`) the server stops waiting and drops the connection, but
the app cannot be told to stop what it started.
- **Progress.** A request with `_meta.progressToken` gets `notifications/progress`
([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before
its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster
export reports each artboard, `document.exportForScreens` and `assets.export` report each file
("Exporting file 3 of 250", in place of their encoders' steps), and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always),
and none follows the reply. A client that does not read fast enough misses updates, never the reply.
- **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`,
`prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running.
Anything else that arrives while a call has already run for two seconds is not queued behind it:
it is answered with error `-32003` (`data.busy` names the running call, its request id and how
long it has run) and was **not run**, so the client can retry later or cancel the running call.
Requests that arrive sooner than that queue and are answered in order, as before, so a client
that sends a few quick calls together sees no difference. At most 64 lines wait; past that the
server stops reading until the engine catches up.
`vectorcraft_mcp::Server::serve` takes the input and the output on separate threads (`Send`), and
`Server::with_busy_after` changes the two seconds. Calling `handle_line` directly is as before:
one line in, one reply out, no threads.
### Not implemented
- **`resources/subscribe` / `notifications/resources/updated`.** It would also have to be honest about
*whose* edits it sees: comparing the undo history's length misses edits made in the app window,
which is the main reason to run against a live document. `document.inspect`'s `revision` is the
right signal — it is monotonic and already bumped when a document is replaced — but noticing an
edit still means someone asking.
- **Reading the document during a long call.** Requests that need the engine (`doc_inspect`,
`resources/read`, `command_list`) are refused as busy while an export runs, not answered from a
snapshot.
- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests. Supporting
them means being able to suspend a tool call mid-flight, which is what the `2026-07-28` revision's
input-requests mechanism exists for.
- **Streamable HTTP / SSE.** stdio only.
`completion/complete` covers the "what could I pass here" half of the same problem, which is why it
is here and subscriptions are not.
## Tools
### Common command tools
These names match the conventions in [FilmCraft #28](https://github.com/storytold/filmcraft/pull/28).
The older names below remain listed because existing workflows use them.
| Tool | Arguments and result |
|---|---|
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` |
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` |
| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action |
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) |
| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode. `ui_inspect` is the same call as `inspect_ui`, and `ui_screenshot` is `screenshot` with `window: true`. `ui_screenshot` takes an optional `max_side` (1–4096), as `screenshot {window: true}` does: a capture whose longer side is larger is scaled down to `max_side` pixels on that side, and the reply's text gives the returned size as `width`/`height` and the capture's as `capturedWidth`/`capturedHeight`. Without `max_side` the full capture comes back |
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
`screenshot`, `save_file` and `export` are conservatively marked as mutations. Unknown top-level
argument keys return JSON-RPC `-32602`; command failures and caught tool panics return `isError`
content, and the server continues serving. Command `params` are passed through: the registry
currently describes them in prose, so MCP does not guess schemas or silently remove keys.
Coordinates are points in document space: y points down, the origin is the first artboard's top-left, and a new
document is 612 × 792 (US Letter). New objects become the selection. Most commands act on the selection or on
explicit `ids`. A command that leaves out an object selected inside a selected group also leaves it out when `ids`
list it together with a group that holds it. The commands that select their `ids` first (`object.group`, the
`object.arrange.*` commands, `object.move`, the Pathfinder commands and the others like them) refuse an id that names a
layer or sublayer, names no object or is not a non-negative integer, with an error that names the command that was run,
and leave the document and the selection unchanged; `layer.move` reorders layers.
explicit `ids`, which stand in for the selection, so they work with nothing selected. Where a command's params say
`ids?|id?` (or `id?, ids?: … (instead of id)`), `id` is `ids` of one object: `{"id": 7}` does what `{"ids": [7]}`
does, with or without a selection, and an `id` or `ids` that is malformed (not a non-negative integer) or names no
object is refused under the command's name, never replaced by the selection (`effect.apply {"id": "warp.arc"}`
without `effect` names the effect). A command that leaves out an object selected inside a selected group also
leaves it out when `ids` list it together with a group that holds it.
Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`,
or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`).
For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). Both
also style the new object in the same call: `dash` (lengths, or null for solid), `cap`
(butt|round|square) and `join` (miter|round|bevel) go to `stroke.set` with `strokeWidth`; `opacity` (0–100 %)
and `name` to `object.setProps`; `effects: [{effect, params?}]` are applied in order as `apply_effect`
does, to the whole object. They are checked before anything is drawn (effect ids against `effect.list`),
so a bad one draws nothing. The object and its styling are one `command.batch`, so the call is one undo step
(named as the drawing command names its own, e.g. Pen or Rectangle) and a paint the engine refuses (a colour
it can't read, a swatch the document lacks) fails the call and leaves the document as it was. The reply adds `name`, `opacity` and `effects` (each `effect.apply` result)
when given; `paint` lists the stroke options applied.
A gradient given to several objects fits each one's own bounds; `paint.setFill` / `paint.setStroke` with
`span: "selection"` place a linear or radial gradient once on the union of the targets' bounds instead, so one
ramp runs across them.
For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art).
For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`.
Lines start with no fill and a visible stroke.
The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolour the active
proxy keeping each colour's model, `paint.lastColor` / `paint.lastGradient` re-apply the last solid colour or gradient,
and `paint.recent` returns the recent colours that every paint command (and the eyedropper) feeds.
`paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected
objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy).
| Tool | Arguments | Notes |
|---|---|---|
| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. |
| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. |
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
| `inspect_ui` | `{}` | UI state. Remote mode only. |
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee). |
| `type_text` | `{text}` | Remote only. |
| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`ui.dialog.cancel`). Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. |
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. |
For example, create editable Japanese area type with the engine's vertical writing mode:
```json
{"name":"add_text","arguments":{"text":"こんにちは","x":40,"y":40,"width":160,"height":240,"size":20,"vertical":true}}
```
`vertical` also applies to point type and to `path` + `mode` (`area` or `onPath`). Omit it or pass `false`
for horizontal writing. This is the same option as `text.create` / `text.createInPath`; it does not change
the engine's existing glyph orientation or CJK composition.
For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`).
PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text).
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
inside it (a path left with none leaves the selection, one with all of them is selected whole). The Lasso adds anchors
with `mods.shift` and takes them away with `mods.alt`. They run `select.toggle {ids}` and `select.anchorsMany {items,
mode: "set"|"add"|"toggle"|"subtract"}`:
```json
{"name":"pointer_gesture","arguments":{"tool":"selection","mods":{"shift":true},"events":[{"kind":"down","x":180,"y":80},{"kind":"drag","x":300,"y":200},{"kind":"up","x":380,"y":200}]}}
```
`press_key {key: "e"}` while a Selection or Group Selection marquee is being dragged makes the marquee select only the
objects that lie entirely inside it, measured by their geometric bounds, and `e` pressed again switches it back to the default mode. The
Selection tool selects a group only when the whole group lies inside; Group Selection selects the objects inside
groups, each as a whole object. Both run `select.set {ids}` (`select.toggle {ids}` with `mods.shift`). The marquee
also selects the artboard guides that lie entirely inside it, and no canvas guide. Each marquee starts in the default
mode. To press the key during a drag, end one
`pointer_gesture` after the `drag` event and send the rest in another with the same `tool`, which keeps the drag:
```json
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"down","x":80,"y":80},{"kind":"drag","x":220,"y":170}]}}
{"name":"press_key","arguments":{"key":"e"}}
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"drag","x":230,"y":170},{"kind":"up","x":230,"y":170}]}}
```
With nothing being dragged, `e` chooses the tool bound to E.
Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed
in paint order (0 is painted first, the bottom row of the Appearance panel). Cmd+/ runs `appearance.addFill` and
Cmd+Alt+/ runs `appearance.addStroke` (`press_key {key: "/", mods: {cmd: true, alt: true}}`). `paint.setFill`, `paint.setStroke`,
`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take
`item` to edit one of them; `run_command appearance.setActiveItem {"index": n}` makes that row the target of later
calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes; the proxy of
its kind (`paint.proxies`) and the Gradient tool's annotator then show that row.
`inspect_document` reports it as `paint.appearanceItem`. Live effects take the same `item` to apply to one fill or
stroke instead of the whole object: `run_command effect.apply {"effect": "path.offsetPath", "item": 0}`, and
`effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects;
`apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`.
`effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries
it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape
those outlines. On paths, compound paths and images it does not change what is drawn.
Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with
`isError: true` and a message. The model can read the message and retry.
Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour;
`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a
global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes
swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked).
`swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient
or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected
art's colours (by default as global swatches the art links to).
`swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging
them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art
in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`.
The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the
names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and
`swatch.sortByKind`.
`brush.move {name | names, to?, type?}` reorders brushes among the brushes of their type, as dragging one onto
another of its type in the Brushes panel does. `to` is an index among the brushes of that type in `brush.list`
order: the brushes go before the first brush of that type at or after `to` that is not moving, and after the last
brush of that type when there is none or `to` is left out. A move that keeps the order records no undo step.
Brushes of different types, or a `type` other than theirs, are refused with "brushes move only within their type".
Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB
colors applied to it (`paint.setFill`/`setStroke` colors, gradient stops and freeform points, `paint.editGradient`
stops, `paint.freeform.addPoint`/`setPoint`, `paint.sampleColor`, `object.mesh.setPointColor`/`addLine`, the
`text.create` and `text.createInPath` color, the `text.setStyle` fill, `text.setRangeStyle` fills and strokes, a
`shapeBuilder.merge` hex fill (its `fill: {color, keepModel: true}` form keeps RGB), the
`symbol.adjust` stain color, `swatch.new {color}` and `{colors}`, `swatch.newGroup`, `swatch.editGroup` and
`swatch.edit`) are stored as CMYK, converted through the color settings (`edit.colorSettings`: the CMYK
working space and intent, as Document Color Mode converts them);
Gray stays Gray, and `keepModel: true` keeps a color as given. A color that `swatch.edit` or `swatch.editGroup` is
given and the swatch already has keeps its model, as does a stop color that `paint.editGradient` is given and the
gradient already has; a stop that names a swatch takes the swatch's color as it is, and `swatch.new` or `swatch.edit`
with `mode` converts a color into that mode only. RGB documents keep colors as given. Harmonies, Edit
Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's).
`swatch.new {colors: [...]}` saves several colours as swatches in one undo step. `color.harmony {color, rule, steps?, variation?, amount?}` answers what the Color
Guide panel shows: the harmony rule's colours, base first, and per colour its row of `2·steps+1` variations
(shades, cool or muted on the left, tints, warm or vivid on the right). With `limitTo` (a swatch library id or name,
or `"document"` for the document's swatches) every colour snaps to the library's nearest colour (ΔE 2000), as the
panel's Limit to Library does (`ui.colorGuideLimit`); Recolor Artwork's `limitTo` takes the same values.
Color Themes (local only: no online service) keeps five-colour themes in the preferences. `colorTheme.save
{colors: [...]}` saves up to five colours as they are, `colorTheme.save {color, rule}` saves the five-colour theme a
harmony rule makes from a base colour (base first; `name?` defaults to "Theme N", `replace: ""` overwrites
that theme in place). `colorTheme.list` answers `{themes: [{name, colors: [hex], keys: [colour keys], rule?}]}`,
`colorTheme.delete {name}` removes one and `colorTheme.addToSwatches {name}` adds one to the Swatches panel as a
colour group (one undo step).
```json
{"name":"run_command","arguments":{"command":"colorTheme.save","params":{"color":"#2266aa","rule":"splitComplementary","name":"Harbor"}}}
{"name":"run_command","arguments":{"command":"colorTheme.addToSwatches","params":{"name":"Harbor"}}}
```
SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}`: `styling` (`presentation`,
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`, each holding only the art over its artboard), `preserveEditing` (the SVG
reopens as the full document), `metadata` and `fewerTspans` (one `` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian after a byte order mark) or `latin1`
(ISO 8859-1, other characters as `&#x…;` references); `document.serialize` still answers `text`, plus `dataBase64`
(the file) when it isn't UTF-8, and such files open again. `profile: "tiny12"` writes a simplified SVG Tiny 1.2
(presentation attributes only; filters, masks, blend modes and embedded fonts left out, symbols as art, each with
a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a
font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out
with a warning. Type names its faces by numeric `font-weight` (600 for Semibold and for a Japanese face's W6, 300 for Light, `bold` for 700),
embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open,
place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`).
Symbols export as one `` with a `