Cowork Artifact Pattern
How to build a Cowork artifact that re-fetches your data, fails gracefully, and earns its slot in the artifact list.
← Back to Reference HubBest for: Use an artifact when the data changes over time AND the reader will want to see it again. Don't reach for one for a one-off answer.
- Persists between Cowork sessions and survives app restart
- Opens in the artifact sidebar; re-runs its JS each time it opens
- Sandboxed: limited network reach, no arbitrary npm packages
- Best use case: "a Markdown table I want to keep coming back to" — pipeline, install audit, daily digest
Limitations: Not for one-off explanations (chat is faster). Not for static visuals (chat handles images too). The discipline is: artifacts are for recurring views of changing data — everything else is chat.
Best for: callMcpTool for live data from any MCP you declared; askClaude for summaries or classifications you don't want to hard-code.
- callMcpTool returns whatever the listed MCP returns — probe first, see #4
- askClaude is for summaries, classifications, or "turn this blob into one sentence"
- Both are async; always await inside try/catch
- Declare every MCP you intend to reach in `mcp_tools` at create_artifact time
Limitations: askClaude is metered — don't call it in a tight loop. callMcpTool can only reach tools you listed at create_artifact time; the sandbox enforces the declaration.
Best for: Chart.js for visualizations. Grid.js for >20-row sortable tables. Mermaid for diagrams. Vanilla JS for everything else.
- Chart.js v4 UMD — global `Chart`
- Grid.js v5 — global `gridjs`, requires its stylesheet
- Mermaid v11 — global `mermaid`, use `<pre class="mermaid">`
- For small datasets (<20 rows) a hand-rolled table is shorter than Grid.js
Limitations: No React, Vue, Tailwind, or D3. If you need behavior beyond the three allowlist libraries, write it in vanilla JS — every successful Cowork artifact I've seen does.
Best for: A 30-second probe per MCP saves hours of debugging the render loop against an assumed response shape.
- Call each MCP once in chat. Read the exact JSON you get back. Write it down.
- Some tools wrap (`{ tasks: [...] }`), some return raw arrays, some return objects — code against what you saw
- Widget-rendering MCPs (list_skills, list_plugins, list_connectors) return empty from sandboxes — accept it and move on
- For data-returning MCPs: `list_scheduled_tasks` and `list_artifacts` are the dependable ones
Limitations: If you skip the probe, you'll spend more time debugging the artifact than building it. There is no second-cheapest version of this discipline.
Best for: Inline CSS in <style>, system-stack fonts, sidebar-narrow widths (~480–700px). Resist dark mode; the sandbox renders only light.
- `:root { color-scheme: light }` at the top of your <style> block
- Off-white background (`#fafafa`); cards on `#ffffff` with subtle border (`#e5e7eb`)
- System font stack: `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`
- Design for ~480–700px width; assume narrow sidebar, not full window
Limitations: Don't try to be clever about dark mode — the sandbox renders only light, and dark-mode media queries silently break the visual contract. Don't add top-level padding that fights the sidebar chrome.
Best for: Use localStorage for UI state (collapsed sections, filter values, sort order). Let the host handle refresh.
- localStorage survives reload and app restart
- Store UI state (filters, sort order, collapsed sections) — not data
- Data fetches re-run on every open; no manual refresh button needed
- The artifact view header already has a Reload button; don't build a parallel one
Limitations: Don't store sensitive data — the HTML is stored as-is and is readable. Don't store huge blobs — localStorage is per-artifact and has a size cap.
Best for: Wrap each callMcpTool in try/catch; render a `.error` div in the failing section; keep the rest of the artifact functional.
- Wrap each callMcpTool in try/catch — failure is the default case, not the exception
- Render a per-section `.error` div with the actual error message when a fetch fails
- Use `Promise.allSettled` when fetching in parallel — never let one rejection kill the whole render loop
- Build the summary from whatever fetches did succeed; degrade gracefully
Limitations: Don't render a single top-of-page "everything is broken" banner — per-section errors give the user actionable debug info. A banner just tells them to give up.
Probe before you build
list_skills, list_plugins, list_connectors) are designed to render in-chat widgets and return empty arrays when called from an artifact's callMcpTool. The install-audit artifact's whole design hinged on discovering this — a 30-second probe saved hours of confused debugging. Call each MCP once in chat. Read what you get. Code against that.From the worked example
The Cowork Install Audit artifact is the canonical example for every pattern on this page. Full source: /examples/cowork-install-audit.html — open it in another tab and read along.
classifyTask — decision logic for the status badge
Pure function. Takes a task, returns a label + CSS class + sort rank. The render layer doesn't have to know the rules; it just renders what classifyTask returns. This pattern keeps the rendering loop short and the rules unit-testable.
function classifyTask(t) {
if (!t.enabled) return { label: 'Disabled', cls: 'badge-disabled', rank: 4 };
if (t.fireAt && !t.cronExpression) {
var fireDate = new Date(t.fireAt);
if (fireDate < new Date()) {
return { label: 'Expired', cls: 'badge-expired', rank: 0 };
}
return { label: 'Pending', cls: 'badge-pending', rank: 2 };
}
if (t.lastRunAt) {
var lastRun = new Date(t.lastRunAt);
var daysSinceRun = (Date.now() - lastRun.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceRun > 14) return { label: 'Stale', cls: 'badge-stale', rank: 1 };
}
return { label: 'Healthy', cls: 'badge-healthy', rank: 3 };
}loadAll — independent try/catch per fetch, graceful summary
Two fetches, two try/catch blocks, two success flags. If one fails the other still renders, the summary is built from whatever did come back, and the artifact only shows a full-error state if both MCPs fail. This is the install-audit's defensive-failure contract.
async function loadAll() {
var tasks = [];
var artifacts = [];
var tasksOk = false;
var artifactsOk = false;
try {
var r1 = await window.cowork.callMcpTool('mcp__scheduled-tasks__list_scheduled_tasks', {});
tasks = Array.isArray(r1) ? r1 : (r1 && r1.tasks ? r1.tasks : []);
tasksOk = true;
} catch (e) {
renderError('tasks-body', 'Could not load scheduled tasks: ' + (e && e.message ? e.message : e));
document.getElementById('tasks-meta').textContent = '';
}
try {
var r2 = await window.cowork.callMcpTool('mcp__cowork__list_artifacts', {});
artifacts = Array.isArray(r2) ? r2 : (r2 && r2.artifacts ? r2.artifacts : []);
artifactsOk = true;
} catch (e) {
renderError('artifacts-body', 'Could not load artifacts: ' + (e && e.message ? e.message : e));
document.getElementById('artifacts-meta').textContent = '';
}
if (tasksOk) renderTasks(tasks);
if (artifactsOk) renderArtifacts(artifacts);
renderHealthSummary(tasksOk ? tasks : [], artifactsOk ? artifacts : []);
if (!tasksOk && !artifactsOk) {
document.getElementById('health-content').innerHTML =
'<div class="error">Neither Cowork MCP responded. The artifact will retry on next reload.</div>';
}
}Light-mode CSS contract
The minimum styling defaults that keep an artifact rendering correctly in Cowork's sidebar. Color-scheme declaration first, off-white background, system font stack, dark text on cards. No dark-mode media queries — the sandbox renders only light.
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: #fafafa;
color: #1a1a1a;
margin: 0;
padding: 24px;
line-height: 1.5;
font-size: 14px;
}
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}| Phase | What to do | What to skip | Common mistake |
|---|---|---|---|
| 1. Plan | Decide what data your artifact will show and which MCPs supply it. Sketch the UI on paper. | Don't add features you'd want "someday" — artifacts are easier to extend than to retire. | Designing the UI before checking whether the data is reachable at all. |
| 2. Probe | Call each MCP once in chat. Read the response shape. Write it down. | Don't trust the underlying API docs — the wrappers reshape. | Skipping the probe and "fixing it up" later — the fix-up usually means rewriting the whole render loop. |
| 3. Draft HTML | Light mode, inline CSS/JS, the allowed CDN tags only, self-contained. | Don't add React or Tailwind — the sandbox blocks them. | Loading dark-mode styles or external stylesheets — they silently break the render. |
| 4. Wire data | Wrap each callMcpTool in try/catch. Use Promise.allSettled if you fetch in parallel. | Don't render until at least one MCP has responded — but don't block the whole page on the slowest one either. | Letting one failed fetch throw and kill the page-render loop. |
| 5. Add UI | Build the simplest thing that surfaces the data — tables, badges, a small summary. Add complexity only when patterns repeat. | Don't add Chart.js if a number in a `<span>` would do. | Over-styling — a working artifact beats a beautiful one. |
| 6. Ship | Call create_artifact with id, html_path, description, and the explicit mcp_tools list. | Don't list MCPs you didn't actually call — the tool requires you to declare your reach. | Listing MCPs the artifact "might call later" — the sandbox enforces the declaration at runtime. |
Build for graceful failure first
The artifact's job isn't to look polished. Its job is to remain useful when one connector goes down, when a fetch times out, when the API the MCP wraps changes its response shape. Promise.allSettled around your fetches; per-section error states; never let one failure kill the whole render loop. An artifact that breaks once will be ignored forever.