Porting a design into Sutra
This guide is for clients (typically an LLM) that design a page in HTML/CSS and port it
into Sutra's node format via the Design API. The goal is reasonable parity — the Sutra
page should faithfully reflect the design, not be pixel-identical.
Design freely first (HTML/CSS is the richest medium and avoids template sameness), then map
to nodes. Do not copy Sutra templates — compose from the primitives below.
Authoritative capabilities
Always read GET /design/capabilities (or the get_design_capabilities MCP tool) for the
live, generated contract: every node's real attribute set, enumerated values (allowed_values),
the supported styles keys, the marks, and the fonts Sutra actually loads. This guide is the
how; the capabilities endpoint is the what — it never drifts from the renderer.
The Sutra-portable profile (the one design constraint)
Sutra renders a constrained flex/box model. A design ports cleanly iff it uses only:
- Layout: normal document flow + flexbox. No
position: absolute|fixed|sticky, no
float, no z-index layering. (Overlap, if needed, via negative margins.)
- Grids: rows become
grid2/3/4; column splits must be 12-unit dist arrays
([6,6], [8,4], [4,4,4]) — never percentages like [50,50].
- Boxes/type/media: padding, margin, background-color, background-image (incl. gradients),
border-*, border-radius, box-shadow, max-width, min-height; any font-size/family/weight/
line-height/color/text-align; <img> with a public URL (no data: URIs).
- Fonts: only the families listed in
capabilities.fonts.available (others fall back).
HTML → node mapping
| HTML |
Sutra node |
Notes |
<section> / full-bleed band |
box |
background + longhand padding; borders via columnStyles (per-side), min-height via the minHeight attr. Not max-width (use container). |
| a flex row |
grid2 / grid3 / grid4 |
dist from the children's width ratio → 12 units; gap is an attr, from CSS gap |
| a flex child — incl. a card (border/radius/padding) |
column |
size = its dist entry, position = 1-based index. The full styles object renders here: put card border/shadow/padding on the column; round via the radius attr. |
| a block that must be narrower / centered |
container |
maxWidth + fullWidth attrs (a box ignores max-width) |
<h1..h3> |
heading (level) |
|
<p> |
paragraph |
|
<a class=button> / <button> |
actionbutton |
colors via buttonBackgroundColor/buttonTextColor/buttonBorderColor; buttonSize, radius, buttonShadow (see allowed_values); navigation via actionCallbackValue + actionCallbackTarget |
inline <a href> |
text node + link mark |
|
<strong>/<em> |
text node + bold/italic mark |
|
<img> |
image |
src (public URL), textAlign, styles; width → resizeableWidth (the image node has no width attr — see below) |
<hr> |
horizontalRule |
dividerColor/dividerStyle/dividerWeight/dividerWidth/dividerAlign |
<ul>/<ol> |
bulletList/orderedList → listItem |
|
| icon + text card |
icontext |
iconImage (avatar URL) or icon + iconColor, iconStyle, iconSide |
| row of chips / pills |
box or container with flexLayout: "row" + badgePill children |
Set flexItemSizing: "fit" so pills keep their label width, flexRowAlign: "center" for centered wrapped rows, and flexGap for row/column spacing. |
The style-split rule (important)
Each element's CSS routes to three places on the node:
node.attrs.styles (the box model): padding-* (longhands), margin-*, background-color,
background-image, box-shadow, line-height — plus, on a column, the borders
(border-width/style/color). See the render rules below for what does not belong here.
- A
textStyle mark on the text inside a heading/paragraph: color → color,
font-size → fontSize, font-family → fontFamily. font-weight ≥ 600 → a bold mark.
node.attrs.textAlign for alignment (not styles).
Render rules capabilities won't tell you — these cause silent drops
capabilities.styles.supported_keys is the validation allowlist, not a render guarantee.
The Box/Column NodeViews honor a narrower, shape-specific set, so a port can return valid: true
and still render with collapsed spacing or missing borders. The four that bite:
- Padding & margin must be LONGHANDS. Emit
padding-top/right/bottom/left (and margin-*).
The padding/margin shorthand is silently dropped — the #1 cause of a port that renders
with all its spacing gone.
- Corner radius is an ATTR, never
styles.border-radius. Use radius + radiusCustom
(column/image) or columnRadius + columnRadiusCustom (box). Enum: with-radius (10px),
circle-radius (20px), custom (exact radiusCustom:{tl,tr,br,bl} px).
- A "card" (border + radius + padding) must BE a styled
column, not a nested <div>/box.
A column renders the full styles object (incl. border-width/style/color) plus the radius
attr; a box does not render generic borders. Put card styling on the flex child itself.
- A
box renders borders only via columnStyles (per-side border-{side}-width/style/color)
and min-height via the minHeight attr. max-width is NOT honored on a box — to make a
block narrower/centered, use a container node (maxWidth + fullWidth attrs).
Images are sized differently — they have no width attr. A width from the design
(CSS width/max-width, or the width= HTML attribute) maps to resizeableWidth, a CSS
width string on the image ("100%" to fill its column, "320px" for a fixed size). Pair it with
resizeableHeight: "100%" and resizeableParentHeight: "unset". If you omit resizeableWidth
it defaults to "50%" and the image renders as a small thumbnail. (Likewise, image alignment
is textAlign — there is no align attr. Put width in resizeableWidth, not in styles.)
Worked example (a hero row)
{ "type": "box",
"attrs": { "uid": "h1", "styles": { "padding-top": "84px", "padding-bottom": "84px",
"background-color": "#16321f" }, "columnStyles": {} },
"content": [
{ "type": "grid2", "attrs": { "uid": "h2", "dist": [7,5], "gap": 48 }, "content": [
{ "type": "column", "attrs": { "uid": "h3", "size": 7, "position": 1 }, "content": [
{ "type": "heading", "attrs": { "uid": "h4", "level": 1, "textAlign": "left" },
"content": [ { "type": "text", "text": "Pull up a chair.",
"marks": [ { "type": "textStyle", "attrs": { "color": "#ffffff",
"fontSize": "76px", "fontFamily": "Source Serif 4" } } ] } ] },
{ "type": "actionbutton", "attrs": { "uid": "h5", "buttonText": "Request a seat",
"buttonBackgroundColor": "#c2502b", "buttonTextColor": "#16321f",
"buttonSize": "L", "radius": "circle-radius",
"actionCallbackValue": "https://example.com", "actionCallbackTarget": "_blank" } } ] },
{ "type": "column", "attrs": { "uid": "h6", "size": 5, "position": 2 }, "content": [
{ "type": "image", "attrs": { "uid": "h7", "src": "https://images.example.com/x.jpg",
"resizeableWidth": "100%", "resizeableHeight": "100%", "resizeableParentHeight": "unset",
"radius": "custom", "radiusCustom": { "tl": 8, "tr": 8, "br": 8, "bl": 8 } } } ] }
] }
] }
A card = a styled column (the pattern people get wrong)
{ "type": "grid3", "attrs": { "uid": "g1", "dist": [4,4,4], "gap": 26 }, "content": [
{ "type": "column", "attrs": { "uid": "c1", "size": 4, "position": 1,
"radius": "custom", "radiusCustom": { "tl": 16, "tr": 16, "br": 16, "bl": 16 },
"styles": { "background-color": "#161b3d",
"border-width": "1px", "border-style": "solid", "border-color": "#2c3470",
"padding-top": "34px", "padding-right": "30px", "padding-bottom": "32px", "padding-left": "30px",
"box-shadow": "0 18px 44px rgba(0,0,0,0.45)" } },
"content": [ /* heading + paragraph */ ] }
/* …columns 2 and 3… */
] }
The card's border + (longhand) padding live in the column's styles, and the rounding is the
radius/radiusCustom attr — not a nested box, not styles.border-radius.
Theme adaptation with themed()
To let colors follow the space's active theme, write color-bearing styles as
themed(token:primary@L<lightness>,S<saturation> ; #fallbackHex) (e.g.
themed(token:primary@L20,S35 ; #1f3d2b)). The hex after ; renders when no theme override is
active. Raw hex is fine too but emits a color_not_themed validate warning.
Workflow
GET /spaces/{id}/design → current content_digest (use as base_digest).
POST /spaces/{id}/design/validate with { nodes } → fix any errors (warnings are advisory).
POST /spaces/{id}/design_drafts with { base_digest, source: "api", nodes } → returns
draft_id and a preview_url.
- Open the preview — a logged-in user, or
POST /spaces/{id}/design_drafts/{draft_id}/preview_link
for an anonymous signed URL — and diff it against your source design: confirm spacing,
borders, radius, and grid widths actually match. valid: true means the schema is well-formed,
not that it renders as intended — don't ship on "the text is present."
POST /spaces/{id}/design_drafts/{draft_id}/publish.
Parity gotchas
- Grid widths: measure the row's column widths and normalize to 12 (
7fr/5fr → [7,5]).
- Spacing / borders / radius: see Render rules above — padding must be longhands, radius is an attr, and a bordered card must be a
column (not a nested box). These pass validate but silently mis-render otherwise.
- Narrow / centered blocks:
max-width is not honored on a box; use a container node (maxWidth + fullWidth attrs).
- Rows of chips / badge pills:
flexLayout: "row" defaults to equal-width children. For chips that should read fully written out, set flexItemSizing: "fit" on the parent box/container/column, use flexRowAlign: "center" when the wrapped row should be centered, and set spacing with flexGap.
- Button alignment: a button hugs its content; align it by the column's
text-align.
- Image width: the
image node has no width attr — set resizeableWidth ("100%" to
fill the column, "320px" for a fixed size), plus resizeableHeight: "100%" and
resizeableParentHeight: "unset". Omitting resizeableWidth falls back to "50%", which
renders as a thumbnail. A width:100% / width="100%" source → resizeableWidth: "100%".
- Images:
src must be a public URL. To guarantee it renders, re-host via
POST /design/assets/import and use the returned URL.
- Fonts: pick only from
capabilities.fonts.available.