Rendering

Zindex renders scenes to multiple output formats. The rendering pipeline normalizes the scene, computes layout, then compiles to the requested format.

Building an agent integration? See How AI Agents Should Use Zindex for the recommended workflow for scene creation, incremental edits, revision handling, validation, normalization, and rendering.

Render themes

Zindex supports 4 render themes that control colors, typography, and rendering style. Themes are applied at render time - the canonical scene data is unchanged.

Pass "theme" in the render request:

POST /v1/scenes/:id/render
{ "format": "svg", "theme": "dark" }

Available themes

ThemeBackgroundStyleFontDescription
cleanWhite (#ffffff)Clean linesInterProfessional, crisp. Default when no theme specified.
darkDark (#1a1a22)Clean linesInterDark mode with warm amber and green-cyan accents
blueprintNavy (#0d1b2a)Clean linesJetBrains MonoTechnical/engineering aesthetic with cyan strokes
sketchDark (#1a1a22)Hand-drawnComic NeueHand-drawn aesthetic with wobbly lines on dark background

Theme resolution cascade

Styles resolve in priority order (highest wins):

  1. Element explicit style (inline object or named registry reference)
  2. Scene palette (scene-level palette field)
  3. Theme defaults (palette colors, typography, stroke settings)
  4. Hardcoded fallback (white fill, dark stroke)

Element-level styles always win. The scene palette overrides theme defaults for unstyled elements - useful when agents want a consistent color scheme without styling every element individually.

Auto-detection from canvas background

When no theme is passed and scene.canvas.background parses as a dark color (perceived luminance < 102 on the 0-255 scale, e.g. #06080d, #1a1a22, rgba(6, 8, 13, 1)), the renderer auto-selects the dark theme so strokes and labels remain legible against the dark canvas. Without this, a clean-theme #333 stroke on a near-black canvas is nearly invisible.

Rules:

{
  "palette": {
    "nodeFill": "#003366",
    "nodeStroke": "#001a33",
    "edgeStroke": "#004488",
    "foreground": "#ffffff",
    "background": "#f0f4f8"
  }
}

Palette keys match the theme’s internal palette: background, foreground, nodeFill, nodeStroke, edgeStroke, containerFill, containerStroke, accentPrimary, accentSecondary, mutedForeground. All keys are optional - only the ones you set override the theme.

Text contrast

The engine auto-computes a contrast-appropriate text color when the user customizes fill (via element style, named style, or palette) but does NOT set textColor. This prevents illegible text like black-on-dark-fill when an agent picks a custom fill.

Sketch mode

The sketch theme uses a deterministic hand-drawn renderer with seeded randomness - the same scene always produces the same output. Shapes are rendered with wobbly lines and jittered control points instead of clean SVG primitives. Sketch mode uses seeded randomness (mulberry32 PRNG) - the same scene always produces the same hand-drawn output, ensuring deterministic rendering.

Supported formats by surface

FormatHTTP APIMCP
SVGyesyes
PNGyesyes

Formats

FormatMIME TypeOutputUse case
SVGimage/svg+xmlText stringWeb display, embedding, vector editing
PNGimage/pngBase64-encodedDocumentation, sharing, fixed-resolution

SVG

The default format. Produces a self-contained SVG document.

{
  "scene": { ... },
  "format": "svg"
}

Response:

{
  "output": {
    "mimeType": "image/svg+xml",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>"
  }
}

Rendering pipeline:

  1. Background rectangle from canvas configuration
  2. Elements sorted by z-index
  3. Node shapes rendered (11 shapes: rect, roundedRect, ellipse, diamond, cylinder, pill, hexagon, parallelogram, cloud, textBox, iconBox)
  4. Node labels with text wrapping
  5. Edge paths with routing (straight, orthogonal, curved, polyline)
  6. Arrow markers (triangle, diamond, circle, bar, none)
  7. Groups and frames with borders
  8. Text, image, and guide elements

Styles applied: fill, stroke, strokeWidth, cornerRadius, fontFamily, fontSize, fontWeight, textColor, dash patterns, opacity, label backgrounds.

Visual diff rendering

Render a color-coded comparison between two revisions of a scene. Added elements appear in green, removed elements as red dashed ghosts, modified elements in amber, and unchanged elements are muted.

POST /v1/scenes/my-diagram/render
{
  "format": "svg",
  "diff": { "fromRevision": 1 }
}
Change typeVisual treatment
AddedGreen solid border (#22c55e, 3px), normal opacity
RemovedRed dashed border (#ef4444, 2px), 40% opacity ghost
ModifiedAmber solid border (#f59e0b, 3px), normal opacity
UnchangedOriginal styling at 50% opacity (muted)

Removed elements are merged back from the “from” revision and rendered at their original positions. A color legend appears in the bottom-left corner.

Works with all render themes and PNG output. Via MCP: pass diffFromRevision to dsp_render_scene.

Icon rendering

Nodes with an icon field render a semantic icon inside the node shape, positioned above the label.

Cloud-provider icons render icon-only by default

Nodes with an icon prefixed aws:, gcp:, or azure: render without a bounding rectangle by default - just the icon glyph and the label below. This matches the official AWS Architecture Icons guidance and the convention used by every professional tool that renders cloud architecture diagrams (Cloudcraft, Lucidchart’s AWS shapes, draw.io’s AWS shape library): cloud service icons are visually self-identifying, so a bounded box adds noise without information. More importantly, an icon-only node composites cleanly over a colored container fill (VPC, subnet, region) - a white knockout under each service would block the container’s color.

Default-equivalent fill: an explicit fill: "#ffffff" (or #fff, white, rgb(255, 255, 255), or the binary rgba(255, 255, 255, 1) / rgba(255, 255, 255, 0) endpoints) on a cloud-icon node is treated the same as “fill not set” and triggers the icon-only override anyway. Translucent intermediate alpha values (rgba(255, 255, 255, 0.85) and similar) are PRESERVED as deliberate “card-style” tinting - the agent picked a specific alpha for a reason and overriding it would strip the AWS-reference card-on-subnet aesthetic. This catches defensive default styling from agents (Python codegen, LLM-generated JSON) that emit fill: "#ffffff" on every node without thinking about the AWS convention - the white knockout would override the platform’s intended icon-only render. The whole defensive-default style block (fill + stroke + strokeWidth) is treated as a unit: when fill is default-equivalent, stroke is also dropped so the icon renders cleanly.

Opting back into a bounded look: set fill to any non-white color. A tinted fill (e.g. #ffeeaa, #dde9fb, the platform’s near-white #fefefe) restores the bounding rect with the chosen color. To get a bordered icon with a transparent interior (line outline, no fill knockout), set fill: "transparent" or fill: "none" together with the stroke you want - explicitly opting out of “fill is just defensive” semantics.

Generic non-cloud icons (lucide:* and others) keep the bounded-box default - they’re used in mixed contexts where a visible boundary matters.

Module / group frames inside colored containers

The default-equivalent rule extends to frames: a frame or group whose fill is default-equivalent (#ffffff, #fff, white, rgb(255, 255, 255), or the binary rgba(255, 255, 255, 1) / rgba(255, 255, 255, 0) endpoints) AND that sits inside a colored ancestor frame gets its fill overridden to "none". Translucent intermediate alphas (e.g. rgba(255, 255, 255, 0.85)) are PRESERVED - they’re the AWS-reference “card-on-subnet” aesthetic where each module reads as a slight tint over the colored container. This matches the cloud architecture convention where module/group frames are dashed boundaries that let the parent container’s color show through (Identity Provider Module, AWS API Proxy Module, etc. inside a tinted Private Subnet).

The override only fires when there’s a colored ancestor in the chain - top-level frames or frames inside transparent ancestors keep their fill (a near-white frame on a white canvas is invisible anyway). The frame’s stroke (typically dashed, marking the module boundary) is preserved - only the fill is overridden, since the stroke is the boundary marker the agent legitimately set.

Workflow rendering

When nodeType uses workflow family types, the renderer produces BPMN-inspired shapes:

nodeTypeRenderingLabel position
workflow.startFilled circle (BPMN start event)External, below the circle
workflow.endBold-stroke circle (BPMN end event, stroke-width >= 3)External, below the circle
workflow.gatewayDiamond with x marker (exclusive gateway)External, below the diamond
workflow.gatewayParallelDiamond with + marker (parallel gateway)External, below the diamond
workflow.gatewayInclusiveDiamond with circle marker (inclusive gateway)External, below the diamond
workflow.timerCircle with clock-hand lines (timer event)External, below the circle
workflow.errorCircle with zigzag path (error event)External, below the circle
workflow.taskFalls through to generic shape (roundedRect)Inside the shape
workflow.subprocessFalls through to generic shape (roundedRect)Inside the shape
workflow.decisionDiamond, sized to inscribe the labelInside the diamond

The four workflow.gateway* types use the BPMN external-label convention because their diamond interiors carry a marker glyph (x, +, circle, pentagon) and there is no room for prose. workflow.decision is the marker-less alternative, sized so the label fits inside the diamond’s inscribed rectangle - matching the broader flowchart convention used by Mermaid, draw.io, Lucidchart, yEd, and PlantUML.

Workflow edge rendering:

edgeTypeLine styleEnd arrowheadStart arrowhead
workflow.sequenceFlowSolid lineFilled triangleNone
workflow.messageFlowDashed line (stroke-dasharray="8,4")Open triangleOpen circle
default (no family override)Solid lineNoneNone

For workflow diagrams, prefer workflow.sequenceFlow over plain default for sequential edges - it auto-applies the directional triangle arrowhead so a reader can follow the flow without reading every label. Plain default renders an undirected line.

ER diagram rendering

Entity-Relationship diagrams get specialized rendering when using ER family types:

ER node rendering

nodeTypeRendering
er.entityStandard rect (generic shape)
er.weakEntityDouble-border rect (outer + inner rect with 4px inset)
er.attributeEllipse (generic shape)
er.relationshipDiamond (generic shape)

Crow’s-foot cardinality markers

When edge endpoints include a cardinality field, the renderer produces crow’s-foot notation markers instead of standard arrows:

CardinalityMarkerNotation
one|Single vertical line
many<Three-line fork (crow’s-foot)
zero-or-one○|Circle + vertical line
zero-or-many○<Circle + fork
one-or-many|<Vertical line + fork

Cardinality markers are specified per-endpoint, so from: { cardinality: "one" } and to: { cardinality: "many" } produces a one-to-many relationship.

When cardinality is omitted on an endpoint and the endpoint resolves to a known column (via endpoint.column or the PK fallback for entities with a single primary key), the engine infers the cardinality from the column’s pk / fk / unique / nullable flags. Author-set values are always preserved. See Crow’s Foot cardinality (auto-inference) for the full rule.

Self-loops skip glyph rendering. When an edge’s source and target are the same entity (e.g. a parent_id-style self-FK), the renderer omits Crow’s Foot glyphs on both endpoints. The U-bracket geometry that draws self-loops is too tight to fit two readable markers; the column-row position inside the entity already conveys the relationship’s direction.

Sequence diagram rendering

When diagramFamily is "sequence", the layout engine positions lifelines horizontally and orders messages vertically by their position in the elements array (definition order = visual order).

Lifeline rendering

nodeTypeRendering
sequence.lifelineRect header box at top + dashed vertical tail + matching rect foot bar at bottom
sequence.actorStick-figure (circle head, body lines) at top + dashed vertical tail + mirrored stick-figure foot at bottom
sequence.noteRounded rect with a folded top-right corner; theme-aware soft yellow fill

Lifeline head and foot. Each lifeline renders both a head bar at the top and a matching foot bar at the bottom of the diagram by default - UML convention also followed by PlantUML, Mermaid, and yEd. The dashed lifeline tail terminates exactly at foot-top. To suppress foot bars (e.g. when the author has their own bottom row of shapes), set scene.extensions.showLifelineFeet: false.

Header auto-sizing and content-aware pitch. Lifeline header width is the larger of the 100 px floor and the measured label width (with padding). Header height is also a floor: single-line labels keep the 40 px default, multi-line labels (e.g. "Compute Endpoint\n(Metadata)") grow the header rect to fit the line block plus symmetric vertical padding. All lifelines on a diagram render at the same effective height (the max across the diagram’s lifelines) so dashed-tail starts align horizontally; foot bars (non-actor) mirror the head height. Multi-line text within the header renders as per-line <tspan dy> so all SVG consumers display the line breaks portably (a single <text> with literal \n was browser-dependent). Lane pitch is computed per pair: each adjacent lifeline pair gets the larger of the equal-share floor, the header crowding guard (16 px gutter past auto-sized headers), and the self-message arc clearance - so a single wide self-message on one lifeline doesn’t push every other gap wider too. The canvas auto-extends if total content exceeds the declared canvas width.

First-message clearance. The first message arrow sits below the lifeline header band with enough clearance that its label (placed perpendicular-above by the placer) doesn’t dip into the header zone. The header band itself is an obstacle in the label placer’s spatial index; subsequent labels also avoid it.

Message rendering

edgeTypeRendering
sequence.messageSolid line with filled arrowhead (synchronous call)
sequence.replyDashed line with arrowhead (return value)
sequence.createDashed line with filled arrowhead

Self-messages (from and to referencing the same lifeline) render as U-shaped loops with rounded interior corners (PlantUML / Lucidchart convention). The loop’s arc width auto-sizes to fit the message label centred above the top arm with clearance from the source lifeline body - agents don’t need to estimate label width or shorten labels to fit a fixed loop.

Lane and pool rendering

Frames with containerType produce visually distinct containers:

containerTypeRendering
poolSolid border, title in header band with separator line
laneSolid border, simple title
generic (default)Dashed border

Pool frames with laneDirection distribute their lane children evenly:

The pool reserves 24px for the title header before dividing the remaining space among lanes.

PNG

Converts SVG to PNG using resvg-js (Rust/WASM). No browser required.

{
  "scene": { ... },
  "format": "png"
}

Response:

{
  "output": {
    "mimeType": "image/png",
    "contentBase64": "iVBORw0KGgo...",
    "width": 1200,
    "height": 800
  }
}

PNG rendering supports a scale option for retina output (e.g., scale: 2 produces 2x resolution).

Rendering via API

Stateless render (public, no auth)

Renders an inline scene without persisting:

curl -s https://api.zindex.ai/v1/scenes/render \
  -H "Content-Type: application/json" \
  -d '{
    "scene": {
      "schemaVersion": "0.1",
      "scene": { "id": "demo", "units": "px", "canvas": { "width": 800, "height": 400 } },
      "elements": [
        { "id": "n1", "kind": "node", "nodeType": "service", "shape": "roundedRect",
          "label": "API", "layout": { "mode": "absolute", "x": 50, "y": 50, "width": 200, "height": 80 } }
      ]
    },
    "format": "svg"
  }'

Stored scene render (auth required)

Renders a previously created scene:

curl -s https://api.zindex.ai/v1/scenes/my-diagram/render \
  -H "Authorization: Bearer dsp_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "format": "svg" }'

MCP render

Via the dsp_render_scene tool:

{ "sceneId": "my-diagram", "format": "svg" }

Render diagnostics

The render response may include a diagnostics array alongside the output. Diagnostics report non-fatal issues detected during rendering - the image is still produced, but something may not look right.

{
  "output": { ... },
  "diagnostics": [
    {
      "code": "TEXT_OVERFLOW",
      "severity": "warning",
      "elementId": "n1",
      "message": "Text overflows element bounds (needs 120px, has 80px)",
      "data": {
        "requiredHeight": 120,
        "availableHeight": 80
      }
    }
  ]
}

TEXT_OVERFLOW

Emitted when wrapped text overflows the bounds of its containing element. This happens when a label is too long for the node’s height at the current font size, or when an icon + label stack doesn’t fit in an explicitly sized node.

FieldTypeDescription
code"TEXT_OVERFLOW"Diagnostic identifier
severity"warning"Severity level
elementIdstringThe id of the element whose text overflowed
messagestringHuman-readable description
data.requiredHeightnumberThe height (px) the text needs to render fully
data.availableHeightnumberThe height (px) actually available inside the element

CANVAS_AUTO_EXTENDED

Emitted by the layout engine when the declared scene.canvas was too small to fit the computed layout and had to be grown. Declared canvas dimensions are a minimum, not a hard cap; the engine auto-extends scene.canvas.{width,height} to fit content (declared + 40px content margin) and surfaces this diagnostic so agents can see it happened. The SVG always renders against the auto-extended canvas - content is never silently clipped.

FieldTypeDescription
code"CANVAS_AUTO_EXTENDED"Diagnostic identifier
severity"info"Severity level (informational, not a warning)
path"/scene/canvas"Points at the scene-level canvas property
messagestringHuman-readable description naming the before/after dimensions
data.declaredWidthnumber | nullThe canvas.width the agent declared, or null if width was omitted
data.declaredHeightnumber | nullThe canvas.height the agent declared, or null if height was omitted
data.widthnumberThe final auto-extended canvas.width returned in scene.canvas
data.heightnumberThe final auto-extended canvas.height returned in scene.canvas
data.extendedDimensions("width" | "height")[]Which dimensions were extended (one or both)

If you want the declared size to remain authoritative for downstream consumers (e.g. clients that read scene.canvas to size their own viewport), persist the auto-extended dimensions back to your scene via updateScene so the next read returns them as the declared values.

CANVAS_AUTO_TIGHTENED

Emitted by the layout engine when the declared scene.canvas.height was reduced below the value the agent declared. This is the only path where the engine reduces a canvas dimension below a declared value. Trip conditions: content is clearly wide-and-flat (aspect ratio above 2.5) AND the declared height left more than 30% slack over what the content actually needs. The engine caps height at content-height x 1.4 to avoid rendering with 60-70% vertical whitespace - common when an agent declares a generic 1600 px height for a long left-to-right workflow.

FieldTypeDescription
code"CANVAS_AUTO_TIGHTENED"Diagnostic identifier
severity"info"Informational, not a warning
path"/scene/canvas"Points at the scene-level canvas property
messagestringHuman-readable description naming the before/after height and content aspect ratio
data.declaredHeightnumber | nullThe canvas.height the agent declared, or null if height was omitted (in which case this diagnostic does not fire)
data.heightnumberThe tightened canvas.height returned in scene.canvas
data.contentWidthnumberContent extent width (maxX - minX)
data.contentHeightnumberContent extent height (maxY - minY)

Agents can detect this and, if they want a taller canvas, widen the declared height back to its original value AND reduce the declared width or rebalance the layout so content is no longer wide-and-flat. Otherwise persist the tightened value via updateScene so subsequent reads return the engine’s preferred size.

EDGE_LABEL_SUPPRESSED_REDUNDANT

Emitted by the normalizer when one or more edges had FK-named labels auto-promoted to column-row anchors. An edge whose label exactly matches the name of a column declared in either endpoint’s extensions.columns is automatically promoted: endpoint.column is set to the matching column name on the relevant side(s), the edge anchors at the named column row inside the entity (matching the dbdiagram.io / DBeaver convention), and the label is dropped because the column position now carries the FK identity. The diagnostic enumerates which edges were affected so agents can audit the engine’s decision.

FieldTypeDescription
code"EDGE_LABEL_SUPPRESSED_REDUNDANT"Diagnostic identifier
severity"info"Informational, not a warning
path"/elements"Points at the elements array (scene-level diagnostic)
messagestringHuman-readable description naming the count and a sample of suppressed labels
data.suppressedEdgeIdsstring[]Every edge ID whose label was auto-promoted
data.suppressedLabelsstring[]Sorted unique list of the label values dropped

To keep a label visible on a specific edge (for example when the label is a genuine annotation rather than a FK column name), set style.forceLabel: true on the edge - the auto-inference skips edges with this opt-out. Authors writing new scenes can also set endpoint.column directly and skip the label-matching step entirely.

EDGE_COLUMN_NOT_FOUND

Emitted when an edge’s endpoint.column references a column that does not exist in the endpoint entity’s extensions.columns. The renderer falls back to face-centre anchoring on that endpoint, so the diagram still renders, but the agent’s intent (anchor at the named column) was not honoured.

FieldTypeDescription
code"EDGE_COLUMN_NOT_FOUND"Diagnostic identifier
severity"warning"Warning level
path/elements/<edgeId>/<from|to>/columnPoints at the offending endpoint
messagestringHuman-readable description naming the column and the available columns on the entity

The diagnostic message lists the columns actually declared on the entity so the agent can pick a real one. Common causes: typo in the column name, the entity uses the alternate columns shape ({key: "FK", name}) and the agent referenced the wrong field, or the column was renamed but a stale edge still points at the old name.

EDGE_LABEL_SUPPRESSED_FANIN

Emitted when 2+ edges share an exact label string AND a target endpoint. The visual symptom WITHOUT this rule would be a stack of identical labels at the converging node (e.g. four REFUND_FIRED edges all terminating at a REFUNDED state, or three read edges all going to the same database). The platform handles this by rendering the label once on the lowest-id edge per group and dropping it from the others - the arrows themselves remain intact, so the “many flows converge here” structure is preserved without the redundant text.

This is structurally distinct from EDGE_LABEL_SUPPRESSED_REDUNDANT: there’s no underlying column or other carrier - the convergence itself is the redundancy. The rule is family-gated to skip sequence diagrams, where same-label messages between the same actors are temporally distinct events (request/reply round-trips, retries) that must remain visible.

FieldTypeDescription
code"EDGE_LABEL_SUPPRESSED_FANIN"Diagnostic identifier
severity"info"Info level - no error condition
path/elementsScene-level scope
messagestringHuman-readable description naming the suppressed edge count and group count
data.suppressedEdgeIdsstring[]All edge IDs whose labels were dropped
data.groupsobject[]One entry per (target, label) group with targetId, label, electedEdgeId, suppressedEdgeIds

To keep a label visible on a specific edge, set style.forceLabel: true on that edge - it never participates in the group at all (so it neither suppresses nor is suppressed). To break the convergence, distinguish the labels via updateEdge (e.g. REFUND_FIRED [orderRefund] vs REFUND_FIRED [subscriptionRefund]).

LABEL_DUPLICATION_DETECTED

Emitted when 2+ edges share identical label text AND converge on a shared source node. The visual symptom is a stack of identical labels emitted from one node (e.g. an api_gateway emitting three edges all labelled route to different downstream services). The engine renders all labels faithfully because the edges are real and distinct, but the result reads as duplication rather than as N distinct routes. Whether to merge the edges, distinguish the labels, or accept the duplication is a semantic call only the diagram author can make, so the engine surfaces the pattern as a diagnostic and lets the agent decide.

The shared-target case is handled silently by EDGE_LABEL_SUPPRESSED_FANIN above and does NOT trigger this diagnostic.

FieldTypeDescription
code"LABEL_DUPLICATION_DETECTED"Diagnostic identifier
severity"warning"Severity level
messagestringHuman-readable description naming the edge IDs and shared source
data.labelTextstringThe exact (case-sensitive) label text that appears on every edge in the cluster
data.sharedEndpoint.kind"source"Always "source" after the fan-in rule landed
data.sharedEndpoint.elementIdstringThe element ID of the shared source node
data.edgeIdsstring[]All edge IDs in the duplicate-label cluster (≥2)

Self-loops with duplicate labels are intentionally included; they almost always indicate an authoring mistake. Detection is case-sensitive - “Read” and “read” are treated as distinct.

The recommended remediation is to distinguish the labels via updateEdge (e.g. “route to users” vs “route to orders”) so the diagram is unambiguous. Alternatively, if the edges represent the same flow, merge them; if both labels are intentional and clarity is acceptable, ignore the diagnostic.

LAYOUT_ABSOLUTE_AT_ORIGIN

Emitted when one or more nodes declare layout.mode: "absolute" with default coordinates {x: 0, y: 0} - incoherent input. An author writing absolute-mode positioning is asking the engine to honour explicit coordinates, but the literal (0, 0) is the structural default for missing fields, so the engine cannot tell whether the author meant “pin this at the origin” or “I forgot to provide coordinates.”

The engine handles two flavours of this silently today, both surfaced by this single diagnostic:

In neither case is the layout block load-bearing. The fix is the same: remove the layout block from those nodes and rely on layoutStrategy at the scene level for clean auto-layout.

FieldTypeDescription
code"LAYOUT_ABSOLUTE_AT_ORIGIN"Diagnostic identifier
severity"info"Severity level - non-blocking, informational
path"/elements"JSON pointer to the elements array
messagestringHuman-readable description naming a sample of affected element IDs and the engine’s chosen behaviour
data.affectedElementIdsstring[]Sorted list of every node ID with layout.mode: "absolute" and (x, y) = (0, 0)
data.allAtOriginbooleanTrue when every absolute-mode node is at the origin and the engine fell back to whole-scene auto-layout; false when only a subset are at the origin and those nodes were pinned at (0, 0)

A deliberate (0, 0) pin is technically possible but rare. If you genuinely need to anchor a node at the origin, set layout.x: 0.0001 or use a nearby coord - the diagnostic only fires on literal zeros, since the literal-zero pattern is overwhelmingly an agent-authoring mistake.

MISSING_DIAGRAM_FAMILY

Emitted when the scene omits the top-level diagramFamily field (or sets it to an empty string). The field is technically optional in the JSON schema, but it is effectively load-bearing: it gates family-specific behaviour the engine and downstream tooling rely on. Concrete examples of behaviour that depends on diagramFamily:

Without diagramFamily, the engine treats the scene as the generic-flowchart base case and downstream tooling cannot segment by diagram type. The fix is to declare it on every scene at creation time (dsp_create_scene) or via the updateScene operation on an existing scene.

FieldTypeDescription
code"MISSING_DIAGRAM_FAMILY"Diagnostic identifier
severity"info"Severity level - non-blocking, informational
path"/diagramFamily"JSON pointer to the missing field
messagestringHuman-readable description listing the allowed values and the rationale
data.allowedValuesstring[]The seven valid family values: architecture, workflow, entityRelationship, sequence, network, orgchart, uiflow

OUTER_FIELD_NESTED

Emitted when one or more document-root fields - layoutStrategy, diagramFamily, palette - were authored inside scene.scene instead of at the outer document root. The canonical placement (per scene.schema.json) is the document root alongside schemaVersion, scene, and elements. The normalizer accepts the nested form as a fallback so the scene still renders correctly, but downstream tooling that reads the scene against the schema (validators, code generators, type-checked client SDKs) won’t find the field. The diagnostic tells the agent to relocate it via updateScene.

Common authoring mistake: agents read “scene-level” or “set on the scene” in the docs and place the field inside scene.scene (the metadata sub-object) instead of at the outer document root. The normalizer’s fallback prevents silent failure - the field is read either way - but the schema-canonical placement is the document root.

FieldTypeDescription
code"OUTER_FIELD_NESTED"Diagnostic identifier
severity"info"Severity level - non-blocking, informational
path"/scene"JSON pointer to the metadata sub-object holding the misplaced field(s)
messagestringHuman-readable description naming the misplaced fields and the canonical placement
data.nestedFieldsstring[]Names of the fields that were nested (subset of layoutStrategy, diagramFamily, palette)

Using diagnostics for automated correction

Agents can inspect the diagnostics array after a render to detect and fix layout problems in one shot, without visual inspection. For example, when a TEXT_OVERFLOW diagnostic is returned, the agent can read requiredHeight from data, resize the element to fit, and re-render - no human review needed. For CANVAS_AUTO_EXTENDED (info severity), the diagram already renders correctly against the auto-extended canvas so no fix is required; if the agent wants the declared scene.canvas to remain authoritative for downstream consumers, it can read data.width/data.height and call updateScene to persist them. For CANVAS_AUTO_TIGHTENED (info severity), the engine reduced a too-generous declared height to fit wide-and-flat content with comfortable margins; the diagram renders correctly against the tightened canvas - persist via updateScene to make the new height authoritative, or widen the declared height back if a taller canvas was intentional. For EDGE_LABEL_SUPPRESSED_REDUNDANT (info severity), the engine has already promoted the named edges to column-row anchors and dropped their labels - no fix needed unless the agent wants to keep a specific label visible, in which case set style.forceLabel: true on that edge via updateEdge. For EDGE_LABEL_SUPPRESSED_FANIN (info severity), the engine has already dropped the redundant labels and rendered one per group on the lowest-id edge - no fix needed unless the convergence wasn’t intentional, in which case rename one of the duplicates or set style.forceLabel: true on a specific edge. For EDGE_COLUMN_NOT_FOUND: read data to identify the offending endpoint, then updateEdge to either correct the endpoint.column value or remove it (the renderer falls back to face-centre anchoring). For LABEL_DUPLICATION_DETECTED: read data.edgeIds and data.labelText, then call updateEdge on one of the edges with a more specific label (e.g. “route to users” instead of “route”) so the rendered diagram reads unambiguously. For LAYOUT_ABSOLUTE_AT_ORIGIN (info severity): read data.affectedElementIds, then call updateNode on each to remove the layout block - the scene-level layoutStrategy will auto-position those nodes cleanly. If data.allAtOrigin: false, the named nodes are currently overlapping other content at (0, 0), so the fix is more urgent than when true (where the output already renders correctly). For MISSING_DIAGRAM_FAMILY (info severity): pick the right family from data.allowedValues based on what the scene represents and call updateScene (or set it at scene creation time via dsp_create_scene). For OUTER_FIELD_NESTED (info severity): read data.nestedFields, remove the named fields from scene.scene, and re-add them at the outer document root via updateScene. The render output is already correct (the normalizer’s fallback handles the read) - this is a schema-conformance fix. This makes diagnostics the primary feedback loop for programmatic scene editing.

Edge label typography

State-machine convention: EVENT [guard] / action

When an edge label matches the UML / SCXML / BPMN convention EVENT [guard] / action, the renderer splits it into three styled <tspan> runs inside one <text>: the event name in the standard 12px edge fill, the bracketed guard in 10px muted (palette.mutedForeground per theme), and the action segment in 10px muted italic. The convention is shared by PlantUML, Stateflow, XState, Camunda Modeler, and the wider state-machine tooling ecosystem.

Detection is content-based, not gated on diagramFamily - any edge label containing balanced [...] OR a / separator (slash with surrounding spaces) gets the styled-runs treatment. Pure event names, free-form labels, and labels without these markers render as plain text per the existing path. Multi-line labels (containing \n) skip the convention parse and use the existing line-wrapped tspan path.

The width measurer (@zindex/dsp-measure) sums per-segment widths at the matching font sizes plus a 6px inter-segment gap, so layout reservation matches what gets drawn - no surprise overflow into adjacent edges.

To opt out for a specific edge (e.g. when an author wants uniform typography on a label that happens to match the convention), insert a newline so the parser falls through to the multi-line path.

Edge routing

Four routing algorithms determine how edges connect elements:

RouterDescription
straightDirect line between source and target centers
orthogonalRight-angle path with horizontal and vertical segments
curvedSmooth quadratic Bezier with a perpendicular-offset control point
polylineMulti-segment line through waypoints

The author-set value is a preference; the engine’s actual decision is recorded on computed.edgePaths.<id>.router and may differ - see the Long-haul back-edge curve fallback subsection below for the one case the engine flips an authored orthogonal to curved.

Source-aligned target anchors

The orthogonal router selects a target anchor on the side of the target that faces the source. By default the anchor sits at the center of that side. When the source’s perpendicular coordinate falls INSIDE the target’s perpendicular extent (e.g. source X inside the target frame’s X range, with the source above or below the target), the anchor instead snaps to the source’s coordinate on the target’s facing edge - so the route collapses to a clean straight line entering the target perpendicular to the source’s exit direction.

Without this, a frame-targeted edge from a source whose X is inside the frame’s X range would produce an L-path with a terminal segment running ALONG the frame’s top edge (from source-X to frame-center-X) before “entering” at the cardinal anchor - visually reading as “the arrow hugs the frame border before the middle.” The source-aligned anchor eliminates that artifact: the route is a single straight vertical line entering the frame at source-X.

This is critical for cloud architecture diagrams where edges target frames (efs_shared → file_systems for the storage pool, web_application → cluster_manager_module for a logical group) and the source frequently sits above the frame within its X range. No agent intervention required - the router picks the anchor automatically.

Multi-bend obstacle-aware routing (corridor-graph A*)

Simple L-paths and 2-bend step paths can’t always navigate dense layouts. When every L-path candidate AND every step-path candidate from rankedAnchorCandidates crosses one or more obstacles, the router falls through to a corridor-graph A* search before accepting an obstacle-crossing fallback.

The algorithm - the same one used by libavoid (Inkscape, draw.io, dia, yEd) and Graphviz neato/circo - builds a visibility graph from obstacle corners (expanded outward by 8 px clearance), connects every vertex pair sharing an axis with a clear segment, then A*-searches with a manhattan heuristic and a 20-px bend penalty. The result is a multi-bend orthogonal polyline through clear space.

Triggers only when needed (clean simple paths still win in the common case), so for typical scenes the additional cost is zero. For dense scenes with corridor-graph routing per edge: ~5-20 ms each. The cap at 5,000 A* expansions guarantees the fallback never hangs even on pathological inputs.

This is a pure platform improvement - agents make no changes. Edges that previously punched through icons in dense layouts now route cleanly through clear corridors.

Long-haul back-edge curve fallback

Even with corridor-graph A*, the orthogonal router can find a path so much longer than the straight-line manhattan distance that the result reads as visual noise rather than as routing - typically because the dense middle of the canvas was blocked and the only legal corridor ran along the canvas top or bottom. The classic case is a state-machine “back-edge” (e.g. REVIEW_REJECTED → GENERATING for a retry transition), which sweeps from one end of the canvas to the other and produces a giant decorative arc through unused space.

When the chosen orthogonal path is ≥1.5× the manhattan distance between endpoints AND has ≥4 corners, the engine substitutes a curved 2-point path. The substitution applies whenever the author hasn’t pinned the edge to router: "orthogonal" or router: "straight" (so the default unpinned case and an authored polyline both qualify). The replacement is an SVG quadratic Bezier rendered with the control point offset perpendicular to the source-target line by 0.3 × the euclidean distance, swinging in a consistent direction so multiple back-edges read as a coherent loop-back convention. Convention shared by Camunda Modeler, BPMN.io, and draw.io for retry / loopback edges.

The engine’s actual routing decision is recorded on computed.edgePaths.<id>.router ("polyline" or "curved"); agents inspecting that field will see "curved" on edges they authored as orthogonal after this fallback fires. Pin to router: "orthogonal" to opt out.