Reference Guide

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 Hub

Best 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.

Anatomy

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.

Anatomy

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.

Anatomy

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.

Anatomy

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.

Anatomy

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.

Anatomy

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.

Anatomy

Probe before you build

The single most common time-sink in artifact development is writing the render loop against a response shape you assumed. MCP wrappers reshape data relative to the underlying API. Some tools (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;
}