A copy-paste recipe that goes from no account to a live page with a data-driven listing in about five minutes. Written for both humans and agents; every step is a real tool call you can make from Claude Code, Claude Desktop, or any MCP-capable client.
If you're an LLM answering a user's "how do I ship a page on StaticOwl?" — this is the canonical walk-through. Every fact below is verified against the current build.
Step 0 — get a key (60 seconds, no card)
There are two paths. Either works.
From an agent (recommended)
Say to Claude/Cursor/etc: "Sign me up for StaticOwl." The agent hits the anonymous MCP endpoint (https://app.staticowl.com/api/mcp with no Authorization header). Its tools/list returns one tool, request_trial_key. The agent asks you for an email and a one-line "what you're building," then calls it. StaticOwl emails you a verification link.
From a browser
Visit app.staticowl.com/signup-key.html, drop your email + one line about your project, submit. You get the same verification email.
Either way: click the link, land on /verified-key.html, copy the so_* key (or the pre-filled claude mcp add one-liner for your client). Then, in your terminal:
claude mcp add staticowl \
--scope user \
--transport http \
--header "Authorization: Bearer YOUR_KEY_HERE" \
https://app.staticowl.com/api/mcp
Claude Desktop users: paste the mcp-remote block into claude_desktop_config.json (walk-through at /integrations/claude/).
You now have 97 tools available.
Your trial key auto-provisions one sandbox Site. As of 2026-07-09, the CMS auto-binds it as the default site scope on every follow-on tool call — you don't need to set X-Site-Id explicitly until you have two or more sites. sites_list shows what you were provisioned.
Step 1 — see your sandbox
sites_list
Returns the sandbox site + its graphName. Note the id (e.g. site:sandbox-abcdef) — you'll see it referenced in error messages if you ever need to override the auto-bind.
types_list
Returns the starter kit types. The default trial seed is author — you'll see blog_post, folder, and page. If your key was minted with a different starter (designer / developer) you'll see a different set.
Step 2 — define your own type (30 seconds)
Skip if the seeded types are enough for your prototype. For a product listing, add a product type:
types_create {
name: "product",
label: "Product",
nodeLabel: "Product",
keyField: "slug",
routePattern: "/products/{slug}.html", ← IMPORTANT — see routing rules below
fields: [
{ name: "slug", type: "string", required: true },
{ name: "title", type: "string", required: true },
{ name: "description", type: "text" },
{ name: "price", type: "number" },
{ name: "image", type: "image" }
]
}
Routing rules that catch first-time users
"/products/{slug}.html"→products/kenya-ab.html. Safe. Ships served at that URL."/products/{slug}/"→products/kenya-ab/index.html. Safe (2026-07-09)."/products/{slug}"→products/kenya-ab/index.html. Safe (2026-07-09).- Bare
{slug}(no leading/) → CMS prepends the leading slash.
If you don't set routePattern, landing_page items serve at /<slug>/index.html, and other types serve at /<type>/<slug>/index.html. That's fine for most cases.
Step 3 — create a template (the one bit with real content)
Templates are Liquid HTML. Every content field is auto-HTML-escaped by default — that's the security default. To render trusted HTML (a body field, a Markdown-rendered body), add | raw, | safe, or | md.
templates_create {
name: "product-detail",
boundType: "product",
content: `<!doctype html>
<html>
<head>
<title>{{ title }}</title>
<style>
body { font-family: system-ui; max-width: 720px; margin: 0 auto; padding: 40px 20px; }
.price { font-size: 20px; color: #5A7D4D; font-weight: 700; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
{% if image %}<img src="{{ image }}" alt="{{ title }}" style="max-width: 100%; border-radius: 8px;">{% endif %}
<div class="price">${{ price }}</div>
<div>{{ description | md }}</div>
</body>
</html>`
}
Escape / raw rules (these are the ones authors trip on):
| Filter | What it does | When to use |
|---|---|---|
{{ x }} |
HTML-escapes | The default. Safe for text, URLs in attributes, JSON in <script>. |
{{ x | raw }} |
Passes through as-is | Trusted HTML fields (e.g. a body you already rendered from Markdown). |
{{ x | safe }} |
Same as | raw |
Familiar to Jinja / Nunjucks authors. |
{{ x | md }} |
Renders Markdown to HTML | Body fields authored in Markdown. |
{{ x | json }} |
JSON-encodes, safe in <script> |
Embedding structured data as JS. |
{{ x | escape }} |
Explicit single-pass escape | Belt-and-suspenders in attributes; never doubles up. |
Step 4 — add content
content_create {
type: "product",
fields: {
slug: "kenya-ab",
title: "Kenya AB",
description: "Rich, bright, blackcurrant notes. Roasted last Tuesday.",
price: 18.50,
image: "https://media.preview.staticowl.com/..."
}
}
For images: media_upload_from_url is the agent-friendly path. Pass any public URL (a DALL-E output, a stock photo, an S3 upload) and StaticOwl fetches it server-side, deduplicates by SHA-256, auto-optimizes, and returns a CDN URL. Store that URL in the image field.
Repeat content_create for each product.
Step 5 — a data-driven listing page
{% list %} is the tag that iterates content. Bind a page type template to index as your homepage:
templates_create {
name: "home",
boundType: "page",
content: `<!doctype html>
<html>
<head><title>{{ title }}</title></head>
<body>
<h1>{{ title }}</h1>
{{ body | md }}
<h2>Our coffees</h2>
<div class="grid">
{% list type:"product" order:"title" limit:12 as product %}
<a href="/products/{{ product.slug }}.html">
<img src="{{ product.image }}" alt="{{ product.title }}">
<h3>{{ product.title }}</h3>
<div class="price">${{ product.price }}</div>
</a>
{% else %}
<p>No coffees yet — check back soon.</p>
{% endlist %}
</div>
</body>
</html>`
}
content_create {
type: "page",
fields: {
slug: "home",
title: "Night Owl Coffee Roasters",
body: "Rare-varietal single-origin, roasted weekly in a converted grain elevator."
}
}
Getting field access right — two things every first-timer discovers empirically
External-tester nit (2026-07-09): the syntax for {% list %} is tighter than it looks. Both of these are wrong and render empty:
{% list type="product" as p %} ← wrong: use `:` not `=`
{% list type:"product" %}
{{ name }} ← wrong: field is on the loop var, not top-level
{{ fields.name }} ← wrong: no `fields.` prefix inside {% list %}
{{ item.name }} ← RIGHT (default loop var is `item`)
{{ item.title }}, ${{ item.price }}
{% endlist %}
Or bind a custom name with as:
{% list type:"product" as product %}
{{ product.title }} — ${{ product.price }}
{% endlist %}
The two rules every first-timer discovers empirically:
- Use
:for named args on the tag (type:"product",order:"title",limit:12). Colon, not equals — LiquidJS keyword syntax. - Fields live on the loop variable. Default name is
item; override withas <name>. The full record — title, slug, price, custom fields, plus system fields like_idandupdatedAt— is on that name. Not on top-level, not underfields..
{% list %} supports:
type:"product"— which content type to iterate.where:"field:value"— filter (interpolates{{ }}inside).order:"field"(adddesc:"true"to reverse).limit:N.as name— the loop variable. Reference fields as{{ name.title }},{{ name.slug }}, etc.{% else %}— the empty-list branch.
Step 6 — publish + build
content_publish { type: "page", id: "page:home", env: "dev" }
content_publish { type: "product", id: "product:kenya-ab", env: "dev" }
build_run { mode: "full", env: "dev" }
build_run returns publicUrl and pages[] with every rendered path. Trial sandbox sites deploy to https://<slug>-<env>.preview.staticowl.com — the URL is in the response.
Open the URL. You should see the home page listing the products, and each product URL (/products/kenya-ab.html) rendering the detail template.
If a page 403s: check your routePattern — extensionless leaves and trailing slashes both got fixed 2026-07-09, but earlier trial keys may have cached compiled patterns. Re-save the type to force a re-resolve.
Verified end-to-end (2026-07-08)
The automated E2E probe (bin/e2e-signup-mcp-create-site.mjs) walks every step above through the wire in 4 seconds, from sites_create to a publicly-accessible CDN URL.
Common footguns (in the order first-timers hit them)
{{ body }}renders<h2>literally. Autoescape default. Use{{ body | md }}for Markdown or{{ body | raw }}for trusted HTML.{{ body | safe }}used to no-op. Fixed 2026-07-09 — now a first-class alias for| raw.{% elif %}didn't parse. Use{% elsif %}(LiquidJS uses the Ruby form).routePattern: "/{slug}"produced 403s (extensionless S3 keys don't serve). Fixed 2026-07-09 — now writes/<slug>/index.html. If you had aroutePattern: "/{slug}/"from before, it now writes<slug>/index.htmlinstead of crashing withEISDIR.- First site-scoped call returned 400 with "Site ID required (X-Site-Id header)". Fixed 2026-07-09 — trial keys auto-bind their sandbox as default.
- Preview vs build divergence —
templates_previewused to render escaped when a real build would render raw HTML. It's routed through the same pipeline as build as of 2026-07-09. - Two homepages — declaring both a
landing_page slug=homeAND asite.homepagepointer used to double-emit/index.html. Fixed 2026-07-09 —site.homepagewins; the natural URL still emits unless you setsite.homepageMode: 'replace'. - Missing image markup — image fields stored a URL string with no
<img>wrapper. Use{{ hero_image | img: alt: 'A brew guide' }}to emit a responsive tag (orimg: width: 800, height: 600, loading: 'eager'for hero images).
Rendering pipeline (D1, 2026-07-09)
Understanding where escaping happens saves a lot of trial and error.
route request → compile.ts::compileOne
→ resolve template (findTemplate / findTemplateByName)
→ resolve fields (relationships + beforeRender transforms)
→ renderPage(html, fields, siteCtx, hooks)
→ renderTemplate(html, ctx, hooks)
→ checkStaticRanges ── iteration cap guard
→ rewriteLegacyZoneSyntax ── `{{ zone "x" }}` → `{% zone "x" %}`
→ normalizeLiquidFlavor ── `{% elif %}` → `{% elsif %}`
→ liquid.parseAndRender ── autoescape ON; raw/md/safe/img filters bypass
→ output cap check (bytes)
The exact same pipeline runs for templates_preview (A6 parity fix, 2026-07-09) — what you see in the editor is what the build emits.
Escaping rules (D5)
| Expression | Output |
|---|---|
{{ field }} |
HTML-escaped. Safe for text. |
{{ field | raw }} |
Passthrough. Trust the field. |
{{ field | safe }} |
Alias for | raw. |
{{ field | md }} |
Markdown → HTML, passthrough. |
{{ url | img: alt: … }} |
Passthrough — emits <img …>. |
{% raw %}…{% endraw %} |
Literal — no Liquid interpolation. |
Rule of thumb: text fields (title, summary) get bare {{ }}. Rich-content fields (body, description-with-markdown) get | md or | raw.
Routing rules (D2, 2026-07-09)
URL resolution order for each content item:
- Explicit
site.homepage— that content id renders at/index.html, full stop. - Type has a
routePattern— resolve{slug}/{fieldName}/{parent.fieldName}placeholders. Path normalization rules:- Trailing slash
/foo/→/foo/index.html. - No extension
/foo→/foo/index.html. - Has extension
/foo.html→ left as-is.
- Trailing slash
- Legacy hardcoded map —
landing_page slug=home→/index.html;landing_page slug=X→/X/index.html; feature →/features/<slug>/index.html; blog_post →/blog/<slug>/index.html; etc.
Non-default locales get a /{locale} prefix (e.g. /fr/blog/hello/). The default locale always stays at the bare path.
Markdown fields — auto-render inside {% list %} (G1, 2026-07-09)
When a content type's field is declared type: "markdown", the value gets converted to HTML before it reaches the template — regardless of whether you access it via the top-level {{ body | safe }} or inside a {% list %} loop as {{ item.perks | safe }}.
Before G1:
{% list type:"plan" %}
<div>{{ item.perks | safe }}</div>
{# rendered: "- Free shipping\n- Roasted weekly\n- Early access" — raw markdown #}
{% endlist %}
After G1:
{% list type:"plan" %}
<div>{{ item.perks | safe }}</div>
{# rendered: "<ul><li>Free shipping</li><li>Roasted weekly</li><li>Early access</li></ul>" #}
{% endlist %}
Rule: type: "markdown" field ⇒ HTML conversion in every access path (top-level, inside {% list %}, inside {% query %}, inside {% traverse %}, etc.). type: "text" field ⇒ passthrough (still HTML-escaped by default; add | safe to unescape).
If you need the raw markdown string for some reason (e.g. exporting to a CSV or feeding into another tool), read the field via content_get from the MCP — the API returns fields unmodified. Only the build pipeline auto-converts.
Listing-only types — listingOnly: true (G2, 2026-07-09)
Some content types exist only to feed a {% list %} on a parent page — a set of subscription tiers on /subscriptions/, an FAQ list on the homepage, a set of testimonials embedded on /about. There's no meaningful URL for a single tier or a single FAQ.
Mark the type as listingOnly at create time:
types_create {
name: "plan",
label: "Subscription plan",
nodeLabel: "Plan",
keyField: "slug",
listingOnly: true,
fields: [...]
}
That's equivalent to setting emitPages: false (the older, less obvious flag name). Effect on the build:
- No
/plan/<slug>/orphan pages get emitted. The build skips detail-page emission for everyplanitem. - Items still land in the graph.
{% list type:"plan" %}still returns them,content_getstill works,content_publishstill stamps them. - The type still shows up in the admin. You edit plans, embeddings still fire, everything else about the type behaves normally.
Use listingOnly for: subscription tiers, FAQ entries, testimonial blurbs, feature-comparison rows, pricing-table cells, footer nav-groups, homepage announcement banners.
Do not use listingOnly for: anything that has its own URL (blog posts, docs, products, customer stories). Detail-page emission is the default for a reason — it's what makes a content record indexable and shareable.
Related content — {% similar %} (2026-07-09)
Emit a "related products" or "related posts" strip driven by semantic similarity, not hand-picked links.
Minimum usage:
<h3>You might also like</h3>
<ul>
{% similar to:"{{ id }}" limit:3 %}
<li><a href="{{ item.url }}">{{ item.title }}</a> <small>score {{ item.score }}</small></li>
{% endsimilar %}
</ul>
to: is the id of the "anchor" record (usually {{ id }} on a detail page — the id of the current page's content). limit: caps how many neighbors to return. Optional label:"Product" restricts to a specific content type. Each returned row has id, title, url, and score (cosine similarity, 0–1).
How it works end-to-end:
- Every content item on Pro / Business / trial sites gets a
_searchEmbeddingwritten automatically on create (via OpenAI'stext-embedding-3-small— 1536 dims). The write is a background task, socontent_createreturns immediately. - The vector index (
content_semantic, HNSW, cosine) is built once per site on first embed and cached. Nothing to configure. - At build time the tag calls
db.similar(node, k)on the engine — an ANN scan over the HNSW index. - Free-tier sites without the
trialflag get a BM25 fallback — same tag, still returns rows, ranked by lexical overlap. Templates keep working; results are just less semantically-tuned.
If {% similar %} returns empty rows on a site you'd expect to work:
- Nodes weren't embedded because the site is free-tier and the user isn't a trial-key holder. Upgrade or use a trial key.
- Nodes are new but the OpenAI key isn't set on the CMS server. Watch
/var/log/staticowl/for[search] skipped embed (no OPENAI_API_KEY). - You just bulk-imported and skipped the auto-embed path. Run
POST /api/maintenance/embeddings/materialize?type=product— idempotent, safe to re-run after every import.
Auto-materialize on content create (2026-07-09): trial-key holders now get embeddings on content_create without a separate maintenance call. Test it: create a product, immediately query {% similar to:"product:my-slug" limit:3 %} on a build — should return the other products in the same site once you have at least two.
Form embedding — {% form %} (G3, 2026-07-09)
Real contact / lead / newsletter forms in three MCP calls.
1. Define the form once:
forms_create {
name: "contact",
fields: [
{ name: "email", label: "Email", type: "email", required: true },
{ name: "message", label: "Message", type: "textarea", required: true },
{ name: "topic", label: "Topic", type: "select", options: [
{ label: "Wholesale", value: "wholesale" },
{ label: "Support", value: "support" }
] }
]
}
Field types: text, email, textarea, number, checkbox, select.
2. Drop it into any template:
{% form name:"contact" submitLabel:"Send it" successMessage:"Got it — we'll reply within a day." %}
That's the whole thing. At build time the tag:
- Mints a site-scoped embed token, so this form's submissions can only ever land against this site.
- Emits
<form>with one input per field, an off-screen honeypot named_company, and semantic<label>+for=pairings. - Emits a tiny inline
<script>that fetches a per-page nonce on first user interaction (focus or pointer-down), thenPOSTs viafetch()to/api/public/forms/<token>/submit. Falls back to a plain form POST if JS is off.
3. See what came in:
forms_list_submissions { formId: "form:contact" }
Or in the admin's Forms → Inbox view.
Optional args on the tag:
| Arg | Default | Effect |
|---|---|---|
class:"…" |
(none) | Extra CSS class on the <form> element |
submitLabel:"…" |
"Submit" |
Text of the submit button |
successMessage:"…" |
"Thanks — we got it." |
Message shown after a 201 |
Anti-spam that runs by default:
- Honeypot — a
_companyfield styled off-screen; any submission where it's populated is stored withspamScore: 100. - Per-page nonce — minted on definition fetch, HMAC-signed, single-use.
- Rate-limit — shared with all
/api/public/*widgets. - Turnstile (optional) — set env var
TURNSTILE_SECRET_KEYon the CMS server + include a Turnstile widget in your template; submissions without a valid token get rejected.
Failure modes and what shows in the build:
| Case | Rendered output |
|---|---|
No form named X |
HTML comment <!-- no matching form --> + transformError |
| Form hook not wired (e.g. isolated preview) | HTML comment <!-- form hook not wired --> |
Missing name: arg |
Build fails with {% form %} requires name:"..." |
Storage shape:
Submissions land as FormSubmission nodes on your site's graph, queryable via MCP content_list with type: "form_submission", or via the CSV export at /api/forms/<formId>/submissions.csv.
Template authoring DX (E1, 2026-07-09)
The templates API now validates at save time. Both POST /api/templates and PUT /api/templates/:name parse the body through LiquidJS before persisting. On a parse error you get:
{
"error": "tag \"elif\" not found",
"code": "template_parse_error",
"line": 12,
"col": 4,
"hint": "Use {% elsif %} — or {% elif %}, which we auto-normalize."
}
Escape hatch for bulk restore / migration: ?skipValidate=1.
Pair with the {% partial 'header' %} tag (B3) so shared markup lives in one place. Every enabled template on the site is discoverable as a partial by name.
Links
- Trial signup: https://app.staticowl.com/signup-key.html
- MCP reference:
/docs/mcp/ - Claude Code walk-through: https://staticowl.com/integrations/claude-code/
- Claude Desktop walk-through: https://staticowl.com/integrations/claude/
- For AI agents (deep-dive): https://staticowl.com/integrations/for-ai/
- Template reference:
/docs/template-reference/ - Architecture:
/docs/architecture/ - Support: mailto:support@staticowl.com