Louis Tsang
00214 min

When a reply is an interface

Replies have begun arriving as pages. One extra line at the end of a prompt — structure your response as HTML — and the reply comes back as a small page instead of a document. Karpathy mentioned this in passing on X in May, after Thariq Shihipar of the Claude Code team had written it up. The Claude blog gave the practice a title: The unreasonable effectiveness of HTML. Simon Willison reconsidered his Markdown default the same week. Google packaged the same shift into the Gemini app under the label Generative UI. What is being noticed is not a new format. It is what an agent’s reply already wants to be.

For a while Markdown was the right default for a defensible reason. In the GPT-4 era the context window was 8,192 tokens; Markdown’s token-economy over HTML was worth real money. That constraint has lifted. The default lagged because defaults always lag.

The four properties

Once the token argument is gone, the two formats separate on four properties.

The first is density. Markdown gives the writer headings, lists, tables, code blocks. HTML gives the writer those plus CSS, SVG, images, canvas, layout, and arbitrary interaction. A diagram in Markdown is an ASCII tree. A diagram in HTML is an SVG with arrows and annotations that line up.

The second is readability under length. Past about a hundred lines, Markdown is a wall. Shihipar’s wry observation is that he himself stops reading at that point, and he is the author of the reply. A 200-line spec rendered as a page — a table of contents pinned to one side, status pills, appendices folded — is something a colleague will open. The information is the same. The reading rate is not.

The third is shareability. A Markdown file needs a renderer. An .html file is the renderer. Upload it, send the link, the reader opens the page directly.

The fourth is interactivity. A static report cannot be argued with. An interactive report — drag the priority slider, watch the table re-sort, hit copy as JSON — closes the loop back to the conversation that produced it. The Claude Code post catalogues these throwaway interfaces: a kanban for ticket triage, a feature-flag editor that warns about dependencies, a side-by-side prompt comparator with a live token count. Each is generated once to answer one question, then discarded.

What is shifting is not the format of the reply. It is the category. Markdown is a document. HTML, in this usage, is an interface — temporary, single-use, generated on the way to a decision.

A division of labour

A useful clarification before going further. This is not “Markdown is finished.” Markdown remains the right default for sources — for README.md, for CLAUDE.md, for anything edited version-by-version and merged in git. The clean split, once the dust settles, is roughly this: Markdown for what is maintained, HTML for what is read, JSON or YAML for what is passed between systems.

A division of labour across three text formatsMarkdown holds what is maintained — readmes, project guides, content sources. HTML holds what is read — reports, specs, throwaway interfaces. JSON or YAML holds what is passed between systems — API payloads, configuration, structured data. Each format is named for the verb its tooling does best: diffs cleanly, renders cleanly, parses cleanly.WHAT EACH FORMAT IS GOOD FORMarkdownHTMLJSON / YAMLMAINTAINEDREADPASSEDREADME.md, CLAUDE.md,content sourcesreports, specs,throwaway interfacesAPI payloads,config, structured datadiffs cleanlyrenders cleanlyparses cleanly

Each layer’s strength is obvious once it is named. Markdown diffs cleanly. HTML renders cleanly. JSON parses cleanly. The mistake was asking any one of them to do the others’ jobs.

Inside the inline sandbox

The newest fold of this shift is happening inside the chat itself. Not as Markdown, not as an Artifact, a third thing in the middle: the reply renders as HTML directly inline in the conversation, in a sandbox the model writes for the moment and the conversation immediately runs.

The sandbox is small, and its boundaries are worth knowing.

  • The HTML is sandboxed and ephemeral. Every re-render goes through a tool call; state does not survive across messages.
  • Network requests and external scripts are restricted to a CDN whitelist — cdnjs, esm.sh, jsdelivr, unpkg. The sandbox cannot reach your own API or your MCP servers.
  • There is no localStorage. There is no routing. State is whatever the JavaScript holds in memory.
  • A single bridge, sendPrompt(text), posts a message back into the conversation. This is what lets a button inside the rendered widget say Save changes and have the result return as the next turn.

