rendering-artifacts · git:20261029.970834a · 2026-10-29 · sha256 73305464c000e1f6
rendering-artifacts git:20261029.970834aB
Immutable. This exact content never changes and is served at /api/v1/blob/73305464c000e1f6.
---
name: rendering-artifacts
description: When the user wants to SEE, render, visualize, preview, or "show me" a chart, diagram, mock-up, table, or interactive widget — render it with the show_artifact tool instead of writing files to disk. Also when you write a standalone document the user will keep or edit (a README, spec, report, letter) — put it in the panel with show_artifact(kind="markdown") and a short summary in chat, not a long markdown block in the reply.
---
# Rendering artifacts (generative UI)
The console has an **Artifact panel** powered by the `show_artifact` tool. Use it whenever the
user wants to **look at something rendered** rather than get source files.
## The presentation ladder — prefer the lowest tier that answers
protoAgent can show an answer at four tiers. **Prefer the lowest tier that answers the
request**: each rung costs more than the one below it (a component more than text, a sandboxed
frame more than a component) and a plainer answer reads faster, so climb only when the rung
below genuinely can't carry the answer. The rule of thumb is **inline for an answer, panel for
a work product.**
| Tier | Mechanism | Runs code? | Reach for it when… |
|---|---|---|---|
| **Text** | markdown (prose, code, KaTeX, GFM tables) | no | a fact, a short explanation, code, or a small static table — the answer is just *words and numbers* |
| **Data component** | `show_component` → `component-v1` | no | exact values, a record, ordered steps, a code pointer — structured data you want rendered natively, not a widget |
| **Inline artifact** | `show_artifact(…)` (the default) | yes (artifact sandbox) | an answer the user **interacts with right in the conversation** — a calculator, a what-if explainer, a chart, a small tool — that belongs beside your prose in scrollback |
| **Panel artifact** | `show_artifact(…, placement="panel")` | yes (same sandbox) | a **work product** carried across turns — a document, a deck, a PDF, a large app — that the user opens, edits and returns to |
**A diagram is never a chat-text fence.** The chat shows a ` ```mermaid ` fence in your reply as
a plain code block, not a drawing. To show a diagram, call `show_artifact(kind="mermaid", code=…)`
(inline by default). Live mermaid fences render only inside a `markdown` **artifact**.
**A standalone document goes in the panel.** When the answer *is* a document the user will keep,
edit or hand on (a README, a spec, a report, a letter), call
`show_artifact(kind="markdown", code=…)`. It opens in the panel. Reply in chat with a short
summary of what you wrote, not the whole document as a markdown block. Short answers, snippets
and explanations stay text.
`placement` is a real argument on the tool (`show_artifact(kind, code, title="", links=None,
placement=…)`). It **defaults to `"inline"`**, so a plain `show_artifact(kind, code)` renders the
answer inline for the frame-renderable kinds (html, svg, mermaid, react, vega-lite); pass
`placement="panel"` to put it in the side panel instead. An explicit `placement="inline"` on a
kind that can't render inline (markdown, a deck, a PDF, a file) falls back to the panel with a
note; under the default that same fallback is silent. (`show_service`, the async variant, takes
the same `placement`.)
An inline artifact **is** an artifact: same store, versions, render verdict,
`update_artifact`/`rewrite_artifact`, and an **Open in panel** button — only *where* it renders
differs. Inline lives in the transcript next to your message; panel opens the side panel.
`placement="inline"` is allowed for `html`, `svg`, `mermaid`, `react` and `vega-lite`; any other
kind renders in the panel. Default to **inline** for a self-contained answer and reserve the
**panel** for something the user will keep working on.
Inline placement is part of protoAgent's **interactive-answers** capability, and the **panel is
its universal fallback**: where a console or a `show_artifact` build doesn't offer inline
placement, the same call still renders the artifact in the panel — the answer is never lost. So
choose the **tier** by what the answer *is* (a calculator, a chart, a small tool), let it render
**inline** (the default) beside your prose — or pass `placement="panel"` for a work product — and
describe the answer by **what it does**, not by where it landed — then you're right whether it
renders inline or in the panel.
## When to use `show_artifact` (NOT the filesystem)
- "show me…", "render…", "visualize…", "draw…", "make a chart/diagram/flowchart of…",
"build a little widget/demo to see…", "preview…"
- → call `show_artifact(kind, code)`; it renders sandboxed and the user sees it immediately.
- "write a README / spec / report / letter / doc for…" → `show_artifact(kind="markdown", code)`
in the panel, with a short summary in chat.
Reach for `show_artifact` **before** writing files. Writing `.jsx`/`.html` to the workspace gives
the user files to wire up themselves — not what they asked for when they want to *see* it.
## Style with the design system: required for `html` and `react`
An artifact sits inside the console, in **the operator's theme, which may be dark or light**. It must
look like part of the app, not like a stock web page. The frame already has the design system's
`.pl-*` classes and every `--pl-*` theme token, so build on them:
- **Colours come only from tokens.** Use `var(--pl-color-fg)`, `var(--pl-color-fg-muted)`,
`var(--pl-color-bg-raised)` (cards and surfaces), `var(--pl-color-bg-inset)` (inputs and wells),
`var(--pl-color-border)`, `var(--pl-color-accent)` (the one emphasis colour) and the
`--pl-color-status-*` colours.
**Never hardcode a colour** (`#fff`, `white`, `#333`, `rgb(…)`, a blue button). A hardcoded
light card or dark-grey text breaks on the other theme.
- **Leave the page background and base text colour alone.** They're themed already.
- **Use the kit's classes for controls and layout:**
- `pl-card` for a surface;
- `pl-field` with a `<label>` and a `pl-input` / `pl-select` / `pl-textarea` for inputs;
- `pl-btn`, plus `pl-btn--primary` for the main action;
- `pl-segmented` for a few mutually exclusive choices;
- `pl-stats` / `pl-stat` for computed results;
- `pl-table`, `pl-heading`, `pl-eyebrow`, and `pl-badge` / `pl-callout` for status.
Plain `<input>`/`<button>`/`<select>`/`<table>` are themed by default too, so if you're unsure,
write the plain element and don't style its colours.
- **Spacing, shape and type:** use `var(--pl-space-2…6)`, `var(--pl-radius)` and
`var(--pl-font-sans)` / `var(--pl-font-mono)`. Keep it compact; the answer sits in a chat column.
No hero headers, gradients, drop shadows or emoji decoration.
A themed calculator skeleton:
```html
<style>
.calc { display: grid; gap: var(--pl-space-3); max-width: 420px; }
.out { color: var(--pl-color-fg-muted); }
</style>
<div class="calc pl-card">
<div class="pl-field"><label for="t">Bill total</label><input id="t" class="pl-input" type="number" value="84.50"></div>
<div class="pl-stats"><div class="pl-stat"><span class="out">Each pays</span> <strong id="each">—</strong></div></div>
<button class="pl-btn pl-btn--primary" id="go">Recalculate</button>
</div>
```
## Kinds
- `mermaid` — flowcharts, sequence/ER/gantt diagrams. `code` is the Mermaid definition. The
panel lets the user zoom and pan it. A diagram **of the code** can link its nodes and messages
to the exact lines (`links=`) — see the `diagramming-code` skill.
- `markdown` — a Markdown document (notes, a README, a spec, a report, a letter). Rendered with design-system
prose styling; GitHub-style tables/lists/code work, and a ` ```mermaid ` fence becomes a live
diagram. Reach for this over `html` when you just want **formatted text**.
- `html` — a full or partial HTML document (with inline `<style>`/`<script>` as needed).
- `svg` — inline SVG markup (icons, simple charts).
- `vega-lite` — a **chart from data**: a JSON Vega-Lite spec with the rows inline in
`data.values` (never `data.url` — it isn't loaded). The fastest way to chart: write the encoding,
not a component. It's themed to the console automatically, so leave colours/background out, and
it fills the panel width. Reach for this over `react` + chart.js whenever the chart is a
standard mark (bar/line/area/point/arc/rect heat-map) over a table of rows. With the data
plugin on, `data_chart(sql, spec, title)` runs the query and inlines the rows for you.
**Leave `$schema` out.** The renderer is always the bundled Vega-Lite v6, so a `$schema` line
adds nothing (a v5 one only logs a version warning).
**Keep ordered categories in order.** A `nominal`/`ordinal` axis sorts alphabetically by
default, so months come out Apr, Feb, Jan… — for months, weekdays, stages or any sequence
set `"sort": null` (data order) or an explicit array on that encoding.
**Every standard chart is a `vega-lite` artifact — no exceptions for "add it to the tool".**
A bar, line, area, point, arc (pie/donut) or heat-map chart is ALWAYS its own `vega-lite`
artifact, **even when the user asks you to "add a chart" to an existing calculator or tool**.
Show it as a separate inline `vega-lite` artifact right next to that tool, with the tool's
values in `data.values`. **Never hand-build bars or lines out of `<div>`s, CSS widths or SVG
rects/paths** inside an `html`/`react`/`svg` artifact. Only an animated or highly custom
visual (one Vega-Lite genuinely can't express) justifies a hand-built one.
- `react` — a self-contained component script that renders into `#root`; React, ReactDOM, and
Babel are provided. Easiest: **name your top-level component `App`** and it **auto-mounts** — you
don't have to write the mount call. (You still can: `ReactDOM.createRoot(...).render(...)`; an
explicit render always wins.) React artifacts can also `import` from a curated **offline** set
(see below).
## Richer React: charts, icons, and design-system components
`react` artifacts may `import` (ES modules) from this curated, fully-offline set — no network:
- **`@pl/ui`** — protoLabs design-system component wrappers that match the console theme.
Enough to prototype real **layouts** and **components**, not just widgets:
- *Layout / structure* — `AppShell` (`{header, sidebar, aside}`) · `Header` (`{name, org, actions}`)
· `SideNav` + `SideNavItem` (`{icon, active}`) · `Container` · `Section` · `Panel` · `Grid`
(`{cols, gap, auto}`) · `Row` (`{label, desc, status}`) · `Hero` (`{cta}`) · `Divider`
- *Navigation* — `Tabs` + `Tab` (`{active, icon}`) · `Segmented` + `SegmentedButton` ·
`Menu` + `MenuItem` (`{icon, destructive}`) + `MenuSeparator`
- *Data / content* — `Table` · `Board` · `Stats` + `Stat` (`{value, label}`) · `Steps` + `Step`
(`{num, title}`) · `Progress` (`{value, max, variant}`) · `Accordion` + `AccordionItem`
(`{title, open}`) · `Avatar` (`{src}`) · `Empty` (`{icon, title, desc, action}`)
- *Overlays* — `Dialog` (`{title, onClose, footer}`) · `Drawer` (`{side, title, footer}`) ·
`Callout` (`{variant, title}`) · `Tip`
- *Forms* — `Field` (`{label, hint}`) · `Input` · `Textarea` · `Select` · `Switch` (`{label}`) ·
`Checkbox` (`{label}`)
- *Primitives / type* — `Button` (`{variant, size}`) · `IconButton` · `Card` · `Badge` · `Tag` ·
`Kbd` · `Link` · `Dot` (`{variant, pulse}`) · `Spinner` · `Skeleton` · `Alert` · `Heading`
(`{as}`) · `Eyebrow` · `Lead` · `Prose` · `Icon` (a [lucide](https://lucide.dev) icon by
`name`, e.g. `<Icon name="rocket" />`)
Prefer these over hand-rolled markup so a prototype matches the live theme; drop to a raw
element with `className="pl-…"` for anything not wrapped here.
- **`chart.js`** — `import { Chart } from 'chart.js'` (controllers pre-registered) for quick
bar/line/pie/etc. charts onto a `<canvas>`.
- **`d3`** — `import * as d3 from 'd3'` for bespoke/data-driven SVG visualisations, e.g.
`const x = d3.scaleLinear().domain([0, 10]).range([0, 320])`.
- **`three`** — `import * as THREE from 'three'` for 3D scenes (three.js r170) on a `<canvas>`,
e.g. `new THREE.WebGLRenderer({ canvas: ref.current }).render(scene, camera)`.
- **`animejs`** — `import { animate } from 'animejs'` for animation (anime.js v4 API), e.g.
`animate(el, { translateX: 100, duration: 400 })`.
- **`lucide`** — the raw icon library, if you're not using `@pl/ui`'s `Icon`.
- **`react`** / **`react-dom/client`** — also importable (`import { createRoot } from
'react-dom/client'`); they resolve to the same React the globals use.
```jsx
import { createRoot } from 'react-dom/client';
import { Card, Stat, Button, Icon } from '@pl/ui';
import { Chart } from 'chart.js';
function App() {
const ref = React.useRef(null);
React.useEffect(() => {
new Chart(ref.current, { type: 'bar',
data: { labels: ['A','B','C'], datasets: [{ label: 'n', data: [3,7,5] }] } });
}, []);
return (
<Card>
<Stat value="7" label="peak" /> <Icon name="trending-up" />
<canvas ref={ref} width={320} height={160} />
<Button variant="primary">OK</Button>
</Card>
);
}
createRoot(document.getElementById('root')).render(<App />);
```
The design system's `.pl-*` classes (e.g. `className="pl-btn pl-btn--primary"`) and `--pl-*`
tokens are available in **every** `html`/`react`/`markdown` artifact, and using them is required (see
"Style with the design system" above). Only these libraries are
available; for anything else, write the code inline (the sandbox has no other network access).
**`html` artifacts get the same import map.** Inside a `<script type="module">`,
`import * as THREE from "three"`, `import * as d3 from "d3"` and
`import { animate } from "animejs"` (and `chart.js` / `lucide`) resolve to the same offline modules — no CDN URL needed. If your `html` artifact ships its own
`<script type="importmap">`, it's merged with the shell's into one map, and your entries win.
## Editing an artifact (don't re-create it)
When the user asks to change something you already rendered, **iterate the same artifact** — don't
call `show_artifact` again (that makes a near-duplicate and clutters the panel). Use:
- **`update_artifact(old_string, new_string)`** — a targeted edit. `old_string` must appear in the
current source **exactly once** (copy it verbatim, whitespace included; add surrounding context
to make it unique). This is the fast path — prefer it for small changes. Creates a new version.
- **`rewrite_artifact(code, title?)`** — replace the whole source. Use for large changes where a
targeted edit would be awkward. Creates a new version; the kind is kept.
Each edit is a **version** the user can step back through in the panel, so iterate freely — you're
never destroying the previous version. Both default to the most-recent artifact; pass
`artifact_id` to target another.
## Did it render? ALWAYS verify (closing the loop)
A React artifact can throw at render time (a bad import, an undefined component, an export
mismatch) even when the code looks right. **Verifying the render is a standard step, not an
optional one** — confirm it worked before you tell the user it's done:
- When the panel is open, `show_artifact` / `update_artifact` / `rewrite_artifact` wait briefly and
**append the render verdict to their reply** — e.g. *"⚠ But it FAILED to render: Icon is not
defined"* or *"It rendered cleanly."* Read it.
- An **inline** artifact's chat frame mounts only after the tool replies, so its create/edit reply
can't carry a verdict. It says so, and names the `check_artifact(<id>)` call to make. **Make that
call before you tell the user it works.** `check_artifact` waits for the inline frame to report.
- If the reply didn't carry a clean verdict (it came back before the render finished, or said "no
result yet"), **call `check_artifact` to confirm** — it waits briefly for the verdict. Make this
your default after creating or editing an artifact.
- On a failure, **fix it** with `update_artifact` / `rewrite_artifact` — the artifact still exists,
so iterate on it (don't start over), then verify again. Loop until it renders cleanly.
Treat a render error as the signal to iterate — that's the code→render→fix loop. Don't apologise and
guess; read the error and make the targeted edit.
## Managing artifacts
- **`list_artifacts()`** — see the ids/kinds/titles/version counts and which are pinned (to target
an edit, pin or delete).
- **`check_artifact(artifact_id?)`** — the latest render verdict (see above).
- **`pin_artifact(artifact_id, pinned=True)`** — keep a **long-lived** artifact (a master resume, a
reference doc, a plan you'll revisit) from being evicted. Only the most recent ~200 unpinned
artifacts are kept, counting everything rendered on this agent; an unpinned one survives while a
chat that shows it still exists, but once that chat is deleted (or it was never shown in a chat)
it disappears after enough unrelated renders — and any id you wrote down then points at nothing.
Pin it as soon as you know it will outlive the conversation. Pins are capped: if it refuses, unpin
one you no longer need (`pinned=False`). A cap of 0 means the operator turned new pins off —
artifacts already pinned stay protected until unpinned. A pin keeps the latest versions, not
every edit.
- **`delete_artifact(artifact_id)`** — remove one for cleanup. (The user can also delete from the
panel's trash button.)
## Saving a generated FILE (docx / xlsx / pptx / pdf / image)
When a skill writes a real **file** to disk — a Word doc, a spreadsheet, a slide deck, a PDF, an
image — call **`save_file_artifact(path, title?, artifact_id?)`** right after, to put it in the Artifact panel as
a **versioned download artifact** with a Download button and a preview by type:
- `.docx` and `.pdf` → their **real pages** (scroll, zoom with −/Fit/+); `.pptx` → its **real
slides** (current slide + filmstrip, arrow keys to page). Each keeps its extracted text underneath.
- `.csv`/`.tsv` → a table (any common delimiter; numbers right-aligned); `.xlsx` → one table per sheet.
- `.md` → rendered prose; `.json` → pretty-printed; images → a thumbnail; other text → plain text.
`path` can be relative to a filesystem project (as `find_files`/`read_file` report it — pass
`project` if more than one has it) or absolute. An absolute path with `project` must be inside that
project's root; for a file anywhere else, leave `project` empty. Re-saving the same document as a new revision? Pass the prior
`artifact_id` so it becomes v2, v3… of the same artifact instead of a new panel entry.
```text
# after cowork's docx skill writes /work/report.docx
save_file_artifact("/work/report.docx", "Q3 Report")
```
**Linking instead of copying (`link=True`).** For reference material you did *not* produce — a
long source PDF, a dataset, a spec — `save_file_artifact(path, link=True)` points the panel at the
file in place without storing a copy. The file must live inside a registered filesystem project (a
path outside every project, or a symlink escaping one, is refused). A linked artifact is
**read-only**: no copy, no version history — don't pass `artifact_id` with `link=True`; save without
`link` for an editable, versioned copy. It has its own, larger size cap (`max_linked_kb`, 200 MB
default). Downloads serve the file in place, re-checked against the project fence on every request:
403 once it's no longer reachable, 409 if it changed or disappeared since it was linked.
This is for files that already exist on disk. To *render* HTML/React/SVG/Markdown/charts, use
`show_artifact` (above) — don't write those to a file first.
**Showing a document that's already on disk** (a PDF a tool rendered, a deck, a report): ONE
`save_file_artifact` call on that file. Never `read_file` it and paste its contents into
`show_artifact` — that re-types the whole document through the model (a minute or more for a
few pages of HTML) and the copy drifts from the file.
## Interactive artifacts (calling back to you)
`html` and `react` artifacts can call **`window.protoArtifact.ask(prompt)`** — it returns a
Promise resolving to *your* answer — so an artifact can be a live mini-app (a game NPC, a tutor,
a generator). Use it when the user asks for something that needs intelligence *inside* the widget:
```js
const line = await window.protoArtifact.ask("Greet the player as a grumpy dwarf, one line.");
```
It only works if the operator set `ARTIFACT_ASK_ENABLED` — if it's off, `ask()` rejects with a
message telling them how to enable it, so write artifacts that degrade gracefully.
`html` and `react` artifacts can also use **`window.protoArtifact.send(text)`** — it puts `text`
into the chat **as a user message** and starts a normal, visible turn. Unlike `ask`, `send` is
**on by default** where the console provides it: it costs exactly what typing costs and the
operator sees exactly what was sent. Use it to let the user act on a result, subject to three
rules:
- **Feature-detect it first.** `send` is part of the interactive-answers bridge and isn't on
every console, so call it **only when it's actually there** —
`typeof window.protoArtifact?.send === "function"` — and keep a plain fallback (show the
result as text the user can read or copy) for when it isn't. An artifact that calls `send`
unconditionally throws where the bridge is absent.
- **Only from a labelled button** — never on load, a timer, or an input change. Each `send` is
a *proposal*: the console shows the user a "Send to chat" confirm under the artifact, and the
message goes out only if they approve it (or earlier chose "Always allow" for this artifact).
If they dismiss it, the Promise rejects — handle that quietly.
- **Put the selected values into the text,** because the agent sees only that text — the
artifact's control state is never synced to the agent.
```js
// a "Ask about this scenario" button, after the user picks values
btn.addEventListener("click", async () => {
const msg = `Split a $${bill} bill ${people} ways with a ${tip}% tip.`;
if (typeof window.protoArtifact?.send !== "function") return showResult(msg); // bridge off: keep it readable
try {
await window.protoArtifact.send(msg);
status.textContent = "Sent"; // only once it actually went out
} catch (err) {
status.textContent = `Not sent: ${err.message}`; // busy / dismissed / rate-limited: a quiet note, never an error
}
});
```
**Always handle the Promise** `send()` returns, as above: say "Sent" only after it resolves,
and on a refusal show a quiet inline status ("Not sent: the agent is busy") — a refusal is a
normal outcome, not an error, so never surface it as one.
To send the user to a web page, use **`window.protoArtifact.openLink(url)`** (feature-detect it the
same way) — a plain `<a target="_blank">` can't leave the sandbox. It is a *proposal* too: the
console asks "Open ‹host› in a new tab?" under the artifact, and the tab opens only if the user
clicks Open (or earlier chose "Always allow links" for this artifact — a separate choice from
sends). It accepts `https:` links only, and the operator may narrow that to a list of origins.
Call it only from a labelled button. The Promise resolves once the tab **actually opened** and
rejects if the user dismissed it, the browser blocked the pop-up, or the link isn't allowed —
show a quiet "Not opened: …" note, plus the URL as text they can copy.
## Authoring order for `html` (so it previews while you write it)
An inline `html` artifact streams into a live preview as you write it, so author it top to
bottom in this order:
1. **`<style>` first.** Put all CSS in a leading `<style>` block. The preview stays hidden until
your first `<style>` closes, so leading with it means the answer is **never shown unstyled**.
2. **Readable markup next.** Write the body so it reads as a finished answer *before any script
runs* — real labels, headings and default values live in the HTML, not injected by JS.
3. **Scripts last.** Put `<script>` at the end. The preview strips scripts and runs them only
once the markup is whole, so nothing legible should depend on a script having executed.
The same order helps `react` and `svg`: static, styled structure first, behaviour last.
## Quality bar for interactive answers
An inline artifact is an *answer*, so hold it to the bar a good answer meets. Every item below
is non-negotiable for a calculator, explainer, chart or tool:
- **Every enabled control does real work.** No dead buttons, no inputs that change nothing. If a
control isn't wired yet, disable it or leave it out.
- **Accessible controls.** Every input has a connected `<label>`; the widget is fully operable by
**keyboard**; focus is **visibly** indicated (don't strip the focus ring without replacing it).
- **Validated numeric input.** Read with `valueAsNumber`, guard with `Number.isFinite`, clamp to
sensible **domain bounds**, and refuse **zero divisors**. **Never** render `NaN`, `Infinity`, or
a **stale** result left over from the last valid input — show a clear placeholder or an inline
message instead.
- **Units and assumptions shown.** Label every quantity with its **unit**, and state on screen any
**assumption** you baked in (tax rate, rounding, currency) — don't bury it in the math.
- **Reduced motion.** Respect `prefers-reduced-motion`, and give any animation a **pause** and a
**reset** control.
- **Labelled provenance.** Mark each value as **sample**, **user-provided**, **retrieved**, or
**calculated**, so the user can tell example data from their own input from a computed result.
- **`send` carries the values.** Call `protoArtifact.send(...)` only from a **labelled button**,
**after feature-detecting it** (`typeof window.protoArtifact?.send === "function"`, with a plain
fallback), with the **selected values** written into the text — the agent sees the text, never
the controls.
## When to still write files
Only when the user explicitly wants a **project / files** ("scaffold a repo", "write the component
to a file", "create a Vite app"). For "show me a counter widget" → `show_artifact("react", …)`.