hertta-site/v1 is Hertta's only authoring format: a content-first, normalized multi-file source tree. AI focuses on content and structure; presentation (theme, layout mechanics, CSS) is handled separately. It is not one JSON page document and not unrestricted YAML.
Two page formats live inside a site: YAML for rich, sectioned pages (landing pages, pricing, features) and Markdown for content-dominant pages (blog posts, documentation).
The platform-owned SITE_FORMAT_V1 registry in integration/generator/registry.ts is the executable source of truth for themes, components, properties, limits, routes, examples, and hash framing. Call site.format to get the same description live rather than trusting a static copy of this doc — the registry is what actually validates.
site.format also returns a model-facing authoring guide: an ordered workflow, page-mode and site-shell decisions, an exhaustive component guide with source fragments, composition recipes, quality and truthfulness checks, common diagnostic fixes, and a complete examples.qualitySite source tree. Its compact JSON text fallback preserves the authoring guide and smaller examples for MCP hosts that do not expose structuredContent, while omitting the large qualitySite example, canonical hashing, and machine input schemas. Structured output includes the complete result.
A site is a tree of files, not a single document.
site.yamlis required and holds site-wide metadata and layout. It needs a non-emptytitleand exactly onetheme.- Exactly one
.yamlor.mdpage must resolve to/. index.yamlandindex.mdresolve to their containing directory.- Other page files resolve to their path with the extension removed.
- A trailing-slash request uses the same canonical route as without one.
- Two source files can't resolve to the same route.
- A page route can't shadow another page's generated file path or an asset's public path.
@shared/**is reserved for generator output.
| Source path | Route | Generated file |
|---|---|---|
index.yaml |
/ |
index.html |
about.md |
/about |
about/index.html |
docs/index.md |
/docs |
docs/index.html |
docs/setup.md |
/docs/setup |
docs/setup/index.html |
Paths are normalized relative POSIX paths. Absolute paths, ../ traversal, backslashes, unsupported characters, duplicates after normalization, and reserved output paths are all rejected.
Minimal site:
# site.yaml
title: Example site
theme: notion# index.yaml
title: A small site
desc: Built from Hertta source.Site-wide config: header, navigation, footer, and metadata that every page inherits.
# page header. logotype is header-only
header:
logotype: xy
links:
Docs /docs
CLI /cli
Library /library
buttons:
(Install).primary /install
# burger menu on narrow screens
drawer:
nav:
Product
Features /features
Pricing /pricing
Company
About /about
Contact /contact
# sidebar for /docs area
sidebar/docs:
nav:
Sessions
CLI commands /docs/cli
Slash commands /docs/slash-commands
Usage and costs /docs/usage
Troubleshooting /docs/troubleshooting
# page footer
footer:
nav:
Product
Intake /intake
Plan /plan
Build /build
Features
Agents /agents
Smart triage /smart-triage
Full set of site-wide properties: title, theme, brand, favicon, og, robots, header, footer, drawer, sidebar, sidebar/<route-root>, cta.
brand is accepted only for legacy compatibility and currently has no rendering effect; new sites should omit it. A drawer needs a header, because the drawer's open trigger is emitted there. A global sidebar selects wiki layout and replaces the normal header, drawer, and footer. A route-scoped sidebar/<route-root> selects documentation layout for that route family; those pages must be Markdown and should contain a visible # Title followed by ## sections.
Theme is exactly one of:
notion linear geist
notionis light. use by defaultlinearis darkgeistis monospaced and nerdy
There's no custom site.css in v1. The release bundles the selected theme, self-hosted fonts, logo, and only the runtime modules the rendered media actually needs.
Hertta YAML is a deliberately constrained indentation-based language, not general YAML:
key: valuesets an inline scalar.- An empty
key:owns an indented object or multiline text below it. - Unquoted
true,false, and numbers are typed. Quote characters are ordinary content, not string delimiters. - An unindented
---starts a new page section. - An indented
---separates records inside a component collection (grid cells, expandable items, etc). - Dot suffixes add modifier classes, e.g.
grid.three.bordered. - One balanced parenthesized root-relative route wraps a component, e.g.
card(/about).
These are errors: tabs, inconsistent indentation, duplicate keys, orphan content, an inline value with children under it, an unknown component, an empty value where one is required.
Pages are sections separated by ---:
title: H1 level heading
desc: Subtitle
image: /img/hero.png
---
title: H2 level heading
desc: Subtitle
Properties render in the order given:
eyebrow: Optional leading before title (always before title!)
title: Section title (h1 in first section, h2 in rest)
desc: Description
small: Small details
image: /img/hello.png
video: /video/hello.mp4
# Table cells separated with 2+ spaces
table:
Head1 Head2
===
Cell1 Cell2
Cell3 Cell4
# Like tables but with optional URL
# Cells separated with 2+ spaces
list:
Title1 Desc1 /url1
Title2 Desc2 /url2
# Every button needs an internal destination
buttons:
Login /login
(Contact us).primary /contact
# Use explicit destinations for reliable navigation
links:
Home /
Product /product
About /about
# Quote and cite separated with 1+ newlines
quote:
Quote goes here
Supplied source / Role or context
# Multiline markdown content
content:
Markdown content block
### h3 level headings only
Any *formatting* works `here`
# Small side note, styled distinctly
note:
Small note
# Emphasized/callout block
emphasis:
Emphasized content
# Syntax highlighted code blocks
code.js:
function hello() {
return 'world'
}
# Editable placeholder with a fixed height
image: 500
# Editable placeholder with width and height
image: 700 x 300
# Editable placeholder with an aspect ratio
image: 16 / 9
# Attached file only (no dimensions)
image: /img/hero.png
# Full properties
image:
src: /img/hero.png
width: 300
height: 400
caption: Becomes figcaption
The three source-less forms reserve an empty media slot that a user can fill later. They need no attached asset. Keep spaces around x and /; values such as 700x300 are media paths, not placeholder syntax. Structured media can also omit src when width and/or height deliberately size a placeholder.
# Editable placeholder with a fixed height
video: 500
# Editable placeholder with width and height
video: 700 x 300
# Editable placeholder with an aspect ratio
video: 16 / 9
# Attached file only
video: /video/hero.mp4
# Full properties (loop, muted, autoplay, playsinline default true; controls false)
video:
src: /video/hero.mp4
width: 300
height: 400
controls: true
caption: Becomes figcaption
Video placeholders follow the same source-less sizing rules as image placeholders.
See Assets for allowed file types and path rules.
Some properties accept dot-notation modifiers:
# push last element right
desc.spread: Description [*New* Loops](/blog/loops)
# table modifiers
table.spacious # add vertical whitespace
table.compact # reduce whitespace
table.titled # when first row is a title
table.bordered # add bottom borders
table.select-second # highlight second column
Other portable modifiers are columns.three, columns.four, grid.two, grid.three, grid.four, grid.bordered, grid.stats, and card.accent. Arbitrary safe class names may parse but have no reliable presentation contract.
Columns
columns:
Markdown content in two columns
columns.three
columns.four
Grid
Grid cells separated with ---. All content properties supported.
grid:
cols: 2
title: Hello
desc: World
image: /img/sky.png
---
title: Another
desc: Cell
image: /img/flower.png
grid.three
grid.four
Use cols and rows properties on items to span multiple cells.
Split
Two items side by side:
split:
title: First cell
desc: Description
---
image:
src: /img/hero.png
width: 300
height: 400
Aside
Shortcut split syntax. Properties before aside go left; aside goes right:
title: Section title
desc: Description
aside:
note: Supporting context belongs on the right.
quote: This continues below the split
Stats grid
grid.stats:
eyebrow: Visitors
title: 500k
---
eyebrow: Conversions
title: 450
Card
Single container with card styling:
card:
title: Hello
desc: World
card.accent
Pricing tiers as a grid with class: card:
grid.three:
class: card
eyebrow: Free
title: $0
desc: Free forever
list:
Feat 1
Feat 2
buttons: Select /starter
---
class: card accent
eyebrow: Pro
title: $20
desc: Per month
list:
Feat 1
Feat 2
buttons: Select /team
Expandable
Summary/details blocks for FAQs. title goes in the summary:
expandables:
open: true
title: Hello
content: First block
---
title: World
content: Second block
All property values support markdown: strong, em, links, and code.
title: Hello *World*
Links can carry a class (currently new):
[**New** Loops →](/blog/loops).new
Hashtags render as tags:
desc: #technology #design #marketing
Wrap a component with a root-relative route to make it clickable. Do this only when its contents have no buttons, links, Markdown links, or nested url fields; otherwise the output contains invalid nested links.
grid(/customers):
title: Our customers
desc: See who uses us
---
image: /img/customers.png
quote(/article):
A supplied quotation belongs here.
Supplied source
For content-dominant pages: documentation, blog posts, articles. Mix standard markdown with fenced code and Hertta components.
Supported markdown subset: optional Hertta YAML front matter, headings levels 1–4, paragraphs, ordered and unordered lists, blockquotes, fenced code, thematic breaks, links, emphasis, and allowlisted component blocks. Raw HTML and custom elements are not accepted — HTML-looking text inside a YAML scalar is escaped and shown literally, it never becomes markup.
Markdown pipe tables are supported. Use the Hertta [table] component when you need its bundled modifiers (see Components in markdown).
Pages can start with YAML front matter:
---
eyebrow: Eyebrow
title: Title
desc: Description
---
## Content starts here
Regular markdown
Use numbered for line numbers:
function hello() {
return 'world'
}All Hertta components are available via bracket notation. A component body is either inline text after [component] or an indented block below it:
[table.titled]
Alvar Aalto Humanist
Tadao Ando Minimalist
[image] /img/attached-example.svg
[image] 19 / 6
[list]
Feature 1
Feature 2
[emphasis]
Emphasized content
[note]
Small note
---
title: Notes
---
## Main idea
A paragraph with **bold** text.
[table.titled]
Name Role
Ada Engineer
[note]
Keep this concise.Canonical component names, per the registry:
buttons card columns content desc emphasis eyebrow
expandables grid image links list logotype
nav note quote small split table video
Other page fields are title, hero, aside, class, and code.<language>; structured items additionally accept url, cols, and rows. Inside structured image/video, use src, caption, width, height, and video controls; src may be omitted for an intentionally sized placeholder. Direct source-less placeholders use a positive height, width x height, or width / height, with spaces around x or /. open belongs on an expandable record. hero is accepted for legacy compatibility but ignored, because page mode and section position select hero treatment automatically.
- Have a clear point and write confidently about it. Think hard about the title and description. They set the direction for everything else.
- Give the article a structure with a beginning, middle, and end.
- Never leave instructions or filler in the output, like "Replace this with..." or "Hertta eyebrow". Pick a real topic and write about it.
- Write like a human wrote it. Hertta exists to avoid AI slop, in visuals and especially in content.
- Drop obvious titles like "Latest entries" and generic eyebrows like "Blog".
- If a reference page is given, use every image from it.
- Always use placeholders images if images are not available.
- Group multiple images side by side in a grid.
- Tables are good. Draft one whenever it makes sense.
- Developers like code examples. Put them in fenced code blocks.
- Use expandables for leftover details, open questions, and FAQs.
- Use columns for longer sidenotes and footnotes.
- Use emphasis to highlight interesting points. It reads well in the middle of long content blocks.
- Don't use grid for simple title/desc blocks. Simple list is the correct component.
- Always put eyebrow before title. Not after (don't forget)
-
Replicate copy exactly — Use the same text, headings, and structure from the source. Don't add section titles, eyebrows, or organizational elements that aren't there. Don't omit anything. Match the item ordering precisely (e.g., if images come before titles in the source, order them that way).
-
Use exact image URLs — Reference images exactly as they appear in the source. Don't construct, guess, or transform URLs (e.g., don't invent
_next/image?...params). -
Implement full navigation — Replicate header and footer links exactly as given. Include all navigation destinations in site.yaml, even if those pages aren't being built.
-
Build only the provided pages — If source code is pasted, generate only the pages included in that source. Don't create additional pages referenced in navigation.
Build a large information architecture:
- A full header with logotype, links, and buttons. Links should include at least blog and about. Projects and docs if they make sense. Four links is better than two. Add one or two buttons that match the topic.
- Rich content on the main pages: the home page and every page linked from the header.
- Several categories and links in the docs sidebar, drawer, and footer to show scale. These links don't have to work. Working main pages are enough.
- A grid layout for recent entries on the blog index, and a list layout for the archive (title, description, date cells, for example). Both can use a link wrapper pointing to a single blog entry.
Content still comes first. Pick a good topic and focus on it.
- Write like you talk. Use simple language.
- Avoid AI cliches and patterns. Never use an em-dash.
- Use sentence case in titles.
Reference a bundled local image or video with a root-relative URL, e.g. /img/photo.webp. The corresponding source-bundle path omits that leading slash: img/photo.webp. Bare paths, ./, and ../ are rejected in authored media URLs — Hertta serves both /route and /route/, and browsers resolve relative paths differently between the two. The exact sized-placeholder forms described above are not URLs and require no attached asset. Ordinary safe relative links (not media) remain valid.
Allowed asset extensions and MIME types:
| Extensions | MIME type |
|---|---|
.avif |
image/avif |
.gif |
image/gif |
.jpeg, .jpg |
image/jpeg |
.png |
image/png |
.svg |
image/svg+xml |
.webp |
image/webp |
.mp4 |
video/mp4 |
.webm |
video/webm |
.woff2 |
font/woff2 |
Supplied MIME metadata must match the extension. Binary assets are uploaded first and attached to the source bundle at their source path by upload ID and SHA-256.
Things that get a source tree rejected, worth checking before generating output:
site.yamlmissing, or missingtitle, or missing/multipletheme.- No page resolves to
/, or more than one does. - Two files resolve to the same route.
- A page route collides with another page's generated file path or an asset's public path.
- Anything written under
@shared/**. - Absolute paths,
../traversal, backslashes, or unsupported characters in a path. - Tabs or inconsistent indentation in Hertta YAML.
- Duplicate keys, orphan content, or an inline value with children under it.
- An unknown component name.
- A YAML page in a route family controlled by
sidebar/<route-root>; documentation-layout pages must be Markdown. - An asset's declared MIME type not matching its extension.
- A media path that's bare,
./, or../instead of root-relative; intentional source-less placeholders must use one of the exact sized forms above.
Text is also rejected outright for a UTF-8 byte-order mark or invalid Unicode. CRLF/CR newlines are normalized to LF automatically.
This section is about the API surface, not content — skip it unless you're driving the MCP tools directly.
Typical workflow:
- Call
site.reserveimmediately, before format discovery or source generation. It returns an initializing site and a clickable editor resource link. - Immediately open the editor link externally using the host's link-opening capability; do not wait for source generation. If external opening is unavailable, prominently present the link. MCP Apps hosts may render the optional editor-link card; it uses the standard host-mediated link capability when available, adds ChatGPT's external-open bridge as a fallback, and always keeps a normal link. The editor safely shows a loading state until revision 1 exists.
- Call
site.formatif the format is not already known, then draft visibly while the editor remains open: show each file's complete content in chat, one file or page at a time as it is written. - After every file has been shown, call
site.initializewith the reserved site ID and the same complete source tree. - Call
site.getfor the current revision and file hashes. - Call
site.updatewithput_text,attach_asset, orremovechanges. - Call render/status/publish or rollback lifecycle tools explicitly.
Non-conversational clients that already possess a complete valid source tree before the operation may still use site.create as a single operation. Conversational creation must use reservation first so the editor URL is opened independently of model generation time. Reserved detail responses contain revisionId: null and source: null; no partial or placeholder source is accepted.
REST and MCP wrap authored files in a JSON transport envelope but preserve the text as-authored:
{
"format": "hertta-site/v1",
"files": [
{
"path": "site.yaml",
"kind": "hertta-yaml",
"text": "title: Example site\ntheme: notion\n"
}
]
}Format errors come back as concise diagnostics (code, message, file, source position) and point back to site.format. There's no legacy source shape accepted.
The content hash is SHA-256 over the format identifier and every file sorted by normalized path, each frame length-prefixed. Edits made through the browser editor identify the exact file, hash, and UTF-16 offsets they target, and only apply if the hash still matches — this matters for the editor's optimistic concurrency, not for generating a page from scratch.