googlicius/obsidian-steward/stateful-widget

Claude Code and agent skill by googlicius/obsidian-steward

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Build interactive HTML project widgets with persisted runtime state (games, counters, forms). Read before show_widget when user actions must survive reopening the note.

Install

npx vskill@latest install googlicius/obsidian-steward/stateful-widget

SKILL.md

Stateful Widget Skill

Use this skill when creating interactive HTML project widgets (games, counters, quizzes, forms) where user actions must persist after the user closes and reopens the conversation note.

When to use

  • User asks for a game, interactive demo, counter, quiz, or any widget that changes based on clicks/keyboard input.
  • You use show_widget with project mode (files: [{ name, content }, ...]), not a single code blob.
  • State must survive: reopening the note, scrolling away and back, or editing widget code files (hot-reload).

Skip this pattern for static animations, one-shot diagrams, or SVG-only widgets with no runtime state.

How a widget is rendered

  • Create (`show_widget`): Writes project files under {stewardFolder}/Widgets/{widgetId}/ (index.html, main.js, style.css, Widget.md, etc.). widgetId = slugified widgetName + short unique suffix. Appends a stw-widget-project fence to the conversation note:

``stw-widget-project <widgetId> ``

  • Mount (`WidgetPostProcessor`): When the note is displayed, the post-processor reads the fence, resolves {stewardFolder}/Widgets/{widgetId}, bundles the entry HTML (inlines linked CSS/JS from the project folder), injects window.stw for state and assets, and renders the bundle in a sandboxed iframe (srcdoc, allow-scripts, strict CSP, no network). Manifest assets are not inlined into HTML; the iframe requests allowed files from the host at runtime and creates local blob URLs.
  • Hot-reload: Edits to project files via edit trigger a re-bundle and iframe refresh.

Only project (HTML multi-file) widgets get the state bridge and state.json. Single-blob code widgets do not persist runtime state this way.

How state is managed

PieceRole
window.stwAPI injected into the iframe by the host (not written by you in vault files).
window.stw.getState()Returns last saved data object, or null on first load.
window.stw.setState(data)Saves a JSON-serializable snapshot; debounced ~400ms, then written to vault.
state.jsonCreated lazily in the project folder on first successful setState. Host-owned; do not author or edit it manually.

Note: You must call setState after every meaningful state change. Without it, state.json never appears.

On-disk shape (host-managed)
json
{
"version": 1,
"updatedAt": "2026-05-29T00:00:00.000Z",
"data": {}
}

Your widget only supplies the inner data object via setState. Define a schema that fully describes the UI (e.g. board cells, score, turn).

Manifest assets and window.stw.getAsset

Only vault paths listed in the manifest assets array may be loaded. The host injects an allowlist registry on window.stw.assets (vault paths, not loadable URLs). Keys:

KeyExample manifest pathUse in state
Stem (filename without extension, when unique)Images/x.png"x"
Vault path (normalized)Images/x.png"Images/x.png" — always safe; required when stems collide

window.stw.getAsset(id) is async (Promise<string | null>). The iframe asks the host for file bytes, then creates a local blob URL inside the iframe. Results are cached per id for the session.

Add a new image by editing only Widget.md manifest assets (then save). Hot-reload rebuilds the registry — no main.js map update if you already look up by stem.

Static markup: asset:… in HTML attributes (src, href, poster) and CSS url(asset:…) is auto-hydrated on load. You can still author:

html
<img src="asset:Images/board.png" alt="Board" /> <audio src="asset:Audio/track.mp3"></audio>

Dynamic UI: store a short id in state (stem or vault path) and resolve at render time:

javascript
let state = window.stw.getState() ?? { cells: Array(9).fill(null), turn: 'x' };
async function render() {
await Promise.all(
state.cells.map(async (cell, i) => {
const img = document.querySelector(`[data-cell="${i}"]`);
img.src = cell ? await window.stw.getAsset(cell) : '';
})
);
}

Declare every vault path in manifest assets. Use unique filenames when relying on stem keys; if two assets share a stem, use the full vault path in state and in getAsset.

Do not persist asset paths or blob URLs in state

