---
title: Let AI write the HTML
description: A block whose HTML an agent writes lifts the ceiling that content-types impose — and hands the maintenance of that page to whoever can read markup. Powerful, and not for every team.
updated: 2026-08-20
---

[Let AI generate your content](/en/docs/ai-content-generation) covers the ordinary case: an agent composes a page out of the blocks the site already has. This page covers the other one — the agent writes **raw HTML**, because the layout it needs does not exist as a content-type.

It is a genuinely useful escape hatch and a genuinely sharp tool. The difference between the two is a design system, and that is most of what this guide is about.

### Who this is for

Structured content is the thing you give up here, and it is worth naming what that means before anything else.

A normal block gives an editor named fields: a title, a standfirst, an image, a layout choice. They fill them in, the design holds, and the block is translatable one field at a time. **A free block gives them a wall of HTML.** Change the wording of a heading and you are editing markup; translate it and you are maintaining a second complete document; get a tag wrong and the page breaks.

So the fit is a question about your team, not about the block:

| The site is run by | Free blocks are |
|---|---|
| An editor who writes and publishes copy, and should never see a tag | **the wrong tool.** Every free block is a page they can no longer maintain alone. Add a content-type instead. |
| A webmaster who is also comfortable with the design — reads HTML, knows the design system, would have opened the template anyway | **the right tool.** It removes a round-trip through a developer for a one-off layout. |
| A developer, with an editor downstream | **fine, deliberately.** Use them while shaping a page, then promote anything that survives into a real content-type. |

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>The question is not "can the agent write this?" but "who maintains it in six months?"</b> An agent will produce a beautiful block for a team that cannot touch it afterwards, and nothing in the CMS will warn them. If the person who edits this site is a content editor and nothing more, the honest answer to a layout that does not exist is a new content-type — not a free block.</div></div>

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Midi.</b> Everything here was built on <b>Midi</b>, the demo site of a citrus-drinks maker — bright palette, ten named colour tokens, Font Awesome already loaded site-wide. It carries a <code>freeBlock</code> content-type and a <code>/design-system.html</code> catalogue that exists for one reader: the agent.</div></div>

## 1. First, decide you actually need it

Two questions, in this order. Both have a cheap right answer and an expensive wrong one.

**Does an existing content-type already do this?** If yes, use it — for all the reasons above. The cost of a free block is paid by whoever edits the page next, and it should buy something.

**Is this shape going to repeat?** If the same layout turns up on a third page, it is not a free block — it is a content-type you have not written yet. Duplicating markup across pages is how a site stops being maintainable, and an agent will duplicate it happily and fast.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>A free block is an escape hatch, not a default.</b> The failure mode is not one bad block — it is a site where every page is one <code>freeBlock</code>, nothing is translatable, and the CMS has become a text editor with extra steps.</div></div>

## 2. The content-type

Four fields, and each one is load-bearing.

```yaml
freeBlock:
  name: Free block (HTML written by the AI)
  template: 'elements/freeBlock.liquid'
  icon: code
  tabsets:
    main:
      name: Content
      fields:
        title:
          type: text
          label: Title (internal, never displayed)
          info: 'Used to spot the block in the back-office. Never rendered on the site.'
        html:
          type: embed
          label: HTML
        images:
          type: image
          label: Images referenced in the HTML
          config:
            multiple: true
            max: 10
            versions:
              full: { label: Full width, width: 1600 }
              card: { label: Card, width: 800, height: 600 }
    config:
      name: Display
      fields:
        width:
          type: select
          label: Block width
          translatable: false
          config:
            options:
              container: Container
              full: Full width
          default: container
```

- **`html` is an `embed` field**, the type whose value *is* HTML and renders as such. See [Custom fields](/en/docs/custom-fields).
- **`title` never renders.** It exists so the back-office list is readable — "Bande méthode" beats a truncated `<section class="bg-dark py-20…`.
- **`images` is a real media field**, not URLs typed into the markup. The agent picks from the library, the template resolves the URLs. That keeps the media usage counters honest and survives a re-upload.
- **`width`** decides whether the template wraps the block in the site's container or lets it run edge to edge.