What this is for — a calculator, a flow diagram, an explanation with parameters one can vary, a lightweight dashboard, a form mockup — is clear in five minutes. What it is not for — anything needing a backend, login, persistence, cross-session memory — is also clear: that is what Artifacts (with window.storage) or a real Claude API integration exist to do.

Below is one such reply: a form that updates a live JSON state object as the reader interacts with it. The two buttons at the bottom would normally call back into the conversation; in the iframe here they fall through to the browser console.

Rendered in an isolated iframe approximating Claude’s chat sandbox. The styling is the model’s own.

The source — the HTML the model actually emitted — is below.

<div style="display: flex; flex-direction: column; gap: 1.5rem; padding: 1rem 0;">

  <h2 class="sr-only">Interactive form showcase covering text inputs, selects, checkboxes, radios, range sliders, color picker, file inputs, a custom toggle, and buttons with a live state preview.</h2>

  <!-- Account -->
  <div>
    <h3 style="margin: 0 0 .75rem;">Account</h3>
    <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
      <div>
        <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Full name</label>
        <input id="f-name" type="text" placeholder="Jane Doe" style="width: 100%;" />
      </div>
      <div>
        <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Email</label>
        <input id="f-email" type="email" placeholder="jane@example.com" style="width: 100%;" />
      </div>
      <div>
        <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Password</label>
        <input id="f-pw" type="password" placeholder="At least 8 characters" style="width: 100%;" />
      </div>
      <div>
        <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Date of birth</label>
        <input id="f-dob" type="date" style="width: 100%;" />
      </div>
    </div>
  </div>

  <!-- Profile -->
  <div>
    <h3 style="margin: 0 0 .75rem;">Profile</h3>
    <div style="display: flex; flex-direction: column; gap: 12px;">
      <div>
        <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Bio</label>
        <textarea id="f-bio" placeholder="Tell us a little about yourself..." style="width:100%;"></textarea>
      </div>
      <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
        <div>
          <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Country</label>
          <select id="f-country" style="width:100%;">
            <option>United States</option>
            <option>United Kingdom</option>
            <option>China</option>
            <option>Japan</option>
            <option>Germany</option>
            <option>Brazil</option>
          </select>
        </div>
        <div>
          <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Age</label>
          <input id="f-age" type="number" min="0" max="120" value="28" style="width:100%;" />
        </div>
        <div>
          <label style="display:block; font-size:13px; color: var(--color-text-secondary); margin-bottom:6px;">Website</label>
          <input id="f-site" type="url" placeholder="https://" style="width:100%;" />
        </div>
      </div>
    </div>
  </div>

  <!-- Preferences -->
  <div>
    <h3 style="margin: 0 0 .75rem;">Preferences</h3>
    <div style="display: flex; flex-direction: column; gap: 14px;">
      <div>
        <div style="font-size:13px; color: var(--color-text-secondary); margin-bottom:8px;">Plan</div>
        <div style="display:flex; gap: 18px; font-size: 14px; flex-wrap: wrap;">
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="radio" name="plan" value="free" checked /> Free</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="radio" name="plan" value="pro" /> Pro</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="radio" name="plan" value="team" /> Team</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="radio" name="plan" value="enterprise" /> Enterprise</label>
        </div>
      </div>
      <div>
        <div style="font-size:13px; color: var(--color-text-secondary); margin-bottom:8px;">Interests</div>
        <div style="display:flex; gap: 18px; font-size: 14px; flex-wrap: wrap;">
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="checkbox" class="interest" value="design" checked /> Design</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="checkbox" class="interest" value="engineering" checked /> Engineering</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="checkbox" class="interest" value="marketing" /> Marketing</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="checkbox" class="interest" value="research" /> Research</label>
          <label style="display:flex; align-items:center; gap:6px; cursor:pointer;"><input type="checkbox" class="interest" value="ops" /> Operations</label>
        </div>
      </div>
    </div>
  </div>

  <!-- Display -->
  <div>
    <h3 style="margin: 0 0 .75rem;">Display</h3>
    <div style="display: flex; flex-direction: column; gap: 14px;">
      <div style="display: flex; align-items: center; gap: 12px;">
        <label style="font-size:14px; color: var(--color-text-secondary); min-width: 100px;">Font size</label>
        <input id="f-font" type="range" min="10" max="24" value="16" step="1" style="flex:1;" />
        <span id="f-font-out" style="font-size:14px; font-weight:500; min-width: 40px; text-align:right;">16px</span>
      </div>
      <div style="display: flex; align-items: center; gap: 12px;">
        <label style="font-size:14px; color: var(--color-text-secondary); min-width: 100px;">Volume</label>
        <input id="f-vol" type="range" min="0" max="100" value="60" step="1" style="flex:1;" />
        <span id="f-vol-out" style="font-size:14px; font-weight:500; min-width: 40px; text-align:right;">60%</span>
      </div>
      <div style="display: flex; align-items: center; gap: 12px;">
        <label style="font-size:14px; color: var(--color-text-secondary); min-width: 100px;">Accent color</label>
        <input id="f-color" type="color" value="#185fa5" style="width: 48px; height: 32px; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); background: transparent; padding: 0; cursor: pointer;" />
        <span id="f-color-out" style="font-size:13px; font-family: var(--font-mono); color: var(--color-text-secondary);">#185fa5</span>
      </div>
      <div style="display: flex; align-items: center; justify-content: space-between;">
        <label for="f-notif" style="font-size:14px;">Enable email notifications</label>
        <button id="f-notif" role="switch" aria-checked="true" style="position: relative; width: 40px; height: 22px; padding: 0; border-radius: 999px; background: var(--color-text-info); border: none; cursor: pointer;">
          <span id="f-notif-knob" style="position:absolute; top:2px; left:20px; width:18px; height:18px; background:#fff; border-radius:50%; transition: left .15s ease;"></span>
        </button>
      </div>
    </div>
  </div>

  <!-- Attachments + Search -->
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
    <div>
      <h3 style="margin: 0 0 .75rem;">Attachments</h3>
      <input type="file" id="f-file" />
      <input type="file" id="f-file2" multiple accept="image/*" style="margin-top: 8px;" />
    </div>
    <div>
      <h3 style="margin: 0 0 .75rem;">Search</h3>
      <input id="f-search" type="search" placeholder="Search anything..." style="width: 100%;" />
    </div>
  </div>

  <!-- Actions -->
  <div style="display: flex; gap: 8px; padding-top: 1rem; border-top: 0.5px solid var(--color-border-tertiary); flex-wrap: wrap;">
    <button id="f-submit" style="background: var(--color-text-primary); color: var(--color-background-primary); border-color: var(--color-text-primary);">Save changes</button>
    <button id="f-cancel">Cancel</button>
    <button disabled>Disabled</button>
    <button style="border-color: var(--color-border-danger); color: var(--color-text-danger); margin-left: auto;">Delete account</button>
  </div>

  <!-- Live state -->
  <div style="background: var(--color-background-secondary); border-radius: var(--border-radius-lg); padding: 14px 16px;">
    <div style="font-size: 12px; color: var(--color-text-secondary); margin-bottom: 8px;">Live state</div>
    <pre id="f-state" style="margin: 0; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--color-text-primary); white-space: pre-wrap; word-break: break-word;">{}</pre>
  </div>
