---
name: artifact
version: 1.3.3
description: Embed a rendered HTML file or live URL inline in chat via a single self-closing `<artifact src="..." />` tag — the Svamp client renders it as a sandboxed iframe with modes for inline, card, bare, and immersive.
tags:
  - svamp
  - artifact
  - rendering
  - visualization
author: svamp
license: MIT
---

# Artifact

Embed rich visual output inline by writing an HTML file (or pointing at a running server's URL) and emitting a single self-closing tag:

```
<artifact src="./outputs/dashboard.html" title="Dashboard" />
```

The client reads the file, inlines relative refs (`./image.png` etc.) as data URIs, and renders it in a sandboxed iframe — like a Jupyter cell output. **Default to plain markdown for ordinary answers.**

> ⚠ **Inline artifacts are for SMALL self-contained HTML only.** Anything large — images, datasets, big reports, base64 blobs — **must** be written to a file and referenced via `<artifact src="./outputs/<file>" />` (or served with `svamp serve`), never pasted inline as base64/HTML. An inline `<artifact>…body…</artifact>` body over **512 KB** is rejected: the client shows an error card (not the content) and the daemon strips it and asks you to redo it as a file reference. Inline content is stored verbatim in the chat history and re-sent on every replay — a huge inline blob bloats the session and can make it unloadable.

## Strategy: which mechanism?

Four options, pick the lightest that works:

| Mechanism | When |
|---|---|
| **Inline body** `<artifact title="…">…html…</artifact>` | Tiny self-contained widgets: a styled metric card, an SVG diagram, a small Chart.js plot. No file needed; the HTML lives in the chat message. Keep under ~10 KB. |
| **File `<artifact src="./viz.html" />`** | Substantial visualization (dashboards, 3D scenes, multi-section explorers). Persists on disk, supports relative-ref inlining for `./image.png`, can be `svamp serve`-shared as a stable URL, iterates cleanly via singleton dedupe. |
| **URL `<artifact src="https://my-app.example.com/" height="540" />`** | Embed a live server you're running, or any external site. Canvas-panel-style. |
| **Standalone server (`svamp serve` / `svamp service expose`)** | Real apps with backend, database, multi-user state. Post the URL as a normal markdown link, no artifact tag needed. |

**Decision tree:**
1. Need a backend / database / persistent state? → standalone server.
2. ~10 KB or less, throwaway widget? → **inline body** (`<artifact>...html...</artifact>`).
3. Will you iterate on it across messages? → **file** (write file, emit `<artifact src="..." />`).
4. Embed a running server or external site? → **URL src**.

While the agent is still emitting the body content (closing `</artifact>` not yet received), the client shows a "Generating… N chars" placeholder. The iframe only mounts once the close tag arrives — no flashing of half-rendered HTML.

## Attributes

```html
<artifact
    src="./outputs/viz.html"     <!-- file path OR absolute URL; OR omit and use inline body -->
    title="Dashboard"            <!-- header label -->
    height="540"                 <!-- fixed pixel height (10..4000); default = auto-size for files -->
    width="80%"                  <!-- px number (10..4000) or percentage; default = full chat-column width -->
    mode="default"               <!-- "default" | "bare" | "immersive" | "card" -->
    description="..."            <!-- card mode: summary text -->
    poster="./outputs/thumb.png" <!-- card mode: thumbnail image -->
/>
```

Either `src` or an inline body is required. All other attributes optional.

### Modes

| Mode | Chrome | When |
|---|---|---|
| `default` *(omit `mode`)* | Header with title + Reload + Fullscreen + ⋮ menu | Most things. Always discoverable. |
| `bare` | Tiny floating control bar on hover only | Polished demos / posters where the header would distract; user can still reach reload + fullscreen. |
| `immersive` | None — pure iframe | Game-like or generative experiences where ANY chrome breaks the illusion. User uses browser controls (right-click, etc.). |
| `card` | A preview card; click opens in new tab | Output is built for fullscreen viewing — 3D scenes, dashboards too large for chat flow. Combine with `description` and `poster`. |

### When to set each

- `title` — short artifact name ("3D scene", "Sales dashboard"). Surfaced in the header, the new-tab title, and the singleton "Newer version of …" pill.
- `height` — for content with no natural document height (Three.js scenes, video, fullscreen apps, URL embeds). For URL `src`, defaults to 540 px because auto-resize can't work cross-origin.
- `width` — rarely needed. Artifacts span the **full chat-column width by default**. Set a px value or percentage (e.g. `width="400"`, `width="60%"`) only to deliberately shrink an artifact — a small widget that shouldn't be column-wide. The chat-column cap still applies, so this only narrows, never widens past the column.
- `description` / `poster` — only used in card mode.

### Header / menu

In `default` and `bare` modes, the user gets a header (or hover bar) with three control surfaces:
- **Reload** (↻) — re-fetches the file or cache-busts the URL. Use when iterating: edit the file, click reload — no need to scroll away.
- **Fullscreen** (⛶) — toggles iframe fullscreen. Hidden automatically on browsers that don't support it (some mobile WebViews).
- **⋮ menu** — *Open in new window* (standalone tab), *Reload* (same as the icon), *Show as card* (only when the agent originally emitted `mode="card"` and the user expanded it).

## Going inline (no file)

For widgets that don't deserve a file — a metric card, a small SVG diagram, a single Chart.js plot — omit `src` and put the HTML directly inside the tag:

```
<artifact title="Quarterly revenue">
<div style="font-family:system-ui;padding:16px;background:var(--svamp-surface);border-radius:8px;color:var(--svamp-text)">
  <div style="font-size:11px;color:var(--svamp-text-secondary);text-transform:uppercase;letter-spacing:0.05em">Q4 revenue</div>
  <div style="font-size:28px;font-weight:600;margin-top:4px">$4.2M</div>
  <div style="font-size:12px;color:#059669;margin-top:2px">↑ 18% vs Q3</div>
</div>
</artifact>
```

Inline body works exactly like file content — same sandbox, same theme bridge, same CSP, same auto-resize, same modes. Differences:

- **No relative file refs.** Inline body has no associated directory, so `<img src="./local.png">` won't resolve. If you need files, use `src=`.
- **Bloats chat history.** Inline content lives in the message JSONL, so every replay/sync sends it. Keep under ~10 KB. For anything substantial, write to a file.
- **No `svamp serve`-style stable URL.** Inline blobs are ephemeral. Use `src=` to a file if you want shareable URLs.

When in doubt: inline for tiny standalone widgets, file for anything you might iterate on or share.

## Writing the file

Use the standard Write tool. Two storage conventions:

**Disposable (chat-related, ephemeral):** save under `.svamp/<sessionId>/outputs/`. Not tracked by git. Cleaned up with the session.
```
.svamp/<sessionId>/outputs/dashboard.html
.svamp/<sessionId>/outputs/3d-scene.html
```

**Persistent (project artifact):** save under `./outputs/`, `./reports/`, or wherever the project keeps these. Tracked in git if relevant.
```
./outputs/sales-report.html
./reports/quarterly.html
```

Pick disposable when the output is throwaway (one-off charts, scratch demos). Pick persistent when the artifact has lasting value (final reports, deliverables).

## Iteration

When you iterate on the same artifact, **edit the file in place** and emit a new `<artifact src="..." />` tag. The client auto-detects that two blocks share a `src` and:
- Renders only the **latest** block inline.
- Collapses older ones into a "Newer version below ↓" pill that scrolls to the latest on click.

This means you can iterate freely without polluting the chat with stale renders.

## Sharing

To share with a colleague, run `svamp serve`:

```bash
# DEFAULT is the `link` tier — a capability URL. NO login required:
# anyone who has the URL can read it. The hostname IS the secret.
svamp serve dashboard ./outputs

# Login-gated (only you, via Hypha) — opt IN to this.
# NOTE the owner tier is the BOOLEAN flag below. Do NOT pass the word "owner" to `--access`:
# that flag takes an access LIST, which must contain email addresses, and it hard-errors with
#   serve: invalid access entry "owner" — an access list must contain email addresses
# (#2024 — this block used to document exactly that broken form.)
svamp serve dashboard ./outputs --owner

# Restrict to specific emails (they log in via Hypha)
svamp serve dashboard ./outputs --access alice@x.com,bob@y.com

# Listed publicly (anyone, no URL secrecy assumed)
svamp serve dashboard ./outputs --public
```

⚠️ **Do not assume the default is login-gated.** It is not — `svamp serve` prints the tier back to
you as `link (capability URL — anyone with the URL can view)`. Before serving anything internal,
either pass `--owner` or accept that possession of the URL is possession of the content.
Because the hostname is the secret, there is no way to revoke access to a `link` mount other than
`--regenerate-url`, which mints a new one; deleting the mount does not un-publish what was already
fetched.

Use the URL exactly as `serve`/`serve list` prints it — never compose it by hand, and never append a
path to it.

Post the resulting URL as a normal markdown link. The svamp client doesn't manage shares — `svamp serve` is the dedicated tool.

## Rendering environment

- **Sandbox**: `allow-scripts allow-popups`. Scripts run; the iframe is null-origin and cannot read parent cookies/storage/DOM.
- **Fullscreen**: enabled — both the parent's fullscreen button and `document.documentElement.requestFullscreen()` work.
- **Network**: `connect-src https: wss:` — `fetch()` to HTTPS URLs and WebSocket to wss:// work. HTTP is blocked.
- **Nested iframes**: forbidden (`frame-src 'none'`) — anti-phishing.
- **Relative refs**: in file mode, `src=`/`href=` paths inside the HTML resolve against the HTML file's directory and are inlined as `data:` URIs (5 MB cap total). In URL mode, the iframe loads the URL directly and resolves refs natively.
- **Auto-resize**: file mode injects a ResizeObserver that posts height to the parent. URL mode can't auto-resize (cross-origin) — set `height="..."`.
- **Removed files**: if a file is deleted, the renderer shows a "Removed: ./path" pill.

## Theming

The Svamp client injects the user's current theme into file-mode iframes as CSS custom properties on `<html>`, plus a `data-theme="dark|light"` attribute. Reference these to adapt automatically:

```css
:root {
  --svamp-bg            /* base background */
  --svamp-surface       /* card / elevated */
  --svamp-surface-high  /* highest elevation */
  --svamp-text          /* primary text */
  --svamp-text-secondary
  --svamp-accent        /* link / accent */
  --svamp-divider       /* borders */
}

body { background: var(--svamp-bg); color: var(--svamp-text); }
[data-theme="dark"] .hero { background: linear-gradient(...dark...); }
```

Theme updates push live via `postMessage` — no reload. URL mode does not get theme injection (we don't own the document).

For a branded visualization with its own deliberate palette, hardcode your colors and ignore the variables.

## Talking to agent-spawned servers

If your iframe `fetch()`es from a server you've run via `svamp service expose`, the server **must** include CORS headers (the iframe origin is `null`):

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
```

### FastAPI

```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
```

### Express

```javascript
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
```

### Plain Node http

```javascript
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') return res.end();
```

If the iframe console shows `CORS error`, your server is missing one of these headers.

## Style & design system

These are **recommendations, not rules**. They produce coherent, polished output by default; deviate when the content calls for it (artistic visualizations, branded posters, deliberately moody experiences).

### Philosophy

- **Seamless** — the artifact should feel like it belongs in the chat, not a foreign embed. Use `var(--svamp-bg)` / `var(--svamp-text)` so the visual flips with the host theme.
- **Flat** — solid fills only by default. Skip gradients, shadows, glow, neon unless the content explicitly calls for them. Use **borders OR shadows, not both**.
- **Warm minimal** — clean geometric layouts with soft rounded corners (8–12 px). Indigo or sky as primary accent. Slate / zinc / stone for structural neutrals.
- **Diverse** — pick the visualization type that fits the content: flowchart, timeline, hierarchy, cycle, comparison, chart, calculator. Don't default to one.
- **Text outside, visuals inside** — prose explanation goes in markdown *around* the artifact, not inside it.

### Typography

- Base 14–16 px, line-height ~1.5, system-ui font stack (`system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`).
- Font weights **400 / 500 / 600** only. Skip 700+ unless it's deliberately punchy.
- Sentence case for titles. No ALL CAPS labels unless they're tiny (10–11 px) UI labels with letter-spacing.
- Never set font-size below 11 px — unreadable on mobile.
- Round every displayed number (`$4.2M`, not `$4218354.97`).

### Spacing

Use a 4-px rhythm: 4 / 8 / 12 / 16 / 24 / 32 / 48 px. Pick one scale and stick to it — sloppy spacing reads as noise even when individual elements look fine.

### Color palette (when you need accents)

A handy ramp library — five shades per ramp. Use 2–3 ramps per artifact max. Indigo / sky for the primary accent, slate / zinc as structural neutrals.

| Ramp | 50 (fill) | 200 (stroke) | 400 (accent) | 600 (subtitle) | 800 (title) |
|------|-----------|-------------|-------------|----------------|-------------|
| Indigo | `#EEF2FF` | `#C7D2FE` | `#818CF8` | `#4F46E5` | `#3730A3` |
| Sky | `#F0F9FF` | `#BAE6FD` | `#38BDF8` | `#0284C7` | `#075985` |
| Emerald | `#ECFDF5` | `#A7F3D0` | `#34D399` | `#059669` | `#065F46` |
| Amber | `#FFFBEB` | `#FDE68A` | `#FBBF24` | `#D97706` | `#92400E` |
| Rose | `#FFF1F2` | `#FECDD3` | `#FB7185` | `#E11D48` | `#9F1239` |
| Slate | `#F8FAFC` | `#E2E8F0` | `#94A3B8` | `#64748B` | `#334155` |

- Text on a 50-fill: use 800 from the same ramp. Never pure black.
- SVG defaults: 50 fill + 200 stroke + 800 title + 600 subtitle.
- Chart.js: 400 for `borderColor`, 400 with 0.1 alpha for `backgroundColor`.

For inline widgets, you can still reference `var(--svamp-accent)` etc. and skip the ramps — both approaches are fine.

### Animation

Animation should *convey* state change, not decorate. Entry animations: 200–400 ms ease-out, stagger child elements by 60–100 ms. Anything longer feels slow.

### Streaming-friendly write order

If you're emitting an inline artifact body that the agent generates token-by-token (the user will see streaming progress in the placeholder, but if they ever see partial HTML rendered, this order minimizes flashing):

- **SVG**: open `<svg>`, emit `<defs>` (markers, gradients) first, then visual elements top-to-bottom.
- **HTML**: `<style>` block first, then content, then `<script>` last. Solid fills work even mid-stream; gradients can flash during DOM diffs.

### Mobile-first

Single column by default. Widen at media queries (`@media (min-width: 768px)`) only when there's information that benefits from columns. Test by mentally resizing to 320 px wide.

## Chart.js patterns

Chart.js is the go-to for data viz. Recommended setup:

```html
<div style="position:relative;width:100%;height:300px">
  <canvas id="c"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
  const chart = new Chart(document.getElementById('c'), {
    type: 'line',
    data: {
      labels: ['Jan','Feb','Mar','Apr','May'],
      datasets: [{
        data: [30, 45, 28, 50, 42],
        borderColor: '#818CF8',
        backgroundColor: 'rgba(129,140,248,0.1)',
        fill: true,
        tension: 0.3,
      }],
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      plugins: { legend: { display: false } },
      scales: {
        y: { grid: { color: 'rgba(0,0,0,0.06)' } },
        x: { grid: { display: false } },
      },
    },
  });
</script>
```

Rules of thumb:
- Wrapper `<div>` owns the height (Chart.js can't size to a parent that has `auto` height). `width: 100%; height: <N>px`.
- `responsive: true` + `maintainAspectRatio: false` so the canvas fills the wrapper.
- Hide the legend by default (`legend: { display: false }`) — adds noise more often than information. Show it only when there are 2+ datasets.
- Bars: `borderRadius: 6`. Lines: `tension: 0.3` for smooth, `0` for stepped.
- Multiple charts on one artifact: unique `canvas` IDs.
- Interactive controls **must** call `chart.update()` after mutating `chart.data`.

```js
function refresh() {
  const v = +document.getElementById('slider').value;
  chart.data.datasets[0].data = base.map(d => Math.round(d * v / 50));
  chart.update();
}
```

## SVG patterns

For flowcharts, timelines, hierarchy diagrams, comparisons:

```html
<svg width="100%" viewBox="0 0 680 H" style="display:block">
  <defs>
    <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
            markerWidth="6" markerHeight="6" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#94A3B8"/>
    </marker>
  </defs>
  <!-- nodes & connectors here -->
</svg>
```

ViewBox sizing:
- Fix width at **680** for a comfortable column. Adjust `H` to fit content + 40 px bottom buffer.
- Keep all content within `x = 0..680`. `text-anchor="end"` extends left from the x coord.
- `width="100%"` on the `<svg>` element so it scales to bubble width on mobile.

Recommended defaults for diagram shapes:
- Node fill: ramp **50** (light), stroke: ramp **200**, stroke-width 1 or 1.5.
- Node title text: ramp **800**, weight 500, size 14 px.
- Node subtitle: ramp **600**, weight 400, size 11–12 px.
- Connector lines: `#94A3B8` (slate-400) with an arrow marker.

## Common UI patterns

A short catalog. Pick the one that fits, don't force a single template.

- **Metric dashboard** — grid of small stat cards (label / number / delta). 4-col on desktop, 2-col on tablet, 1-col on mobile. See the "Quarterly metrics" template below.
- **Comparison** — side-by-side panels, the recommended option bordered in the accent color with a small label badge.
- **Calculator** — labeled `<input type="range">` sliders + live result display. Slider inputs must call `chart.update()` (or update the DOM directly) on every `input` event.
- **Timeline** — vertical or horizontal sequence with dates, dots, and connector lines.
- **Flowchart** — boxes + arrows in SVG; arrow markers via `<defs>`. Use a `viewBox` for crisp scaling.
- **Bar comparison** — horizontal bars with labels left, value right, bar fills indigo-200, value text indigo-800.
- **Toggle / select** — segmented buttons or a `<select>` to flip between dataset views.

## Templates

### 1. Comparison card (Tailwind via CDN)

```html
<!doctype html>
<html><head>
  <meta charset="utf-8">
  <script src="https://cdn.tailwindcss.com"></script>
</head><body class="p-6">
  <div class="grid md:grid-cols-2 gap-4 max-w-3xl mx-auto">
    <div class="rounded-2xl p-5" style="background: var(--svamp-surface); border: 1px solid var(--svamp-divider); color: var(--svamp-text);">
      <h2 class="font-semibold">Option A</h2>
      <p class="text-sm mt-1" style="color: var(--svamp-text-secondary);">Short, fast, fewer features.</p>
    </div>
    <div class="rounded-2xl p-5 relative" style="background: var(--svamp-surface); border: 2px solid var(--svamp-accent); color: var(--svamp-text);">
      <span class="absolute -top-2 right-4 text-white text-xs px-2 py-0.5 rounded" style="background: var(--svamp-accent);">Recommended</span>
      <h2 class="font-semibold">Option B</h2>
      <p class="text-sm mt-1" style="color: var(--svamp-text-secondary);">More work upfront; better long-term.</p>
    </div>
  </div>
</body></html>
```

Then write it to `./outputs/comparison.html` and emit:
```
<artifact src="./outputs/comparison.html" title="Comparison" />
```

### 2. Animated entry (no JS framework)

```html
<!doctype html>
<html><head>
  <meta charset="utf-8">
  <style>
    body { margin: 0; padding: 32px; font-family: system-ui; background: var(--svamp-bg); color: var(--svamp-text); }
    .card { opacity: 0; transform: translateY(8px); animation: fade .4s ease-out forwards; background: var(--svamp-surface); border-radius: 12px; padding: 16px 20px; margin-bottom: 12px; }
    .card:nth-child(2) { animation-delay: .1s; }
    .card:nth-child(3) { animation-delay: .2s; }
    @keyframes fade { to { opacity: 1; transform: none; } }
    .card p { margin: 4px 0 0; color: var(--svamp-text-secondary); font-size: 13px; }
    .card h3 { margin: 0; font-size: 15px; }
  </style>
</head><body>
  <div class="card"><h3>Step 1</h3><p>Gather inputs.</p></div>
  <div class="card"><h3>Step 2</h3><p>Transform them.</p></div>
  <div class="card"><h3>Step 3</h3><p>Persist the result.</p></div>
</body></html>
```

### 3. Full-bleed 3D scene (fixed height, immersive)

```html
<!doctype html>
<html><head><meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>html,body{margin:0;height:100%;overflow:hidden;background:#0f172a;}canvas{display:block;}</style>
</head><body>
  <script>
    const w = innerWidth, h = innerHeight;
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(45, w/h, 0.1, 100);
    camera.position.z = 4;
    const renderer = new THREE.WebGLRenderer({antialias:true});
    renderer.setSize(w, h);
    document.body.appendChild(renderer.domElement);
    scene.add(new THREE.AmbientLight(0xffffff, .6));
    const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(2,2,2); scene.add(light);
    const cube = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({color: 0x5eead4}));
    scene.add(cube);
    function loop(t){ cube.rotation.y = t*0.0006; cube.rotation.x = t*0.0004; renderer.render(scene, camera); requestAnimationFrame(loop); }
    requestAnimationFrame(loop);
  </script>
</body></html>
```

Emit:
```
<artifact src="./outputs/3d.html" title="3D viewer" height="540" mode="card" poster="./outputs/3d-thumb.png" description="Click to launch the rotating cube demo" />
```

### 4. Live data from agent-spawned API

```html
<!doctype html>
<html><head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head><body style="font-family:system-ui;padding:16px;background:var(--svamp-bg);color:var(--svamp-text)">
  <h2 style="margin:0 0 12px">Live sales</h2>
  <canvas id="c" height="240"></canvas>
  <script>
    fetch('https://sales-api-xxx.svamp.dev/last7days')
      .then(r => r.json())
      .then(rows => new Chart(document.getElementById('c'), {
        type: 'line',
        data: { labels: rows.map(r => r.day), datasets: [{label: 'Revenue', data: rows.map(r => r.usd), borderColor: '#0ea5e9'}] },
      }));
  </script>
</body></html>
```

Run server first:
```bash
# Server must include CORS headers — see "Talking to agent-spawned servers" above
svamp service expose sales-api --port 8000
```

Then emit:
```
<artifact src="./outputs/sales-dashboard.html" title="Sales (live)" />
```

### 5. URL embed (CanvasPanel-style)

For a running dev server, deployed app, or external site:

```
<artifact src="https://my-app.example.com/" title="My app" height="640" />
```

No file needed. Auto-resize doesn't work for URL embeds — set `height` explicitly.

## Anti-patterns

- **Prefer files for anything substantial** — inline `<artifact>…HTML body…</artifact>` is supported (up to the 512 KB limit below), but reserve it for small self-contained widgets; anything iterated, large, or that references other files should use `src="./file.html"`.
- **Don't fetch from non-CORS endpoints.** If you don't control the server, the iframe can't reach it. Pre-compute and embed.
- **Don't omit `<!doctype html>`** in your HTML file. Quirks mode breaks CSS.
- **Don't open multiple `<artifact>`s back-to-back** for content that could be one. One artifact = one file = one tag.
- **Don't inline large content.** An inline `<artifact>…body…</artifact>` over **512 KB** is rejected (error card + daemon strips it). Write it to a file and use `src=`.
- **Don't paste base64 blobs into a message.** A giant `data:…;base64,…` blob in chat text bloats history and gets stripped — write the asset to a file and reference it.
- **Don't write huge files** (>5 MB total inlined assets in a `src=` file). The block won't render past the cap.
- **Don't put visualization assets in git** when they're disposable — use `.svamp/<sessionId>/outputs/`.

## Checklist

Before emitting `<artifact src="..." />`:
1. Did you actually write the file? (Use the Write tool.)
2. Is the path relative to the session cwd, OR an absolute `https://` URL?
3. For fullscreen-only content, set `mode="card"` with a `poster`.
4. For URL embeds or canvas-heavy content, set `height="..."`.
5. If iterating, reuse the same `src` so older blocks collapse.
