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

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:

  1. Use : for named args on the tag (type:"product", order:"title", limit:12). Colon, not equals — LiquidJS keyword syntax.
  2. Fields live on the loop variable. Default name is item; override with as <name>. The full record — title, slug, price, custom fields, plus system fields like _id and updatedAt — is on that name. Not on top-level, not under fields..

{% list %} supports:


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)

  1. {{ body }} renders &lt;h2&gt; literally. Autoescape default. Use {{ body | md }} for Markdown or {{ body | raw }} for trusted HTML.
  2. {{ body | safe }} used to no-op. Fixed 2026-07-09 — now a first-class alias for | raw.
  3. {% elif %} didn't parse. Use {% elsif %} (LiquidJS uses the Ruby form).
  4. routePattern: "/{slug}" produced 403s (extensionless S3 keys don't serve). Fixed 2026-07-09 — now writes /<slug>/index.html. If you had a routePattern: "/{slug}/" from before, it now writes <slug>/index.html instead of crashing with EISDIR.
  5. 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.
  6. Preview vs build divergencetemplates_preview used 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.
  7. Two homepages — declaring both a landing_page slug=home AND a site.homepage pointer used to double-emit /index.html. Fixed 2026-07-09 — site.homepage wins; the natural URL still emits unless you set site.homepageMode: 'replace'.
  8. 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 (or img: 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:

  1. Explicit site.homepage — that content id renders at /index.html, full stop.
  2. 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.
  3. Legacy hardcoded maplanding_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:

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:

If {% similar %} returns empty rows on a site you'd expect to work:

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:

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:

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

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Template language reference
69% match
Getting started — StaticOwl Docs
62% match
StaticOwl documentation — StaticOwl Docs
62% match