</div>

<script>
(function () {
  const $ = (id) => document.getElementById(id);
  const fontOut = $('f-font-out');
  const volOut = $('f-vol-out');
  const colorOut = $('f-color-out');
  const stateOut = $('f-state');

  let notifOn = true;

  function readState() {
    const interests = Array.from(document.querySelectorAll('.interest:checked')).map(el => el.value);
    const planEl = document.querySelector('input[name="plan"]:checked');
    return {
      name: $('f-name').value,
      email: $('f-email').value,
      passwordLength: $('f-pw').value.length,
      dob: $('f-dob').value,
      bio: $('f-bio').value,
      country: $('f-country').value,
      age: Number($('f-age').value) || 0,
      website: $('f-site').value,
      plan: planEl ? planEl.value : null,
      interests: interests,
      fontSize: Number($('f-font').value),
      volume: Number($('f-vol').value),
      accentColor: $('f-color').value,
      emailNotifications: notifOn,
      search: $('f-search').value
    };
  }

  function render() {
    fontOut.textContent = $('f-font').value + 'px';
    volOut.textContent = $('f-vol').value + '%';
    colorOut.textContent = $('f-color').value;
    stateOut.textContent = JSON.stringify(readState(), null, 2);
  }

  document.addEventListener('input', render);
  document.addEventListener('change', render);

  const toggle = $('f-notif');
  const knob = $('f-notif-knob');
  toggle.addEventListener('click', () => {
    notifOn = !notifOn;
    toggle.setAttribute('aria-checked', String(notifOn));
    if (notifOn) {
      toggle.style.background = 'var(--color-text-info)';
      knob.style.left = '20px';
    } else {
      toggle.style.background = 'var(--color-border-secondary)';
      knob.style.left = '2px';
    }
    render();
  });

  $('f-submit').addEventListener('click', () => {
    const s = readState();
    sendPrompt('Here is the form data I just submitted: ' + JSON.stringify(s));
  });

  $('f-cancel').addEventListener('click', () => {
    document.querySelectorAll('input, textarea, select').forEach(el => {
      if (el.type === 'checkbox' || el.type === 'radio') {
        el.checked = el.defaultChecked;
      } else if (el.type !== 'file') {
        el.value = el.defaultValue || '';
      }
    });
    render();
  });

  render();
})();
</script>