state.json is not processed for assets at load time.

  • Do not put asset:… paths, vault paths, or blob URLs in setState data.
  • Do store a short id (filename stem when unique, otherwise full vault path) and resolve via await window.stw.getAsset(id).

Splitting into multiple files

When a file grows large — or you or the user want smaller parts — add extra files in the project root (no subfolders). Link every CSS/JS from index.html in dependency order (dependencies first, main.js last). The bundler inlines linked files at render.

html
<link href="base.css" />
<link href="board.css" />
<script src="state.js"></script>
<script src="render.js"></script>
<script src="main.js"></script>
  • Classic scripts only — shared globals or one window namespace; no import/export.
  • Keep main.js last: hydrate, setState, initial render, and registerAction if used.

Required JavaScript pattern (main.js)

Put hydrate + save in main.js (or inline script in index.html if you do not split files). Scripts run after window.stw is injected in the document head. If render uses assets, make it async and await getAsset.

javascript
// 1. Hydrate on load
let state = window.stw.getState() ?? {
cells: Array(9).fill(null),
turn: 'x',
};
async function render() {
// draw UI from state (await stw.getAsset when setting media src)
}
function commitState() {
window.stw.setState(state);
}
// 2. On each user action that changes app state
async function onCellClick(index) {
if (state.cells[index]) return;
state.cells[index] = state.turn;
state.turn = state.turn === 'x' ? 'o' : 'x';
await render();
commitState();
}
void render();

Rules:

  • getState() once at startup (or merge with defaults).
  • setState(state) after every change users should see after reopening.
  • Keep data small and JSON-safe (no functions, DOM nodes, or circular refs).
  • Never store asset: paths, vault paths, or blob URLs in state — persist stem ids (or full vault paths) and use await stw.getAsset (see above).
  • Use one object as source of truth; re-render from it.

Widget.md — name: manifest block (host-maintained)

show_widget creates {projectPath}/Widget.md with a single yaml fence at the top of the note body. The host writes it on create — do not remove or replace the block. You may edit it later to add or update assets and maxAssetSize (e.g. for large audio). Any other fences (actions, actors, agent) go below the manifest fence (see interactive-widget).

FieldTypeRequiredDescription
namemanifestYesBlock type literal.
entrystringYesRelative path to the HTML entry file (e.g. index.html).
typehtmlYesMust be html for project widgets.
widgetIdstringNoProject folder id; host sets on create.
widgetNamestringNoDisplay name from show_widget.
assetsarray of stringsNoAllowlist of vault paths the iframe may request. Exposed on window.stw.assets; resolve with await getAsset(stem) or await getAsset("Path/to/file"). Reference in static HTML/CSS/JS as asset:Path/to/file. Only listed paths are served.
maxAssetSizestring or numberNoPer-file byte cap when the host reads an asset. Plain numbers are bytes (e.g. 5000000). Suffixes supported: B, KB, MB, GB (spacing optional, e.g. 5MB, 5 mb, 5 MB). Host sets 5MB on create; default when omitted from manifest: 5MB. Raise this for larger assets.

Example (host-written on create):

yaml
name: manifest
entry: index.html
type: html
widgetId: My-Game-abc12
widgetName: My Game
maxAssetSize: 5MB
assets:
- Images/sprite.png

Large assets: if an asset exceeds maxAssetSize, the host refuses the request (asset_too_large, logged in the developer console). If a path is missing from the vault, you will see asset_not_found. Use edit on Widget.md to add paths to assets or increase maxAssetSize when media fails to load.

Project layout checklist

  • index.html — entry; list all <link> / <script src> in dependency order.
  • main.js — bootstrap: hydrate/save + initial render (last script).
  • Other .js / .css — optional splits when files get large (project root only).
  • Widget.md — host-created; manifest fence at top (see above). Do not edit the manifest block.
  • state.json — auto-created on first setState; do not include in show_widget files.

Notes

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
85
Repository forks
2
7-day trend score
22

Stars and forks are for the whole GitHub repository, not this skill alone. Refreshed 7 Nov 2026, yesterday.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/googlicius/obsidian-steward/stateful-widget/badge)](https://verified-skill.com/skills/googlicius/obsidian-steward/stateful-widget)

Something wrong with this skill or its page? Tell us.