Centina MCP server
Agents as first-class editors: any MCP client (Claude Code, Claude Desktop, custom agents) gets live read/write access to the document open in the browser. Because the canvas is real DOM, reads report measured truth (getBoundingClientRect, getComputedStyle), and writes go through the same command layer as human edits — atomic, validated, and undoable.
Quick start
In the desktop app: open a canvas, click Agent (top-right, beside Share), then Copy for agent. Paste that into any agent that already has Centina, and it paints on the page you are looking at. The one-time setup is in Centina desktop.
From a terminal: start the editor, then register the MCP command with your client (Claude Code shown):
centina serveclaude mcp add centina -- centina mcp
If serve uses --port, add the same --port to the mcp command. Any running Centina hands you its canvas from the Agent capsule; paste that reference into the agent to point it here.
The full setup — absolute paths, JSON client config, LAN sharing, firewall notes — is in the setup and self-hosting playbook.
The window connects to the bridge automatically within ~2.5 s. Open several windows at one address and the newest one wins — older ones go quiet. Then just talk to the agent: "what's on my canvas?", "build me a pricing section", "make this dark mode".
How it works
MCP client ⇄ stdio ⇄ centina mcp ⇄ WebSocket ⇄ bridge (centina serve) ⇄ the open Centina window
The serving process hosts the bridge, so a Centina window — a browser tab, or the desktop app — is reachable by agents whenever its server runs; centina mcp is a stateless client the MCP client launches. Any number of agent sessions can share one window concurrently — the bridge fans their requests in; windows still follow newest-window-wins. If the server runs but no window is connected, every tool fails fast with No Centina window is open at http://localhost:<port> (in the desktop app: The Centina desktop app has no canvas window open at http://localhost:8462); if the server itself is down, with Centina isn't running at http://localhost:<port>. ping and get_canvas_info report the instance, url, and app (desktop or browser) the agent reached, so it can say where it is. With no --port, centina mcp reaches a running centina serve (3000) or the desktop app (8462), preferring the one with a window open.
Instances are identified by their app URL. Canvases autosave in the browser's IndexedDB, which is per-origin — localhost:3000 and localhost:4500 are different libraries. Node refs therefore carry their origin (localhost:3000#ab12), and every tool routes each call to the instance its ids name; bare ids target the default instance. Tools that can run without any id (ping, get_canvas_info, get_selection, get_tree_summary, create_box, write_html, finish_working_on_nodes) take an optional instance param (localhost:3000, or a whole pasted canvas reference) for the same routing; page accepts a canvas reference too and draws on the page it names. One call targets one instance — mixing origins in a single call is an error. The bridge always listens at the web port + 701 (see ports and firewalls).
Concepts
- Everything is a box. Container nodes are boxes (what other tools call frames); the other node types are text and image. Tools say "node" when any type works and "box" only for container concepts.
- A canvas reference hands an agent the page you are on. Copy for agent in the Agent capsule (top-right, beside Share), ⇧⌘C with nothing selected, or right-click on empty canvas → Copy canvas for agent, puts this on the clipboard:
[Centina localhost:8462#p1a2b3 “Untitled” · canvas · page “Page 1” — use centina MCP tools here: pass this token as instance, or as page to draw on this page]. Paste it into the chat: the agent passes it asinstance(any id-less tool) or aspage(write_html,create_box) and its work lands there, even after you move to another page. - Node ids address everything. Get them from any read tool, or from the human: Copy for agent (right-click any node, ⇧⌘C, or the button at the bottom of the inspector) puts a self-describing reference on their clipboard —
[Centina localhost:3000#ab12 "Hero" · box · 960×540 — use centina MCP tools with this id]— so a paste into any agent chat carries the id, the instance it lives in, and how to resolve it, no per-project setup needed. Every tool accepts the qualified token verbatim and routes to that instance. The context menu's Copy ID copies the bare id(s) for prompts that already have context. Id inputs accept the display form with a leading#. - Every write is one undo step. A whole
write_htmlimport, a 12-nodeupdate_stylesbatch — each tool call reverts with a single ⌘Z. Validation is atomic: if any entry in a batch is invalid, nothing applies and the error names every offending path. - Measured truth.
layout.width/heightvalues are stale forfit/fillsizing — the browser decides those. ReadworldRect(from the live DOM) when you need actual geometry; it'snullfor hidden nodes. - Style writes speak the model schema, not CSS.
update_stylestakesfills,radius,layout.width: {mode}, … — not CSS property names. TherawCssgroup is the escape hatch for arbitrary CSS (it applies last, so rendering stays faithful). It merges per declaration:{prop: "value"}sets one key (validated by the rendering browser'sCSS.supports; custom--*properties exempt),{prop: null}removes it,nullclears the whole map, untouched keys stay.disabledCsstakes the same shape for declarations kept but toggled off; setting a key in either map removes it from the other. Read both back viaget_node_info'sstylefield — the same maps the panel's "Other styles" section edits. - Presence. Nodes an agent touches show a shimmering "Crafting…" overlay — ephemeral, never in the document or history. The overlay draws only on the page the human is viewing; work on another page shows as a pulsing dot on that page's row in the folios panel, whose tooltip names the session. The Agent panel's roster lists, per session, the pages it has written (a live dot while the shimmer is on one; each name is a link to that page). Its lifetime is the agent session, not write recency: every tool call (reads and screenshots included) keeps it alive, so a half-built design never reads as finished while the agent thinks between writes. It clears on
finish_working_on_nodes, instantly when the last agent session disconnects (the hub pushes agent head-counts to the tab), or via a 120 s idle backstop for agents that stay connected but silently move on. The human can also end it: the editor's Home button, pressed while the shimmer is live, confirms and then ends every agent session — the hub fails in-flight calls with "The human ended this agent session from the editor." and closes agent sockets (code 4002). A session ended this way reconnects on its next request; treat the error as "stop this task", not "the bridge is broken". - Pages are no boundary. Ids resolve across every page of the document: reads mix pages freely (nodes not on the human's page carry a
page: {id, name, active: false}field), and writes work on any page — the human's canvas stays wherever they are, and the folios panel marks pages being crafted with a shimmer + pulsing dot (the panel-side face of the canvas shimmer). Constraints that keep this sane: one write call edits one page (ids that span pages are an atomic error; each call is one entry in that page's own undo history), the id-less creators (create_box,write_html,get_tree_summary) take apageparam (id or unambiguous name), andselect_nodes {reveal: true}is the only call that switches the human's page — background-page geometry is still measured truth (a hidden, unpainted stage mounts those pages in the live tab), and background-page screenshots render through the headless fallback automatically. - Contexts relate by membership. A context is a freeform-labeled group (canvas-wide, cross-page); elements that share one are thereby related — there is no directed edge, the shared membership IS the relationship. The driving use: a wiremap card and the hi-fi mock that realizes it share a context, so
get_node_infoon either lists the other undercontextswith its page — read a node's contexts before editing it to find its siblings and intent. Join and leave withset_contexts(labels mint on first use, like tags);get_canvas_info.contextGroupsis the full table. - The human is watching. Every tool call paints in the live tab immediately, so an agent session is a performance by default: new canvas-root content lands beside the human's context — their selection if they have one, else whatever the agent last touched, else what's on camera — at the next slot in the row, and never on top of an existing root (see "Placement" below). Big builds should go shell-first then section-by-section (
write_htmlwithtargetId— each call one undo step), andselect_nodes {reveal: true}pans the camera to finished work. The server ships these habits as MCPinstructionsat initialize, so any connecting agent gets the workflow without per-project setup. - Round trips are the only slow part. A tool call takes about a millisecond; the turn of model thinking wrapped around it takes seconds. So the cost of a session is the number of round trips, not the number of calls — which is what [
batch](#batch--several-calls-one-round-trip) is for. Build and look in one trip, refine and verify in one trip, present in one trip.
Placement
Canvas roots are whole screens, so two of them overlapping is always a mistake. Creation tools (write_html, create_box) place new roots against the measured size of what they just built:
- Never on top of existing work. The whole footprint has to be clear, not just the drop point — a page-sized import lands beside your screens, never across them.
- Beside the reference. The anchor is your selection, else the root the agent last worked on, else the root nearest the middle of your view. New work takes the slot to its right, top edges aligned. A row that has run out of room wraps to a new one below.
- On camera when it can be. Free slots you can see are taken first; a screen too big for the viewport at least starts on screen. Clearing existing work wins over staying on camera when the two conflict.
Passing x/y states a preference, not a licence to overlap: a position that lands on an existing root is nudged clear and the result carries a placement note saying where it went and why. When you mean an exact position — parking something off to the side, staging a comparison — create the node and then move_nodes it: moves are exact by design.
batch — several calls, one round trip
batch takes a list of {tool, args} and runs them in order, in a single request:
{ "calls": [
{ "tool": "write_html", "args": { "html": "<section style=\"…\">…</section>" } },
{ "tool": "get_screenshot", "args": {} }, // no nodeId: captures the write above
{ "tool": "get_tree_summary", "args": {} }
] }Batching changes nothing except how many turns the work takes. Each call keeps its own undo step (two batched writes are still two Cmd+Zs), its own atomicity, and its own behaviour. The reply carries ran, failed, and results in order; any screenshots arrive as image content after the JSON.
- The whole batch is validated before any of it runs — an unknown parameter in the fifth call rejects the batch without applying the first four.
- A call that fails at runtime (a ghost node id) stops the rest by default; the calls that already ran stand — each was its own undo step — and the skipped ones say so. Pass
stopOnError: falseto run them all. - Ids must be known when you write the batch. Nothing substitutes ids created earlier in the same batch, so end with
get_tree_summaryand address the new ids on the next turn. - Up to 20 calls;
batchcan't nest.
The shapes worth memorizing:
| Batch | Does |
|---|---|
[write_html, get_screenshot] | build it and look at it |
[write_html, get_screenshot, get_tree_summary] | …and learn the new ids for next turn |
[update_styles, get_screenshot] | refine and verify |
[select_nodes {reveal}, finish_working_on_nodes] | present the finished work |
Tool reference
Parameter names are strict: a call with a param the tool doesn't declare is a tool error naming the bad key and listing the valid ones. (A typo'd parentId for targetId, silently dropped, once sprayed nine page sections across the human's canvas as top-level roots — never again.)
Orient & read
| Tool | Arguments | Returns |
|---|---|---|
ping | — | canvas name, current page, page count, node count — the health check; plus instance, url, and app (desktop or browser): which Centina answered |
get_canvas_info | — | canvas overview: pages plus the current page's top-level nodes with measured sizes; when the canvas has a component library, components lists each one with its definition node, instance/out-of-sync counts, its variant faces by name, tags, and implementation links (get_component_info is the detail); when contexts exist, contextGroups lists each with its label, note, and member count; call first to orient (get_book_info and get_document_info remain as deprecated aliases) |
get_tree_summary | nodeId?, maxDepth?, page? | compact indented text, one line per node (id, name, kind, size); the cheapest structural read — deep levels truncate with an explicit … +N descendants; page lists a whole non-current page |
get_selection | — | the human's current selection; use when they say "this" — it may be an arrow: selected nodes return as ids + details, a selected flow arrow as connectorIds + the full connector objects (endpoints, marks, note); the editor selects boxes and arrows exclusively, so one list is always empty |
get_node_info | ids | per node: type, name, layout, flex/grid config, children, content, the measured worldRect, style.rawCss/style.disabledCss when the node carries raw-CSS declarations, contexts — each of the node's context groups WITH its other members (page named when foreign) — and component on linked roots: role + identity, an instance's recorded customizations (overrides, the {address, path} selectors update_instances takes), its selected variant, outOfSync: true when behind, a definition's mountedState mid-face-edit: read this before editing to find what a node belongs with |
get_computed_styles | ids, properties? | exact getComputedStyle values (curated layout/paint/typography set by default) |
get_compass_info | nodeId | Compass's own box-redline + typography readout for a subtree — measured padding/gaps, material facts, per-text font readouts/cap-height/x-height/characters-per-line — computed fresh regardless of whether the human has the I/T lenses toggled on. Also guides: each page guide with signed guide − edge distances (world px) to the inspected root's start/center/end — 0 = flush. Active page only. |
get_guides | page? | the page's alignment guides {id, axis, position}, sorted axis-then-position: "v" pins a world X (vertical line), "h" a world Y — compare against worldRect to align or read layout rhythm |
get_connectors | page? | the page's interactions (flow arrows) {id, from, to, shape?, points?, caps?, lineStyle?, color?, decorations, note?} in document order — the from terminus renders as the interaction NODE on the source element, wearing the trigger gesture (tap, the default, when no gesture mark is stored — writing tap via marks clears back to it). An endpoint {nodeId, side?, offset?, center?} anchors a same-page node (side absent = the router picks the exit side as boxes move; offset 0..1 along a pinned side; center: true reaches the box's middle) or {x, y} floats free; decorations (label/chip/gesture/status/decision) sit at t, the 0..1 fraction of routed length; note is the IX annotation card {trigger?, feedback?, rules?, modes?}; absent shape = elbow, absent lineStyle = solid, absent color = the interaction red #d44c47, absent caps = end arrowhead (caps.start is legacy — the node owns the from end) |
Pages & folios
Two book-level creation tools. They never move the human — their canvas stays on their page; pass the returned id as page to write_html / create_box / get_tree_summary, and select_nodes {reveal: true} once there is something to show. Page and folio creation sits outside the undo stream (exactly as the folios panel's own Add buttons do), so these are the two write tools that are not one ⌘Z.
| Tool | Arguments | Returns |
|---|---|---|
create_page | name?, folio? | appends a page to a folio (folio is an id or an exact, unique name; default: the folio of the human's current page); a colliding name becomes Name 2, the folios panel's rule; returns {page: {id, name, folio: {id, name}}} plus a note on how to target it |
create_folio | name?, pageName? | appends a folio with its first page (Folio N / Page 1 by default, collisions renamed the same way); returns {folio: {id, name}, page: {id, name}} |
Edit
All edit tools are batch, atomic, and exactly one undo step each.
| Tool | Arguments | Notes | |||||||
|---|---|---|---|---|---|---|---|---|---|
create_box | name?, parentId?, index?, layoutMode?, layout?, style?, flex?, grid?, page? | new container div; default parent is the canvas root; canvas roots land beside the human's context and never on top of an existing root (see Placement); returns the node with its measured rect | |||||||
update_styles | nodes: [{id, style?, layout?, flex?, grid?, textStyle?, clipsContent?}] | model-schema styling; colors as #rrggbb[aa] or {r,g,b,a}; layout.position: "absolute" opts a child out of its flex/grid parent's flow; `layout.mode: "flex" \ | "grid" switches a box's layout mode in place (to-grid seeds default tracks; "absolute" stays editor-only — it needs measured child pinning); layout.constraints: {h?, v?} sets per-axis resize anchors on absolutely-positioned children ({mode: left\ | right\ | both\ | center\ | scale, inset?\ | offset?\ | pct?}, null` clears an axis; omit the value to anchor at the current spot — see the constraints guide; x/y on an anchored axis convert to its canonical field, and percent dims require a Scale axis) |
move_nodes | moves: [{id, x?, y?, parentId?, index?, before?, after?}] | reposition (absolute contexts), reparent (ids preserved), or order-precise placement via a sibling id | |||||||
set_text_content | texts: [{id, text}] | text nodes only | |||||||
rename_nodes | renames: [{id, name}] | ||||||||
set_notes | notes: [{id, note}] | free-text annotations riding a node (a comment, not painted content); null/"" clears; shows on get_node_info, in the wiremap rail, and in the gallery's pop-out editor | |||||||
set_contexts | changes: [{id, add?, remove?, clear?}] | move nodes in and out of CONTEXT groups by label — freeform sets whose MEMBERSHIP is the relationship (a mock and the wiremap card it realizes share a context; reading either finds the other). Labels match case-insensitively; an unseen add label mints the group; remove requires an existing label; clear strips all | |||||||
update_context_groups | updates: [{id, label?, note?, delete?}] | rename a context, edit its note (null clears), or delete: true — removes the group and strips every membership across all pages, one ⌘Z restores both | |||||||
duplicate_nodes | ids | deep clone; returns newIds plus the full old→new idMap so clones' internals stay addressable | |||||||
delete_nodes | ids | node + descendants | |||||||
set_visibility | ids, visible | idempotent | |||||||
set_locked | ids, locked | idempotent | |||||||
add_guides | guides: [{axis, position}], page? | add alignment guides (world px); the human's box drags/resizes snap to them immediately (unless they've hidden guides with ⇧G — hidden guides don't render or snap in that tab, but all guide tools still work); returns the created guides with minted ids | |||||||
move_guides | `moves: [{id, position?}\ | {id, delta?}]` | exactly one of position/delta per move; one call edits one page (guide ids resolve across pages) | ||||||
delete_guides | ids | atomic — any unknown id applies nothing | |||||||
add_connectors | connectors: [{from, to, shape?, points?, caps?, lineStyle?, color?, marks?, note?, decorations?}], page? | draw interactions between two different nodes on the same page — or with free {x, y} ends (# ids accepted; bound endpoints pin the page); the from end is where the node lands on the source element. Omit an endpoint's side for auto-routing; shape: "arc" is the curved voice the interaction tool draws, shaped by points (world waypoints); marks places the vocabulary the panel way (one gesture/chip/status/decision per interaction, labels plural — the gesture becomes the node's face), note attaches the IX card, decorations is the raw mark list for exact control (ids minted); colors as #rrggbb[aa] or {r,g,b,a} — the default red never stores. An endpoint can also be a branch anchor {connectorId, decorationId}: the arrow grows out of another interaction's decision diamond (place the diamond with marks: {decision: true}, read its id back, then add the branches — a YES/NO chip labels each exit); the host must already exist, and deleting it (or its diamond) removes its branches in the same undo step. Writes re-reveal a hidden Ix layer; returns the new ids plus the page's full list | |||||||
update_connectors | updates: [{id, from?, to?, shape?, points?, caps?, lineStyle?, color?, marks?, note?, swap?, decorations?}] | patch per connector: prefer marks for the vocabulary — gesture/chip/status swap the arrow's one mark of that family in place (null removes), decision: true/false, labels replaces the text-label list; note merges the IX card per field (string sets, null clears; note: null removes the card); swap: true moves the arrowhead to the other end — the node jumps with the new from end (waypoints and mark positions flip); points replaces the waypoint list ([] clears), lineStyle: "solid" clears the field, color: null (or the default red) clears back to the interaction red, decorations replaces the whole raw list (echo ids to keep, omit to mint — anything left out is deleted); one call edits one page (connector ids resolve across pages) | |||||||
delete_connectors | ids | atomic — any unknown id applies nothing | |||||||
select_nodes | ids, reveal? | hands focus back to the human ("I changed these"); not an undo step. reveal: true also pans/zooms the camera to fit the selection (no-op when already fully visible), switching to the nodes' page first when needed — for presenting finished work, not every edit. Nodes on another page require reveal: true (without it the call errors instead of selecting where the human can't see). Interaction (flow-arrow) ids work too: they select as interactions (their inspector opens; reveal pans to their endpoint boxes; a hidden Ix layer re-reveals) — don't mix node and interaction ids in one call |
Wiremaps & wireflows
Structure planning as data (the wiremaps and wireflows guides cover the editor side). One tool family, one map object — a forest: top-level cards are hand-placed ROOTS (their position is user truth; a wireflow is a map of free roots wired with interactions), and nested children opt into the managed tree with trunk lines. The rule that makes the surface small: cards and sections are real boxes — rename with set_text_content, recolor with update_styles, wire flows and transitions with add_connectors between section/card ids, reposition ROOT cards with move_nodes (x/y are container-relative for cards), read deeper with get_tree_summary. The dedicated tools cover only what the generic ones can't: cross-card structure, reflow-coupled inserts, and wireframe styles. Every write is one undo step — each tree re-lays under its root, then the settle hugs (the container fits the forest; hand-placed roots never move).
| Tool | Arguments | Notes | |||
|---|---|---|---|---|---|
get_sitemaps | page? | the page's wiremaps, whole: formFactor on every map (desktop/tablet/mobile — derive mockups at THIS device class), raw entries ({nodeId, parentNodeId} tree, DFS order), an indented outline, and cards — the joined view with each card's title, parent, measured worldRect, and sections ({label, colorHex?, schematicStyleId?}). strayCardIds lists container children no entry claims yet (tidy adopts them) | |||
create_sitemap | name?, formFactor?, cards?, page? | build a whole map in ONE call. Top-level card specs are hand-placed ROOTS — optional container-relative x/y place each (omitted roots pack in a row, one corridor apart); nested children opt into the managed tree, auto-arranged beneath their root with trunk lines. For a wireflow, make every card a root and wire transitions with add_connectors after. Card specs: `{title, sections?: [{label, color?} \ | "label"], children?, x?/y? (roots only)}. formFactor (desktop` default \ | tablet \ | mobile) binds the map to a device class — cards build at its width, get_sitemaps reports it, and mockups derived from the map should target the same class. The container lands beside existing work, never on top. Returns {sitemapId, rootNodeId, cardIds, outline} |
add_sitemap_cards | sitemapId, cards: [{parentCardId?, index?, title, x?, y?, …}] | add cards. Omit parentCardId (or pass null) for a new free ROOT — x/y place it, omitted roots land one corridor beside the rightmost card. parentCardId (or nested children) grows the managed tree beneath that card, reflowed in the same undo step. Existing roots never move (the container hugs) | |||
move_sitemap_cards | moves: [{cardId, parentCardId?, index?}] | reparent/reorder within one map; subtrees ride along; a cycle-closing move refuses the whole batch before anything applies. parentCardId: null DETACHES — the card re-roots as a free card exactly where it stands (world-still), tree ties cut. To reposition a root, use move_nodes | |||
apply_schematics | `sections: [{sectionId, styleId \ | null}]` | dress sections with wireframe styles from the built-in catalog (32 ids, nav-bar … footer-columns — the enum is in the schema): real boxes land inside the section, inked from its fill; null strips back to a plain bar; the brick remembers its style for clean swaps. Styles are RESPONSIVE: the map's formFactor picks the variant (mobile stacks lanes, collapses nav to a burger, piles split heroes) — derived at apply time, so a form-factor conversion re-clothes every dressed section automatically | ||
update_sitemaps | updates: [{id, name?, formFactor?, tidy?}] | rename, convert, and/or tidy. formFactor converts IN PLACE: same structure, sections, colors, notes — card widths rewrite to the device class and applied wireframe styles redraw as their responsive device VARIANTS (mobile stacks lanes, burger nav, piled heroes — never a squish) (duplicate_nodes on a map's root clones the whole map, row included — convert the copy to derive a device variant). tidy: true is the drift sweeper after generic tools touched map internals: adopts stray cards (as free roots), drops dead entries, re-lays each tree under its root, and hugs the container to the forest — hand-placed root positions never change | |||
delete_sitemaps | ids | whole maps — container, cards, and the map record go together, one ⌘Z restores all of it; to remove a single card use delete_nodes (its children promote to the grandparent) |
One call, one site:
{ "tool": "create_sitemap", "args": { "name": "Marketing site", "cards": [{"title": "Home","sections": [{"label": "Header"}, {"label": "Hero"}, "Footer"],"children": [{ "title": "Pricing", "sections": ["Plans", "FAQ"] },{ "title": "About", "children": [{ "title": "Team" }] }]}]}}
…then apply_schematics on the hero, add_connectors from a CTA section to the Pricing card, and an id-less get_screenshot to look.
One call, one flow — the same tool, every card a hand-placed root:
{ "tool": "create_sitemap", "args": { "name": "First run", "cards": [{ "title": "Splash", "sections": ["Logo"] },{ "title": "Onboarding", "sections": ["Carousel", "CTA"], "x": 400, "y": 40 },{ "title": "Sign in", "sections": ["Form"], "x": 400, "y": 320 }]}}
…then add_connectors for the transitions (card-to-card arrows route as gutter elbows automatically; anchor the CTA section's id for an element-level arrow), marks: {decision: true} on the splash arrow and a branch out of its diamond — from: {connectorId, decorationId, side: "bottom"} leaves through the diamond's bottom vertex — for the signed-out path.
Exporting a map with get_component (html) preserves its lines: wiremap trunk edges AND every interaction whose two ends live inside the exported subtree ship as inline <svg> — routes, colors, and labels intact.
Components
The reusable-design loop (the components guide covers the editor side). The rule that keeps this family small: instances are real subtrees — customize them with the ordinary tools (update_styles, set_text_content, move_nodes) and the edit self-records as a CUSTOMIZATION that survives updates; read the records back on get_node_info.component.overrides. The dedicated tools cover only what the generic ones can't: the library table, links, variant FACES, and the override craft.
The instance seal. Property edits are open anywhere inside an instance; STRUCTURE is not. Below an instance root, write_html, create_box, move_nodes (reparents and reorders — x/y-only moves are fine), delete_nodes, duplicate_nodes, and update_styles's layout.constraints all refuse, and the error names the sealing instance, the definition to edit instead, and the detach spell (update_instances {"updates": [{"id": …, "detach": true}]}). Restructure the definition and Bring instances into sync — or detach the one copy that needs to diverge. The instance ROOT itself is an ordinary layer: move it, delete it, duplicate it (that's how you stamp another copy). Faces are CSS-native — a face's NAME is its identity and compiles to a real selector (hover → :hover, size=lg → [data-size="lg"]), and each instance can show one (variant). Every write is one undo step (per page for book-wide instance sweeps).
| Tool | Arguments | Notes | |
|---|---|---|---|
get_component_info | componentIds? | the library in depth (omit ids for all): origin, the provenance event log, tags, implementations, each face with the CSS it compiles to and how many instances show it, the mounted face mid-edit, snapshot presence, and every instance site with sync status, customization count, and selected variant | |
create_component | nodeId, tags? | reify a box into the in-place definition (rename the box FIRST — rename_nodes batch-composes and the box and library row share one name); tags ride at birth because the minted id can't be batch-substituted | |
place_instances | instances: [{componentId, parentId?, index?, x?, y?, variant?}] | place linked instances (atomic batch): canvas roots follow the placement rule, parentId joins that box's flow, variant places the copy already showing a face; works from the snapshot after the definition died; self-nesting refuses | |
update_components | updates: [{componentId, name?, tags?, mountState?, renameStates?, deleteStates?, restore?, delete?}] | the library-side patch: name renames component + definition box together; tags REPLACES; mountState mounts a face for editing (mints new names; edit the mounted definition with the ordinary tools, then mountState: null saves and exits — don't leave the human in the mode); renameStates renames faces (instance selections follow); restore revives a snapshot-only definition; delete: true removes the component (instances detach; one ⌘Z restores all) | |
update_instances | `updates: [{id \ | componentId, variant?, swapTo?, pushOverrides?, resetOverrides?, detach?}]` | the instance-side verbs (one undo step per page touched): a bare {id} is Bring into sync — definition content in, customizations replayed, dead records pruned; variant shows a face by name (null = base); swapTo relinks to another component (customizations carry where their addresses resolve, the face maps by name); pushOverrides/resetOverrides ("all" or {address, path} selectors) promote customizations INTO the definition (siblings read out of sync — nothing auto-updates) or discard them; detach: true unlinks |
link_component_implementation | componentId, label?, url?, path?, commit?, note? | stamps design→code provenance (at least one of url/path); logs an implemented event — call it after scaffolding code from get_component |
Import & export — the web-standard loop
| Tool | Arguments | Notes |
|---|---|---|
write_html | html, mode?, targetId?, index?, x?, y?, page? | the main way to create designs: inline-styled HTML becomes fully editable nodes — divs/sections → boxes with genuine flex/grid config, text-only elements → text nodes, <img> → image, <svg> → svg node (sanitized verbatim markup, monochrome paints normalized to currentColor). Unmapped CSS is preserved as rawCss (rendering stays faithful) and reported in warnings. mode: "insert-children" (default) appends into targetId or the canvas root (x/y optional — default is the next slot beside the human's context; see Placement); "replace" swaps targetId's children. One undo step for the whole import. |
get_component | nodeId, framework? | the subtree as standalone code, riding the exact CSS mapping the editor renders with. framework: "html" (default) is a plain markup fragment with inline style="" attributes; "react" a function component with camelCase style objects; "solid" a function component with dash-case style objects (Solid's dialect — camelCase keys would silently not apply). Canvas position is stripped from the root so the output flows in a host layout. Returns {framework, componentName, code}. |
embed_images | ids | the durability offramp: every http(s) image in the subtrees — image fills and image nodes — is downloaded by the server (no CORS limits) and inlined as a data: URI, so the design survives the source site removing the asset, works offline, and copies between documents intact. One fetch per distinct URL; one undo step for the whole embed; failures (404, non-image content-type, >8MB) leave their srcs untouched and are itemized in the reply. Returns per-URL {mimeType, bytes, occurrences} plus alreadyEmbedded/notFetchable counts and documentBytesAdded — never the data URIs themselves. An explicit step for designs worth keeping, not a routine post-import pass (~4/3 of the image bytes lands in the document JSON per occurrence). |
Generate HTML, write_html it, refine with update_styles/move_nodes, then hand back get_component when the human wants code — the default html round-trips: get_component output is valid write_html input. Imported designs reference images by their original URLs; run embed_images only when the human wants the document to outlive the source site.
Look
| Tool | Arguments | Notes |
|---|---|---|
get_screenshot | nodeId?, scale? (1 or 2) | chrome-free PNG of one node — selection, guides, and the Crafting… shimmer are hidden; the camera pans the node into view and restores after. **Omit nodeId to capture whatever your last write touched**, which is what makes [write_html, get_screenshot] work as one batch. Two capture paths: see below. |
Live-tab path (exact pixels, fast). Used when the editor tab lives in a Chrome started with remote debugging on the instance's debug port — the web port + 6333, so 9333 for the default port 3000:
open -a "Google Chrome" --args --remote-debugging-port=9333
Chrome 136+ blocks DevTools on the default profile, so a normally-launched browser never qualifies — that's what the fallback is for.
Headless fallback (any browser, ~2–4 s slower). When the live tab is unreachable over the debug port — or the node lives on a page the human isn't viewing (the live tab can't photograph the hidden measurement stage) — the server pulls the canvas through the bridge, re-renders it in a throwaway headless Chrome served by the same Centina process with that page active, and clips the same screenshot there. Same engine, same bundle, same fonts — and the human's session is untouched: a capture tab never dials the bridge (newest-tab-wins is not disturbed) and never reads or writes the autosave. The fallback needs headless Chrome available on the serving machine; the editor tab merely needs to exist in any browser.
Presence
| Tool | Arguments | Notes |
|---|---|---|
finish_working_on_nodes | ids? | clears the "Crafting…" shimmer (all nodes when omitted); call after finishing a run of edits — as the last entry of your final batch, not a round trip of its own |
Everything at once
| Tool | Arguments | Notes |
|---|---|---|
batch | calls: [{tool, args?}], stopOnError? | runs up to 20 calls in order in one round trip — the default way to work. Same undo steps, same semantics, fewer turns. Validated whole before any of it runs; a runtime failure stops the rest unless stopOnError: false. Images arrive after the JSON. Can't nest, and can't substitute ids created earlier in the same batch. Full section above. |
Troubleshooting
- "Centina isn't running at http://localhost:…" — nothing answers at that address. Start it:
centina serve(add--portif the reference names a non-default port), or open the Centina desktop app for 8462.centina mcpwith no--portlooks for both; pin one withcentina mcp --port <port>. - "No Centina window is open at http://localhost:…" (in the desktop app: "The Centina desktop app has no canvas window open") — the server runs but no window is attached to its bridge. Open the URL in a browser (the page only connects on
localhost/127.0.0.1) or open the app; or you opened a newer window that took over and later closed it: reload the window you want. - An agent asks for an "active tab" — it is paraphrasing the message above. Nothing needs setup: open (or reload) Centina at that address — a browser tab or the desktop window — and only the newest one carries the connection.
- "ids name N different Centina instances" — one call routes to one window; split the call per instance.
- "the … port is already in use" at
centina servestartup — another Centina (or another app) owns the web or bridge port; stop it or pick another base port (centina serve --port 4500). - Screenshots black or wrong region — the node was offscreen and the pan failed to settle; re-run. File it if it reproduces.
- Style writes rejected —
update_stylesspeaks the model schema (fills,radius,layout.width: {mode}…), not CSS property names. Errors list every offending path; nothing was applied. TherawCssgroup is the escape hatch for arbitrary CSS — but its values are validated by the rendering browser (CSS.supports), so a declaration Chrome can't parse is rejected rather than silently rendering nothing (custom--*props exempt).