Why the styles are inline

One detail of that source is worth pausing on. Every style is inline. No class=, no Tailwind utility, no external stylesheet. style="display: flex; flex-direction: column; gap: 1.5rem;" and so on, all the way down. This is not laziness. It is the only form that fits the medium.

Four reasons.

First, there is no build step. Tailwind is a compiler — utility classes like text-blue-500 and gap-4 exist because a build pass scans the source, generates the corresponding CSS, and injects a stylesheet. The chat sandbox has none of that. The model emits raw HTML; the conversation pastes it onto the page. A class="text-blue-500" would do nothing because nothing translated it.

Second, streaming rendering rules out external stylesheets. The reply does not arrive whole. It streams in, token by token, and the chat client renders each token as it lands. An external stylesheet — or any utility class system that depends on one — opens a window between the markup arriving and the matching CSS arriving. Inside that window the element is visible but unstyled. Inline styles close that window: the style arrives with the element and the page assembles in place, never flashing. Anthropic’s own guidance for the chat sandbox is explicit about this — prefer inline style="..." over <style> blocks; inputs and controls must look correct mid-stream.

Streaming render: external stylesheet versus inline stylesFive tokens arrive over time. With an external stylesheet the first four render as outlines — visible but unstyled — and only the fifth renders styled, once the stylesheet has loaded. The moment of catch-up is the flicker. With inline styles every token arrives already styled and the page assembles in place.STREAMING RENDERExternal stylesheetstylesheet arrivesflickerInline stylesRENDER TIME

Third, the host provides design tokens, not utilities. Look again at the reply’s CSS variables: var(--color-text-secondary), var(--color-text-info), var(--color-background-secondary), var(--border-radius-md), var(--font-mono). These are injected by Claude’s chat shell, in light and dark variants. The model writes against the variables it is offered; the variables are the host’s vocabulary. Tailwind would be a parallel vocabulary the host does not speak.

Fourth, inline styles are portable. The output is one cell in a long conversation; it may be copied, pasted into a doc, dropped into a colleague’s chat, opened later as a file. Each of those moves would strip a separate stylesheet. Inline styles travel.

Behind all four is a quieter property of the medium. The model produces all of this in one pass. It cannot iterate on a stylesheet, see how the page rendered, edit selectors, re-run. In a single-pass generation, every styling decision has to live next to the markup that depends on it. Inline is the form a single-pass document takes.

Put together, the practice is a tell about what the reply is. A reply written in Tailwind would presume a host that compiles Tailwind. A reply written in CSS variables presumes only a host willing to share its tokens — and so the reply is portable across hosts that share that shape: Claude’s chat, an Artifact, an exported file. Inline-styled HTML is the form a guest takes when its only contract with the host is I will hand you my variables.

