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.
<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.
// 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)
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:
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
<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 decisionlive-data-artifacts— reading external data; combines with thisapp-interaction-patterns— read-only, empty, and conflict statesartifact-accessibility— interactive controls need keyboard and focus handling