# Stateful Artifacts

> Build artifacts where the page itself is the record - trackers, checklists, sign-off boards, approval queues, polls, and collectors that remember what viewers do and share it across everyone who opens the page. Covers live-document sync regions, the classic publish model, conflict handling, read-only viewers, and local-only scratch UI. Trigger on "tracker", "checklist", "the page should remember", "shared state", "sign-off", "collect responses", "collaborative page", "save state in the artifact".

- Skill: `lukehle/stateful-artifacts` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukehle/stateful-artifacts`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/stateful-artifacts/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/stateful-artifacts

---


# Stateful artifacts

Some pages are not reports — they are **records**. A close checklist that several people tick
through, a sign-off board, an approval queue, an input collector. The page holds the state and
everyone who opens it sees the same thing.

For shared persistence, declare `capabilities: { artifact: {} }`. Load the host's
`artifact-capabilities` skill when available for the authoritative contract. If it is unavailable,
build the local-only scratch mode below and label it clearly; do not claim shared persistence.

---

## Two models — pick deliberately

### Live document (preferred for trackers and checklists)

Mark the region people edit. **The markup inside that region is the shared document.** Whatever a
click or keystroke does to that DOM in your handlers is saved and reaches every view — and Claude.

```html
<artifact-sync>
  <ul id="close-tasks">
    <li data-id="C02"><input type="checkbox"> Bank rec — operating</li>
    <li data-id="C03"><input type="checkbox"> AR subledger to GL</li>
  </ul>
</artifact-sync>
```

Or put the `artifact-sync` attribute directly on a `<tbody>` or `<ul>`.

**The one rule that decides whether this works:**

> Write the synced content as HTML in the page and **mutate that DOM directly**. Never render it from
> a JavaScript object.

A region re-rendered from JS state is not saved — by design. This is the mistake that costs an
afternoon: everything looks correct locally, nothing persists.

```js
// RIGHT - mutate the live DOM; this is what gets saved
row.querySelector('.status').textContent = 'done';
row.dataset.completedBy = who;

// WRONG - state lives in JS, the DOM is a projection, nothing persists
state.tasks[i].status = 'done';
renderTasks(state);
```

Keep scratch UI out of the record with `<artifact-local>` and `data-local-*` attributes — filter
chips, sort order, a collapsed panel. Those are per-viewer, not part of the document.

### Classic publish (for structured state)

```js
const artifact = await claude.use('artifact');
await artifact.publish(renderPage(state));
```

Here the source of truth is your state object and you re-render the whole page from it. `conflict` is
routine — two people saving at once — so **keep the page a pure function of state**, never of the
live DOM. On conflict: re-read, merge, re-render, publish again.

Use this when state is genuinely structured (ordered queues, computed rollups) rather than
document-shaped.

---

## Permissions

Owner and editors write as themselves. **Read-only viewers reject with `not_granted` / `not_writer`.**

Detect it and render a read-only view — do not show controls that will fail:

```js
const artifact = await claude.use('artifact');
if (!artifact) { renderReadOnly('This view cannot save changes.'); return; }
```

Design the read-only state properly. A checklist that a viewer cannot tick should render as a
status display, not as disabled checkboxes with no explanation.

---

## What state belongs in a page

| Good | Bad |
|---|---|
| Close task checklist and owners | Anything that must survive an audit — that belongs in a system of record |
| Sign-off / approval status | Financial data of record |
| Review comments and open questions | Personally identifying or compensation data |
| Prioritization and voting | High-frequency state (a page is not a database) |
| Assumption inputs for a scenario | Anything requiring row-level permissions — the page has one permission model |

**The page is a coordination surface, not a system of record.** If losing it would be a control
failure, it belongs somewhere else and the page should link to it.

---

## Concurrency

There is no transaction. Two people editing the same row will produce a last-write-wins outcome.

- **Scope edits to the narrowest element.** Per-row or per-cell mutations collide far less than
  whole-list rewrites.
- **Stamp who and when** on each change, visibly. Attribution turns a silent overwrite into a
  visible one.
- **Never rewrite the entire synced region** in response to a single edit — that turns one person's
  click into a conflict with everyone else's work.
- For anything where a collision would be costly, give each actor their own zone (their own column,
  their own row) rather than sharing one.

---

## A worked shape: close sign-off board

```html
<artifact-sync>
  <tbody id="tasks">
    <tr data-id="C03">
      <td>AR subledger to GL</td>
      <td class="owner">—</td>
      <td class="status">open</td>
      <td class="when">—</td>
    </tr>
  </tbody>
</artifact-sync>

<artifact-local>
  <label><input type="checkbox" data-local-hide-done> Hide completed</label>
</artifact-local>

<script>
  document.getElementById('tasks').addEventListener('click', (e) => {
    const row = e.target.closest('tr[data-id]');
    if (!row || !e.target.closest('.status')) return;
    // Mutate the DOM inside the synced region - this is the save.
    row.querySelector('.status').textContent = 'done';
    row.querySelector('.when').textContent = new Date().toISOString().slice(0, 16);
  });
</script>
```

Note what is *not* here: no `state` object, no re-render. The DOM is the document.

---

## Related skills

- Host `artifact-capabilities` — optional shared-state contract; local-only fallback when absent
- `artifact-architecture` — the tier decision
- `live-data-artifacts` — reading external data; combines with this
- `app-interaction-patterns` — read-only, empty, and conflict states
- `artifact-accessibility` — interactive controls need keyboard and focus handling