Consistency without a compiler

Tokens explain what colour and what radius. They do not explain what a widget is. They cannot say that a metric is a single large numeral over a quiet label, that a thirty-row table needs a sticky header, that a bar chart with one series must not lean on colour alone, that flat means flat — no gradients, no shadows, no noise. Each of those is a decision the host holds an opinion about. None of them is a value a CSS variable can hold.

The usual answer to this gap is a component library: <Card>, <MetricBlock>, <DataTable>, written once and imported everywhere. The chat sandbox cannot use that answer. There is no import; there is no build; the model produces one HTML string in one pass and the page renders it. The host cannot ship components — it has no compiler to install them with. What the host can ship is the model’s context. So the spec moves into the prompt.

What sits in the prompt is a Markdown document several thousand words long, pulled in by an internal tool — read_me — the moment the model reaches for a visual reply. Inside Anthropic it goes by Imagine — Visual Creation Suite. It opens with five modules the model requests à la carte — diagram, mockup, interactive, chart, art — and continues into a core design system every module shares, a UI component catalogue, a bridge spec, and the full table of CSS variables in both light and dark. Above all of it sits a philosophy stated in three words: seamless, flat, compact.

The rules themselves follow. They are the interesting part — concrete to the level of irritation, written like negative space:

## Rules

- No `<!-- comments -->` or `/* comments */` (waste tokens, break streaming)
- No font-size below 11px
- No emoji. Icons = Tabler **outline** webfont (5800+, already loaded): `<i class="ti ti-home"></i>`. Outline only — never use `-filled` suffixes (`ti-heart-filled` etc. are not loaded and will render blank). Inherits color + font-size from parent. Decorative icons get `aria-hidden="true"`; icon-only buttons get `aria-label`. Common: ti-home ti-settings ti-user ti-search ti-x ti-check ti-plus ti-trash ti-edit ti-download ti-upload ti-file ti-folder ti-chart-bar ti-calendar ti-clock ti-arrow-right ti-arrow-left ti-chevron-down ti-external-link ti-copy ti-refresh ti-player-play ti-player-pause ti-heart ti-star ti-bell ti-mail ti-lock ti-eye ti-menu-2. Don't hand-draw icon SVG paths.
- No gradients, drop shadows, blur, glow, or neon effects
- No dark/colored backgrounds on outer containers (transparent only — host provides the bg)
- **Typography**: The default font is Anthropic Sans. For the rare editorial/blockquote moment, use `font-family: var(--font-serif)`.
- **Headings**: h1 = 22px, h2 = 18px, h3 = 16px — all `font-weight: 500`. Heading color is pre-set to `var(--color-text-primary)` — don't override it. Body text = 16px, weight 400, `line-height: 1.7`. **Two weights only: 400 regular, 500 bold.** Never use 600 or 700 — they look heavy against the host UI.
- **Sentence case** always. Never Title Case, never ALL CAPS. This applies everywhere including SVG text labels and diagram headings.
- **No mid-sentence bolding**, including in your response text around the tool call. Entity names, class names, function names go in `code style` not **bold**. Bold is for headings and labels only.
- The widget container is `display: block; width: 100%`. Your HTML fills it naturally — no wrapper div needed. Just start with your content directly. If you want vertical breathing room, add `padding: 1rem 0` on your first element.
- Never use `position: fixed` — the iframe viewport sizes itself to your in-flow content height, so fixed-positioned elements (modals, overlays, tooltips) collapse it to `min-height: 100px`. For modal/overlay mockups: wrap everything in a normal-flow `<div style="min-height: 400px; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;">` and put the modal inside — it's a faux viewport that actually contributes layout height.
- No DOCTYPE, `<html>`, `<head>`, or `<body>` — just content fragments.
- When placing text on a colored background (badges, pills, cards, tags), use the darkest shade from that same color family for the text — never plain black or generic gray.
- **Corners**: use `border-radius: var(--border-radius-md)` (or `-lg` for cards) in HTML. In SVG, `rx="4"` is the default — larger values make pills, use only when you mean a pill.
- **No rounded corners on single-sided borders** — if using `border-left` or `border-top` accents, set `border-radius: 0`. Rounded corners only work with full borders on all sides.
- **No titles or prose inside the tool output** — see Philosophy above.
- **Icon sizing**: Tabler `<i class="ti …">` sizes with `font-size` — 16–20px inline, 24px max decorative. For one-off inline SVG icons, set `width`/`height` explicitly (same limits).
- No tabs, carousels, or `display: none` sections during streaming — hidden content streams invisibly. Show all content stacked vertically. (Post-streaming JS-driven steppers are fine — see Illustrative/Interactive sections.)
- No nested scrolling — auto-fit height.
- Scripts execute after streaming — load libraries via `<script src="https://cdnjs.cloudflare.com/ajax/libs/...">` (UMD globals), then use the global in a plain `<script>` that follows.
- **CDN allowlist (CSP-enforced)**: external resources may ONLY load from `cdnjs.cloudflare.com`, `esm.sh`, `cdn.jsdelivr.net`, `unpkg.com`. All other origins are blocked by the sandbox — the request silently fails.
Excerpt: the Rules block from 00-core. The full spec is the companion archive.