Use `info:` generously on these fields. The back-office `info` is the one piece of documentation that reaches an editor *and* an agent at the moment of writing.

## 3. The template

```liquid
{% assign c = content.custom %}
{% assign _html = c.html | default: '' %}

{%- for _img in c.images -%}
  {%- assign _i = forloop.index0 -%}
  {%- assign _url = _img.versions.full.url | default: _img.frontUrl | default: '' -%}
  {%- assign _needle = '[[IMG' | append: _i | append: '_URL]]' -%}
  {%- assign _html = _html | replace: _needle, _url -%}
  {%- assign _needle = '[[IMG' | append: _i | append: '_ALT]]' -%}
  {%- assign _alt = _img.alt | default: _img.name | default: '' | escape -%}
  {%- assign _html = _html | replace: _needle, _alt -%}
  {%- assign _needle = '[[IMG' | append: _i | append: '_LEGEND]]' -%}
  {%- assign _html = _html | replace: _needle, _img.legend -%}
{%- endfor -%}

{% if c.width == 'full' %}
  {{ _html }}
{% else %}
  <div class="mx-auto max-w-container px-6">{{ _html }}</div>
{% endif %}
```

Four decisions in there are worth more than the code around them.

**No `| safe`.** That filter does not exist in Quilium Liquid, and under `strictFilters` it is a **500** on every render — not a silent no-op. An `embed` value already renders as HTML; there is nothing to undo, because Liquid never escaped it in the first place.

**`ALT` is escaped, `LEGEND` is not.** The alt text lands inside an attribute, so an apostrophe in a media name would break the tag. The legend is allowed to carry markup — that is what it is for. Two adjacent substitutions, two opposite rules, and getting them backwards is invisible until it isn't.

**Building the needle inside the loop works.** `{% assign %}` inside a `{% for %}` accumulates across iterations *and* survives past the loop — measured on Midi with two images, both substituted, read back after the loop.

**The unresolved placeholder is left visible.** A `[[IMG2_URL]]` with no third image renders as literal text in the page. That is deliberate: this project's QA already greps the output for leaked template braces and `[object Object]`, and double brackets join that list. A visible artefact gets fixed; a silent blank does not get noticed.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Brackets, not braces — and the reason is not cosmetic.</b> Measured: an <code>embed</code> value is <b>not</b> re-parsed as a template, so a brace expression written inside it reaches the page intact. Both delimiters would work today. Brackets are the safe choice because they stay unambiguous the day that value passes through a render, and because a reader can never mistake a placeholder for engine syntax.</div></div>

## 4. The design system is the guardrail

Nothing above constrains what the agent writes. There is no sanitiser, no class whitelist, no wrapper that repairs bad markup. **The only thing between a free block and a site that looks like six different sites is a document the agent reads first** — so it has to be a document an agent can actually read.

That document is a **static HTML page served from `public/`**. On Midi it is `/design-system.html`, reachable, unlinked from the navigation, and versioned with the theme.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Static beats rendered here, and the reason is worth stating.</b> A catalogue built for humans needs copyable snippets next to each component. An agent needs none of that: it fetches the URL and reads the markup. <b>The page <i>is</i> the source.</b> Which also means the catalogue does not have to be built for this purpose — the component catalogue your designer already produced is very likely the artefact, as-is.</div></div>

![The served component catalogue: the index from A.0 to B.13, then the colour tokens with their hex values, the kicker variants, the type scale and the button states.](/thumbs/4855e8f0-aa3f-11f1-b133-474660f896e3.png)

### Two parts, two jobs

The catalogue that works splits in half, and an agent uses the halves differently:

- **Part A — the primitives.** Kicker, type scale, buttons and their states, form fields, badges, card, stat, stamp, divider. This is the **vocabulary**: what you compose a block out of when the block does not exist yet.
- **Part B — the blocks already in place.** Hero, manifesto, listing, detail, USP, quote, FAQ, CTA, form, footer. This is the **grammar**: the proportions, the vertical rhythm, the way this site sets a title and lets it breathe. A new block has to read as a sibling of those, not as a visitor.

Part B is what makes the difference between "compliant" and "obviously bolted on". It is also the part people leave out, because it feels redundant — those blocks already exist as content-types, so why document them? Because the agent is not going to *use* them, it is going to have to *match* them.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Part B documents content-types. Match them; do not rebuild them.</b> A free block that reimplements the site's FAQ is a regression — the real one is editable field by field and translatable, and the copy is neither. Say this in the instructions explicitly, because the catalogue itself gives an agent everything it needs to do exactly the wrong thing.</div></div>

### Keep one copy

The catalogue almost certainly already exists in your design trail, as a standalone HTML deliverable. Serve **that one**, derived mechanically, rather than writing a second: two catalogues drift, and the one that drifts is always the one the agent reads.

On Midi the served file is `brand/design-system.html` with three substitutions — prototype image paths rewritten to the site's real media URLs (`/media/<uuid>.png`), the favicon repointed, and the contact address aligned with the site's global fields. The brand file itself is untouched and stays the source. A header comment in the served copy records the three rules, so regenerating it is mechanical rather than archaeological.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>In production, put a token in the URL.</b> Serving the catalogue publicly exposes your component library and your brand tokens to anyone who guesses the path. A long unguessable segment — <code>/design-system-&lt;token&gt;.html</code> — costs nothing, still works for an agent that has the URL, and keeps it out of casual reach. Public is fine for a demo site; it is a decision to make deliberately anywhere else.</div></div>

## 5. Point the agent at it

The design system only works if it is read, and "read the design system" is not something to retype in every prompt. It belongs in the **site's AI custom instructions**, alongside the rules that have no other home:

- when *not* to reach for a free block — the two questions from section 1;
- the token names and the fact that nothing outside them exists;
- **Font Awesome instead of emoji**, with the note that an icon inherits colour and size from its parent, so colour is a text class and size is a text size;
- the image placeholders and their zero-based order;
- `width: full` for a block whose HTML carries its own edge-to-edge band — and that such a band left on `container` is squeezed to the grid and looks broken;
- **every block gets its English HTML in the same call.** The `html` field is translatable, and a translation here is a second complete document, not a diff.

See [Use the integrated AI agent](/en/docs/integrated-ai-agent) for where those instructions are stored.

## 6. Ask for one

Everything above exists so that this prompt is short:

```prompt
Add a block to the "Notre histoire" page: a full-bleed band presenting our
three-step cold-press method, with an icon per step and one image on the right.
Read /design-system first and use its patterns and tokens.
One freeBlock, width = Full width. Pick the press-room photo from the
media library and reference it with the placeholders.
Write the French and the English HTML in the same call.
Show me the markup before you write it.
```

Nothing in it names a colour, a font size or a class. That is the test: if the block comes back on-brand, the design system is doing its job; if it comes back generic, the design system is missing a pattern, and *that* is the thing to fix — not the prompt.

"Show me the markup before you write it" matters more here than anywhere else in this documentation. Reviewing a block plan is easy; reviewing forty lines of markup already in the page, in two languages, is not.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>What that produced on Midi.</b> A dark full-bleed band, a chartreuse eyebrow, three steps each with a Font Awesome pill in a different token colour, and the press-room photo pulled in through <code>[[IMG0_URL]]</code> — with its English twin. Zero hardcoded colours, zero emoji, zero leaked placeholders in either locale.</div></div>

![The generated block rendered on the site: a dark full-bleed band, a chartreuse kicker, three steps each with a coloured Font Awesome pill, and the press-room photograph on the right.](/thumbs/48582460-aa3f-11f1-b19c-e9fd272e4250.png)

