Inline widgets (<mcwidget>)
You can embed rich HTML in assistant messages using
<mcwidget title="Title">HTML</mcwidget>. The dashboard renders each tag as
a sandboxed iframe with Tailwind CSS preloaded. Use this for styled visual
content plain markdown cannot express: charts, color-coded tables, styled
cards, visual summaries, simple interactive probes.
Tailwind is preloaded and ready without any setup — it is served from the
dashboard's own origin, not from cdn.tailwindcss.com, which the iframe CSP
does NOT allow. Other libraries (Chart.js, D3, etc.) need a
<script src="…"> tag pulling from one of the CSP-allowed CDNs listed under
Rules below.
When to use
- Use when styled HTML genuinely helps comprehension — Chart.js graphs, color-coded comparison tables, status cards, before/after previews, small interactive probes.
- Don't use for content markdown already handles well — plain prose, bullet lists, fenced code, simple tables. Widget iframes cost more to render and add more context than markdown; default to markdown.
- Save to file for anything large.
<mcwidget>bodies fit comfortably up to a few KB; beyond that, write an HTML file and return the absolute path so the dashboard shows a thumbnail / link instead.
Theme-aware styling (critical)
The widget iframe inherits the dashboard's active theme through CSS custom properties injected into each srcdoc. Use these variables instead of hardcoded colors so widgets render correctly on every theme (light, dark, and user-defined custom palettes).
Core palette (use these first):
| Variable | Role |
|---|---|
var(--bg) |
Page background |
var(--text) |
Foreground text |
var(--card) |
Card / panel background |
var(--card-fg) |
Foreground text on --card |
var(--border) |
Borders and dividers |
var(--accent) |
Primary accent / links |
var(--muted) |
Muted / secondary text |
var(--ok) |
Success state |
var(--warn) |
Warning state |
var(--danger) |
Error / destructive state |
var(--info) |
Info / neutral callout (blue) |
Extended palette (also available):
| Variable | Role |
|---|---|
var(--bg-elevated) |
Raised surface above --bg (headers, modals) |
var(--bg-hover) |
Hover state for interactive backgrounds |
var(--text-strong) |
High-contrast text emphasis |
var(--muted-strong) |
Stronger muted text (still secondary, more legible) |
var(--border-strong) |
High-contrast border for emphasis |
var(--accent-hover) |
Hover state for accent elements |
var(--accent-subtle) |
Tinted background using accent (badges, highlights) |
var(--ok-subtle) |
Tinted ok background (success banners) |
var(--warn-subtle) |
Tinted warn background (warning banners) |
var(--danger-subtle) |
Tinted danger background (error banners) |
With Tailwind, use arbitrary values: bg-[var(--card)],
text-[var(--card-fg)], border-[var(--border)]. Avoid bg-gray-900,
text-white, bg-white, etc — they clash the moment the user switches
themes.
The theme contract
These rules apply to every artifact-bound HTML document, not just
inline widgets — a page generated by a script and pushed via
artifact_save / artifact_update or the kirocrew artifact CLI
renders in the same themed iframe:
- Never set one half of a foreground/background pair. The iframe
injects
body{background:var(--bg);color:var(--text)}defaults, so a lone hardcodedcolor:#111lands on a dark canvas in dark mode (and a lone lightbackgroundswallows themed light text). Set both halves, or neither. - Hardcode nothing; fall back instead. Content that must also render
standalone (outside the dashboard, where no theme vars exist) keeps a
literal fallback inside the var:
color:var(--text,#111); background:var(--bg,#fff). Inside the dashboard the theme wins; standalone, the fallback reproduces the intended palette. - The renderer has a last-resort fallback, not a licence. When a widget
carries a hardcoded light background (
bg-white,bg-<hue>-50|100|200, a light#hex) and references novar(--…)at all, a dark dashboard renders it on a neutral light canvas with dark text instead of white-on-white. That is a readability floor for content that already slipped through: it cannot honor the user's palette, it turns off the moment a single theme var appears, and it does nothing for a dark hardcoded palette. Write to the contract above; never design for the fallback. artifact_save/artifact_updateattach a⚠️hint when widget/html content carries literal colors (#hex/rgb()/hsl()) and novar(--…)reference. Treat that hint as a defect to fix in the same turn — unless the user explicitly asked for a fixed palette (brand colors, a print-faithful mock): the heuristic cannot distinguish deliberate branding, so honor the user's choice and say the hint was intentionally not applied.
Format
<mcwidget title="Deploy status">
<div class="p-4 rounded-lg" style="background:var(--card);color:var(--text);border:1px solid var(--border)">
<div class="text-sm font-semibold" style="color:var(--ok)">✅ Green across all stages</div>
<div class="text-xs" style="color:var(--muted)">last build 08:42 UTC</div>
<div class="text-xs">
<a href="https://github.com/example-org/example-repo/pull/1234"
target="_blank" rel="noopener noreferrer"
style="color:var(--accent)">#1234</a>
</div>
</div>
</mcwidget>
The opening tag takes two attributes: title= and slug=. A slug= binds the
impression to an artifact that already exists and suppresses registration; omit
it for new content.
Every widget is already an artifact
Each finalized <mcwidget> without slug= is auto-registered on the backend,
keyed idempotently by message timestamp and widget index, even if never viewed.
Incognito and temporary sessions never register widgets. Unpinned auto-registered
widgets are pruned oldest-first past 200; the user's star keeps one out of the sweep.
Do not call artifact_save on emitted content: it creates a duplicate and
returns a warning. slug= only binds an impression; it skips registration and
neither reads nor writes the artifact. To revise one, call artifact_get, then
artifact_update to persist a new version, and re-emit with
<mcwidget title="…" slug="<known-slug>">.
Rules:
- One
<mcwidget>per visual payload — don't nest widgets. - Keep the body self-contained: Tailwind classes, inline
style=, or a short<style>block are all fine. Inline<script>is permitted for small visualization code; third-party<script src="…">must target one of the CSP-allowed CDNs (cdn.jsdelivr.net,cdnjs.cloudflare.com). - For Chart.js, pull the library from jsdelivr and instantiate against a
<canvas>inside the widget body, e.g.:<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <canvas id="c"></canvas> <script>new Chart(document.getElementById('c'), { /* config */ })</script> - Give a responsive canvas a definite height, or leave the aspect ratio
alone. The Chart.js defaults (
responsive: true,maintainAspectRatio: true) are safe exactly as written above. Only setmaintainAspectRatio: falsewhen the canvas's parent has an explicit pixel height:
Without one, the canvas and its parent size each other and the self-sizing iframe re-feeds the loop, so the chart never settles and the widget renders blank. Any library that sizes a canvas from its container — Chart.js, ECharts, Plotly's responsive mode — carries the same hazard; inline SVG with a fixed<div style="height:180px"><canvas id="c"></canvas></div>viewBoxhas no feedback path at all. - The iframe CSP is
default-src 'none', which forbids more than script sources: there are no network calls (connect-src 'none', sofetch, XHR and websockets fail), no remote images or web fonts (img-src data: blob:,font-src data:— use inline SVG or a data URI), no form submission (form-action 'none'— use thedata-actionevent path below), and noeval('unsafe-eval'is not granted, so a library that compiles at runtime is dead on arrival). Pass every value the widget needs in its HTML; a widget cannot fetch its own data. A silently blank widget is usually one of these. - The dashboard sanitizes CSS via
src/lib/cssSanitize.ts(shared withWidgetFrame.tsx) — a small allowlist of properties plus a denylist of dangerous functions (expression(),javascript:,url(with external schemes). Write clean CSS and you'll be fine.
Links
Always open links in a new tab. Every <a> inside an <mcwidget> MUST
carry target="_blank" AND rel="noopener noreferrer". The widget iframe
is sandboxed; without target="_blank", navigation either fails silently
or replaces the iframe content with the link target — both broken UX. The
rel attribute is non-negotiable for security: it blocks reverse-tabnabbing
and prevents the destination from accessing window.opener.
Style links with style="color:var(--accent)" (or Tailwind
text-[var(--accent)]) for theme-correct contrast. Add hover:underline
or underline based on density — long copy benefits from underline,
chips / badges look cleaner without.
<a href="https://github.com/example-org/example-repo/pull/1234"
target="_blank" rel="noopener noreferrer"
style="color:var(--accent)">#1234</a>
Render identifiers and bare URLs as links to their known canonical https://
targets, keeping the identifier as the label. If a canonical URL is unknown, leave
the identifier as plain text rather than guessing.
For chat messages, paste the full URL the user shares; never reconstruct.
When the visible label can be made shorter than the URL (e.g. a long doc
title), use a meaningful label (<a href="…">Migration design doc</a>)
rather than dumping the raw URL.
Interactive widgets
Widgets can send events back to the agent. Add data-action and an
optional data-payload (JSON string) attribute to any clickable element:
<button data-action="approve" data-payload='{"id":"123"}'>Approve</button>
When clicked, the dashboard auto-submits a user message of the form
[UI] approve: {"id":"123"}. The agent receives it as a normal message
and can respond with text, a new widget, or both.
Form inputs with name attributes are auto-collected on click and
merged into the payload as formData. Use this for creation forms:
render pre-filled <input> / <select> elements, the user adjusts
values, clicks submit, and the agent receives every field.
Styling for interactive controls (consistent with the dashboard chrome):
- Buttons:
text-xs py-1.5 px-3.5 rounded-md+ theme-var background. - Labels:
text-[11px]+text-[var(--muted)]. - Inputs:
text-sm px-2.5 py-2 rounded-md+bg-[var(--bg)]+border-[var(--border)]. - Zero hardcoded hex colors. Use the theme variables above for every background, foreground, and border.
Density config
cfg.dashboard.widget_density (more / less, default more) controls
the wording of the short pointer that lives in the system prompt. less
biases against widgets by default; more encourages them. Users can flip
this in dashboard settings. Either way, this skill holds the full rules —
the main prompt only discovers that <mcwidget> exists.