Read past the first few bullets and what’s striking isn’t the strictness, it’s the content. The rules are mostly not aesthetic preferences. They are a catalogue of failure modes: flicker during streaming, the iframe collapsing because something was position: fixed, an icon class that doesn’t exist because the model reached for a -filled Tabler suffix that isn’t preloaded, a display: none region whose content streamed invisibly and never came back. Each line names a specific way the model has produced a broken widget before, and writes a flat don’t next to it. The closest thing on a normal team is a senior reviewer’s post-incident memo — except the memo here runs in front of every generation, not after.

Two short clauses give the flavour. “Two weights only: 400 regular, 500 bold. Never use 600 or 700 — they look heavy against the host UI.” The rule is not about typography in the abstract; it is about fit with one specific host’s surface. “Round every displayed number. JS float math leaks artifacts — 0.1 + 0.2 gives 0.30000000000000004.” The model is being told, in that clause, not to leak its own implementation across the seam to the user. Both are written down because the model otherwise gets them wrong.

None of this is style. It is conformance, written down. The reason every visual Claude reply has the same surface — the same flatness, the same outline icons, the same gentle spacing — is not a house aesthetic. It is that each one was generated against the same checklist, in a single pass, with no review.

This is the deeper version of the inline-style observation. The host cannot lint the reply after the fact; it can only narrate the reply in advance. Tokens are the host’s nouns; the spec is the host’s grammar. Together they give the model enough vocabulary and enough constraint to produce something the host can render without surprises. A reply that is an interface needs an interface contract — and the only place to keep that contract, when the guest is a language model writing HTML in one pass, is in the words the model reads just before it writes. The full document, including the four other modules and the SVG-setup specification, is preserved as a companion archive.

From document to interface

It is tempting to read all of this as a small format war. It is not. It is the shape of the reply itself changing, under cover of the format change.

For five years the model’s job was to write a document. The user asked, the model wrote prose, the user read prose. The reply was a static text; Markdown was the natural carrier.

What the model is being asked to do now is different. Generate an explanation a non-technical colleague can follow on first read. Produce a spec a team will actually open. Lay out a comparison the reader can manipulate. Show a configuration the reader can edit before committing it. None of these wants are met by prose. They are met by a small, single-use interface.

The reply has stopped being a document. It is becoming an interface with a time-to-live. Markdown will remain the source format. HTML will be the reading format. The underlying data — JSON, YAML, a database row — will be what the two formats describe. The shift is mostly invisible because none of the three layers is new. What is new is which layer the reply lives in.

References

  1. Thariq Shihipar, “The unreasonable effectiveness of HTML”, Claude Code blog, May 2026.
  2. Simon Willison, “The unreasonable effectiveness of HTML”, May 2026.
  3. Google Research, “Generative UI: a rich, custom, visual, interactive user experience for any prompt”.
  4. Google, “Gemini 3 in the Gemini app”.