## 7. Then ask for a whole page

One block is the unit. A page is the interesting case, and it is the same prompt with a wider frame:

```prompt
Create a "Formats pro" page, hidden from the navigation, noindex.
Audience: wine merchants, delis, bars and restaurants. It presents our three
trade cases — mixed case 24×25cl, bar case 12×75cl, pallet — and sends them to
the contact form.
Read /design-system.html first. Part B.8 has a three-card pricing pattern:
follow it.
Compose it as several freeBlocks, one section each. French and English HTML in
the same call, and mind that a hardcoded link has to carry its own locale.
Show me the plan — one line per block — before writing anything.
```

![The generated page: a dark opening band with a chartreuse eyebrow, the three format cards with the middle one highlighted, a three-figure proof strip and an orange call-to-action band, all in the site's chrome.](/thumbs/485a34a0-aa3f-11f1-96d5-95f4c6c9d894.png)

That produced **four blocks** on Midi — opening band, the three cards, a proof strip, a call to action — each a `<section>`, two of them full-bleed and two on the grid. The page reads as a page of the site, and the only thing naming a colour anywhere in the request was the design system it was told to read.

Three things this exercise surfaced that the single-block case does not:

**A page is several blocks, and the prompt has to say so.** Left to itself, an agent will hand you one enormous `freeBlock` containing four sections. It renders identically and it is much worse: the editor can no longer reorder the proof strip, remove the CTA, or translate one part without touching the rest. "One section each" is the whole instruction.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>A hardcoded link cannot use the locale helper.</b> Inside a free block there is no <code>localeUrl</code>, no <code>url</code> filter, no template at all — the HTML is opaque to the engine. So a link to another page of a bilingual site is written by hand, <b>and each translation carries its own</b>: <code>/fr/contact-pro</code> in the French HTML, <code>/en/contact-pro</code> in the English one. Forget it and the English page quietly sends visitors to the French one, in 200. Grep the rendered other-locale page for the wrong prefix; it is a two-second check and nothing else catches it.</div></div>

**Write translations with `update-content`, not at creation.** `add-content-to-page` does not accept a `translations` argument — pass one anyway and it is dropped, silently, with `success: true`. On this page all four blocks came back English-less and served French to `/en/`, which is exactly the failure the previous callout describes. Create the block, then update it with its translation.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Copy the pattern, not the catalogue's own chrome.</b> A component catalogue wraps each example in its own furniture — a bordered frame, a label, a "static / dynamic" tag. Those classes exist only in that file. An agent copying a whole example block gets markup that renders unstyled on the real site. Worth one line in the instructions, and worth checking on the first block that comes back.</div></div>

## 8. Review

The structural checks from the ordinary case still apply, plus four that are specific to generated markup:

- **No double brackets left in the rendered page**, in either locale — that is an image the agent referenced and never selected.
- **No `style=` attribute and no hex colour** in the source. Both mean the agent went outside the system, and both are one grep away.
- **One `<section>` per block.** Two means the editor can no longer reorder or remove one without the other.
- **The other locale renders.** An untranslated free block falls back to the source language and nobody notices until a customer does.

One more, learned the hard way on this build: **check that the icons actually render.** Midi's Font Awesome stylesheet had a truncated SRI hash, so the browser blocked it and every icon on the site had been an empty circle since the day it was built — invisible in review, obvious the moment someone looked at a screenshot. An agent will keep emitting `<i class="fa-solid fa-sun">` forever and nothing will complain.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Know what you are accepting.</b> An <code>embed</code> field renders with <b>no filter and no documented sanitisation</b>. Whoever can write that field can put a script tag in your pages. That is acceptable for a trusted editor and an agent you review; it is not a field to hand to an untrusted contributor, and it is a reason to keep the free block out of any site where roles are wide.</div></div>
