Skip to main content

Custom Widget Authoring Guide

Custom widgets are the core power-user feature of monodit. You write standard HTML, CSS, and JavaScript; monodit securely runs your code inside an isolated sandbox and wires it up to a rich bridge API (ctx) for storage, network access, and inter-widget communication.

This guide covers everything you need to write, export, and share a custom widget.


Sandbox Environment

Every custom widget runs inside an <iframe sandbox="allow-scripts">. This is a strict security boundary applied to all user-provided or imported code, ensuring that third-party widgets cannot perform malicious actions like unauthorized form submissions or opening spam popups.

Unlike built-in official widgets (which the host application inherently trusts and may grant relaxed permissions like allow-forms or allow-popups), custom widgets are strictly locked down to allow-scripts only. This is not a limitation — it is the core security model of the platform.

Sensitive browser APIs such as geolocation are intentionally not exposed by relaxing iframe permissions. Instead, monodit provides host-mediated bridge APIs that are gated by manifest-declared capabilities and user approval.

What this means for your code:

CapabilityAvailable?
Standard DOM APIs (document, window)Yes
fetch() to the same originBlocked by CORS in most cases — use ctx.proxyFetch
window.parent propertiesBlocked (cross-origin; you get a security error)
window.parent.postMessage()Allowed (used internally by the bridge)
localStorage, sessionStorageBlocked — use ctx.storage
CookiesBlocked
ES modules (import)Blocked (no allow-scripts + type="module")
crypto.randomUUID()Available
Form submissionsBlocked (no allow-forms)
Direct popups (window.open)Blocked (no allow-popups) — use ctx.openUrl

The ctx bridge object is injected as window.ctx before your JS runs. All communication with the host dashboard goes through it.


The Widget Manifest Format

A widget file is a .monodit-widget.md file — a Markdown document with a YAML frontmatter block followed by three fenced code blocks (html, css, js):

---
monodit: widget
specVersion: "3"
widgetType: dynamic
capabilities: [geolocation]
name: My Widget
description: A short description shown in the Gallery
version: "1.0.0"
author: Your Name
icon: StickyNote
minW: 2
minH: 2
defaultConfig:
label: Default Label
configSchema:
- key: label
label: Widget Label
type: text
placeholder: Enter label...
storage:
bindings:
- key: primary
backend: d1-json
schemaId: notes.plaintext
schemaVersion: 1
shareable: true
llm:
tools:
- name: add_note
description: Add a new note entry
mutates: true
requiresConfirmation: true
inputSchema:
type: object
additionalProperties: false
properties:
text:
type: string
required:
- text
---

```html
<div id="root">Hello</div>
```

```css
body { font-family: sans-serif; }
```

```js
document.getElementById('root').textContent = 'Hello from JS';
```

The frontmatter holds all metadata. The three fenced code blocks hold the widget source — write them as ordinary HTML, CSS, and JavaScript. No string escaping required.

YAML quoting tip. Values starting with # (hex colors) and version numbers like 1.0.0 should be wrapped in "..." so YAML treats them as strings rather than comments or numbers.

Field Reference

All metadata lives in the YAML frontmatter:

FieldTypeRequiredDescription
monodit"widget"YesDiscriminator literal. Must be widget.
specVersion"3"YesManifest spec version. Must be "3".
namestringYesWidget display name.
versionstringYesWidget version string.
widgetType`"basic""dynamic"`No
descriptionstringNoShort description for library/gallery surfaces.
authorstringNoAuthor name or handle.
colorstringNoOptional card/accent color hint for UI surfaces.
iconstringNoIcon name (typically a lucide icon key).
minWnumberNoMinimum grid width. Integer >= 1.
minHnumberNoMinimum grid height. Integer >= 1.
capabilitiesarray<"geolocation">NoGated host capabilities. Currently supports only geolocation.
llmobjectNoOptional widget-scoped AI tool contract.
defaultConfigrecord<string, unknown>NoInitial widget config object.
configSchemaarray<unknown>NoSettings schema definition array.
storageobjectNoStorage contract. Required for dynamic widgets.
source (html/css/js blocks)html/css/js fenced blocksYesRequired source blocks (html, css, js) in markdown fences.

The three fenced code blocks (html, css, js) hold the widget source. Each block is required; any block may be empty. Order is free, but the canonical export order is htmlcssjs.

Note: _storage values (private per-widget KV data) are never included in an exported manifest. Only defaultConfig travels with the file.

minW and minH are optional host-enforced layout constraints expressed in dashboard grid units, not CSS pixels.

For import/update decisions, monodit treats a widget revision as the canonicalized manifest identified by:

  • manifestId
  • version
  • contentHash

contentHash is computed from the normalized manifest body, so formatting-only changes do not create a new revision.

Capability Declarations

Some bridge APIs are intentionally gated behind manifest-declared capabilities. A widget must opt in explicitly before the host will allow those calls.

Example:

"capabilities": ["geolocation"]

Rules:

  • Capabilities are optional. Omit the field when your widget does not need any gated host features.
  • Declaring a capability does not grant access by itself. The host may still require a user-facing approval step.
  • Currently supported capability values:
    • "geolocation" — allows the widget to call ctx.getCurrentLocation()

LLM Tool Declarations

Widgets may optionally expose semantic actions to monodit's in-app AI through llm.tools.

Example:

llm:
tools:
- name: add_todo
description: Add a new task to the list
mutates: true
requiresConfirmation: true
inputSchema:
type: object
additionalProperties: false
properties:
text:
type: string
required:
- text

Rules:

  • llm.tools is optional. Omit it when your widget should not expose AI-operable actions.
  • Each tool must declare name, description, and inputSchema.
  • mutates should be true for actions that change widget data.
  • requiresConfirmation is a hint that the AI/host flow should request user approval before execution.
  • Tool names should be semantic actions such as add_todo, complete_task, or append_note.
  • Avoid raw storage mutation primitives such as set_store_document. The tool contract should stay stable even if the widget's internal data shape changes.

The manifest declaration only describes what tools exist. The widget must still register runtime handlers with ctx.llm.registerTool(...).

Basic vs Dynamic Widgets

  • A Basic Widget sets "widgetType": "basic" and must not declare a storage contract.
  • A Dynamic Widget sets "widgetType": "dynamic" and must declare a storage contract.

Imports are validated against that rule. If widgetType and storage disagree, the import is rejected.


Storage Contracts

If your widget uses shared backend storage and you want it to participate in the host's bucket UI, declare a storage contract in the manifest and mark the widget as "widgetType": "dynamic".

Example for a shared document widget (in the YAML frontmatter):

storage:
bindings:
- key: primary
backend: d1-json
schemaId: notes.plaintext
schemaVersion: 1
shareable: true

In monodit's manifest model, that storage contract is also what makes the widget a Dynamic Widget.

Example for an asset widget:

storage:
bindings:
- key: assets
backend: r2-assets
schemaId: gallery.assets
schemaVersion: 1
configKey: assetBucketId
shareable: true

What the Host Uses This For

  • showing compatible buckets in settings
  • validating re-connection between widgets
  • rendering bucket topology
  • distinguishing document buckets from asset buckets

If you omit the contract, the widget can still call the bridge APIs manually, but the host cannot safely reason about compatibility.

See Storage & Buckets for the full model.


Configuration Schemas

By default, users edit a widget's configuration using a raw JSON editor in the settings modal. To provide a better experience, you can define a configSchema in your manifest. This automatically generates a user-friendly form (toggles, dropdowns, text fields) tailored to your widget.

The ConfigField Object

The configSchema is an array of field objects:

PropertyTypeRequiredDescription
keystringYesThe key in ctx.config where this value is stored.
labelstringYesThe label shown in the settings UI.
typestringYesOne of: text, number, boolean, select, color, object, list.
descriptionstringNoHelper text shown below the field.
placeholderstringNoPlaceholder for text/number/color inputs.
optionsarrayYes*Required for type: "select". Array of { label: string, value: string }.
fieldsarrayYes*Required for type: "object". Nested ConfigField[].
itemobjectYes*Required for type: "list". A nested schema node describing each list item.
itemLabelstringNoLabel used for each list row, such as "Feed" or "Bookmark".
addLabelstringNoCustom label for the add button.
stringFormatstringNoOptional migration helper for type: "list". Currently supports "newline-or-comma" to coerce legacy string values into a string list.

Supported Field Types

  • text: Single-line text input.
  • number: Numeric input.
  • boolean: A toggle switch.
  • select: A dropdown menu. Requires the options array.
  • color: A color picker with a text input for hex codes.
  • object: A grouped object with nested named fields.
  • list: A repeatable list of items. Each item can itself be a primitive field or an object.

Full Schema Example

configSchema:
- key: engine
label: Search Engine
type: select
options:
- { label: Google, value: "https://google.com/search?q=" }
- { label: Bing, value: "https://bing.com/search?q=" }
- key: showIcon
label: Show Engine Icon
type: boolean
description: Toggles the visibility of the provider logo.
- key: accentColor
label: Accent Color
type: color
- key: feeds
label: RSS Links
type: list
itemLabel: Feed
addLabel: Add Feed
item:
type: text
placeholder: "https://example.com/feed.xml"
- key: bookmarks
label: Bookmarks
type: list
itemLabel: Bookmark
addLabel: Add Bookmark
item:
type: object
fields:
- { key: name, label: Name, type: text, placeholder: Name }
- { key: url, label: URL, type: text, placeholder: "https://example.com" }

Nested Schema Notes

  • object.fields always uses full ConfigField entries, so nested object properties still need key and label.
  • list.item uses a schema node, not a top-level field. Primitive list items therefore do not need a key.
  • A list of object items is the recommended way to model repeatable structured settings for custom widgets.
  • stringFormat: "newline-or-comma" is intended for migrating older string-based settings to a proper list UI without breaking saved configs.

Theming and Design Tokens

monodit automatically synchronizes your widget's styles with the dashboard's theme. You should avoid hardcoding colors and fonts; instead, use the provided CSS Variables.

Automatic CSS Variable Injection

The following tokens are injected into your widget's :root as CSS variables. They update automatically when the user changes themes:

VariableDescription
var(--primary-color)The dashboard's accent/brand color.
var(--background-color)The main dashboard background color.
var(--widget-background)The background color for widget cards.
var(--widget-border-radius)The corner radius for widget cards.
var(--font-family)The dashboard's global font stack.
var(--text-color)The primary text color.

Default Body Styling

To ensure a seamless look, monodit pre-styles your widget's body with the following defaults:

body {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
color: var(--text-color, #000);
font-family: var(--font-family, sans-serif);
}

Best Practices for Styles

  1. Use Variables for UI Elements: Set button backgrounds to var(--primary-color) and text to var(--text-color).
  2. Respect Transparency: Use var(--widget-background) if you need an internal card-like background, but keep the widget body transparent.
  3. Inherit Fonts: Your widget automatically uses the dashboard font. If you need a specific weight, use font-weight.

Responsive Layouts

One of the most powerful features of the monodit sandbox is that standard responsive design tools are relative to the widget's size, not the browser window.

CSS Media Queries

When you use @media queries in your widget's CSS, they trigger based on the width and height of the widget's iframe. This allows your widget to change its layout when a user resizes its grid cell.

/* Default: Compact view for 1x1 or small cells */
.stats { font-size: 12px; display: block; }
.details { display: none; }

/* Expanded: Wide view for 2x1 or larger cells */
@media (min-width: 250px) {
.stats { font-size: 16px; display: flex; gap: 20px; }
.details { display: block; }
}

Viewport Units (vw, vh, vmin)

In a sandboxed widget, 100vw is equal to the full width of the widget, and 100vh is the full height of the widget. You can use these units to create perfectly scaling typography or layouts.

/* Font size that scales smoothly with widget width */
h1 {
font-size: clamp(14px, 8vw, 32px);
}

JavaScript Adaptation (ctx.onResize)

For complex logic, such as resizing a Canvas or filtering data based on available space, use the ctx.onResize callback.

ctx.onResize((width, height) => {
if (width < 150) {
document.body.classList.add('mini-mode');
} else {
document.body.classList.remove('mini-mode');
}
});

Bridge API (ctx) Summary

window.ctx is available the moment your JS starts executing.

This guide only summarizes the APIs most relevant to widget authors. For the full reference, see Bridge API Reference.

ctx.instanceId

console.log(ctx.instanceId); // "550e8400-e29b-41d4-a716-446655440000"

The unique ID of this widget instance. Useful for debugging; you generally do not need this in widget logic.


ctx.config

const label = ctx.config.label ?? 'Default';

The widget's current configuration object. Values here come from defaultConfig in the manifest plus any changes the user has saved in the settings modal.

ctx.config is a live reference — it is updated in place when the host pushes a CONFIG_CHANGE message. To react to changes, use ctx.onConfigChange.


ctx.theme

if (ctx.theme.mode === 'dark') {
document.body.style.background = '#111';
}
  • ctx.theme.mode'light' or 'dark'. Reflects the user's current dashboard theme mode.
  • ctx.theme.colors — Reserved; currently always an empty object {}.

The theme object is updated in place when the dashboard theme changes. For JavaScript-driven reactions, prefer ctx.onTokensChange.


ctx.storage

Private, per-widget key/value store. Values are persisted inside the widget's configuration on the dashboard and synced to your account.

// Read
const count = ctx.storage.get('count') ?? 0;

// Write (persisted immediately)
ctx.storage.set('count', count + 1);
  • ctx.storage.get(key)unknown | undefined. Synchronous.
  • ctx.storage.set(key, value)void. Synchronous; persists asynchronously in the background.

Values must be JSON-serializable. Keep individual values small (< 10 KB). The total widget config size is bounded by the 512 KB dashboard save limit.


ctx.cache

Browser-local per-widget cache. Values are stored only on the current device and do not affect dashboard autosave, export, or sync.

const weather = await ctx.cache.get('weather');
await ctx.cache.set('weather', weatherSnapshot);
await ctx.cache.delete('weather');
  • ctx.cache.get(key)Promise<unknown>. Async.
  • ctx.cache.set(key, value)Promise<void>. Async.
  • ctx.cache.delete(key)Promise<void>. Async.

Use this for transient caches such as API snapshots, favicon caches, or other local warm data. Do not use it for settings the user expects to travel with the workspace.


ctx.store

Shared cross-widget document storage backed by D1.

// Write a value that other widgets can read
await ctx.store.set({ myData: 'hello' }, 'my-namespace');

// Read a value set by another widget
const data = await ctx.store.get('my-namespace');

// Subscribe to changes made by any widget
const unsubscribe = ctx.store.subscribe('my-namespace', (newValue) => {
console.log(newValue);
});

// Call unsubscribe() when your widget no longer needs the update
  • ctx.store.get(namespace?)Promise<unknown>. Async.
  • ctx.store.set(value, namespace?)Promise<void>. Async.
  • ctx.store.subscribe(namespace, callback)() => void (unsubscribe function).

If namespace is omitted, it defaults to ctx.config.storageNamespace or the widget's instance ID.

If your widget listens to shared data and the storage config can change, re-bind on ctx.onConfigChange.

Runtime semantics:

  • The host keeps a per-namespace IndexedDB cache for ctx.store.
  • get() uses a stale-while-revalidate flow:
    • return the cached document immediately when present
    • check server metadata in the background
    • fetch the full document only when the server has a newer version
  • set() is optimistic:
    • update the local cache first
    • notify local subscribers
    • sync to the server
  • subscribe() can fire for optimistic writes, confirmed writes, or revalidation updates.

For document widgets, the practical rule is: subscription payloads are full snapshots, and your widget should be safe when the same logical value arrives multiple times.

Input Overwrite Guard

Editable widgets should not overwrite a focused input every time a store update arrives. If the user is typing into a textarea and your widget blindly reapplies the remote snapshot, characters can appear to be dropped.

Recommended strategy:

  • track whether the widget has unsaved local edits
  • compare an application-level monotonic field such as updatedAt
  • defer remote application while the focused field has a dirty draft
  • apply the deferred snapshot after save or blur

Example:

const input = document.getElementById("notes-input");
let draftVersion = 0;
let lastSavedVersion = 0;
let lastAppliedUpdatedAt = 0;
let pendingRemoteValue = null;

function hasDirtyDraft() {
return draftVersion !== lastSavedVersion;
}

function getUpdatedAt(value) {
return value && typeof value.updatedAt === "number" ? value.updatedAt : 0;
}

function applyValue(value) {
const incomingUpdatedAt = getUpdatedAt(value);
if (incomingUpdatedAt && incomingUpdatedAt < lastAppliedUpdatedAt) return;

const text = value && typeof value.text === "string" ? value.text : "";
if (input.value !== text) input.value = text;
if (incomingUpdatedAt) lastAppliedUpdatedAt = incomingUpdatedAt;
}

function flushPendingRemote() {
if (!pendingRemoteValue || hasDirtyDraft()) return;
const snapshot = pendingRemoteValue;
pendingRemoteValue = null;
applyValue(snapshot);
}

input.addEventListener("input", () => {
draftVersion += 1;
const currentVersion = draftVersion;
const payload = { text: input.value, updatedAt: Date.now() };

clearTimeout(input._saveTimer);
input._saveTimer = setTimeout(async () => {
await ctx.store.set(payload);
lastSavedVersion = Math.max(lastSavedVersion, currentVersion);
lastAppliedUpdatedAt = Math.max(lastAppliedUpdatedAt, payload.updatedAt);
flushPendingRemote();
}, 180);
});

input.addEventListener("blur", flushPendingRemote);

ctx.store.subscribe(null, (value) => {
if (document.activeElement === input && hasDirtyDraft()) {
pendingRemoteValue = value;
return;
}

applyValue(value);
});

ctx.assets

Bucket-aware file storage backed by R2.

const url = await ctx.assets.upload(file);
const assets = await ctx.assets.list();
await ctx.assets.delete(assets[0].objectKey);
  • ctx.assets.upload(file: File)Promise<string>
  • ctx.assets.list()Promise<Array<AssetRecord>>
  • ctx.assets.delete(objectKey: string)Promise<void>
  • ctx.assets.subscribe(callback)() => void

Asset widgets should use ctx.assets.list() as the source of truth for current bucket contents.

Signed-in account required. ctx.store and ctx.assets are available for Free and Pro users. Guests cannot access shared buckets.


ctx.proxyFetch(url, options?)

Fetches external URLs via the monodit server proxy. Use this instead of fetch() to avoid CORS issues.

const res = await ctx.proxyFetch('https://api.example.com/data');
if (res.status === 200) {
const data = await res.json();
render(data);
}

Options (all optional):

await ctx.proxyFetch('https://api.example.com/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
});

The returned object is not a standard Response. It has:

  • status (number) — HTTP status code
  • headers (object) — Response headers as a plain object
  • text()Promise<string> — Raw response body
  • json()Promise<unknown> — Parsed JSON body

Requires login. ctx.proxyFetch returns a 401 error for guest (unauthenticated) users. Private IP addresses and internal hostnames are blocked (SSRF protection).


ctx.getCurrentLocation()

Requests the user's current coordinates from the host application.

const coords = await ctx.getCurrentLocation();
console.log(coords.latitude, coords.longitude);

Return shape:

  • latitude (number)
  • longitude (number)
  • accuracy (number, optional)
  • timestamp (number, Unix ms)

Requirements and restrictions:

  • The widget manifest must declare "capabilities": ["geolocation"]
  • The host asks the user for approval per widget instance before returning coordinates
  • The host may reject the request if the browser blocks geolocation or the user denies access
  • This bridge is preferred over direct navigator.geolocation access because sandboxed iframe permission behavior is inconsistent across browsers

ctx.openUrl(url, target?)

Opens a URL via the host window. Use this instead of window.open or <a href> — direct navigation is blocked by the sandbox.

ctx.openUrl('https://example.com', '_blank'); // new tab (default)
ctx.openUrl('/dashboard', '_self'); // same tab

ctx.llm.registerTool(name, handler)

Registers a widget-scoped tool handler that monodit's in-app AI can invoke.

ctx.llm.registerTool('add_todo', async (args) => {
const text = String(args.text || '').trim();
if (!text) throw new Error('text is required');

const current = await ctx.store.get();
const items = Array.isArray(current) ? current : [];
await ctx.store.set(items.concat([{ id: crypto.randomUUID(), text }]));

return { message: `Added "${text}".` };
});

Guidelines:

  • Register only tools that are also declared in the manifest's llm.tools block.
  • Validate inputs at runtime even if the manifest already includes an inputSchema.
  • Prefer semantic actions over exposing storage internals.
  • Throw normal Error objects for invalid input or ambiguous matches.
  • Tool handlers may be synchronous or async.
  • Return a compact result object. A message string is a good default for user-facing summaries.

ctx.onResize(callback)

Fires when the widget's iframe container is resized (i.e., the user resizes the grid cell).

ctx.onResize((width, height) => {
canvas.width = width;
canvas.height = height;
redraw();
});

The callback receives (width: number, height: number) — the iframe's window.innerWidth / window.innerHeight at the time of the resize event.


ctx.onConfigChange(callback)

Fires when the user saves new settings for this widget in the settings modal.

ctx.onConfigChange((newConfig) => {
document.getElementById('label').textContent = newConfig.label;
});

The callback receives the full updated config object. ctx.config is also updated in place before the callback fires.


Writing the Manifest File

Write your HTML/CSS/JS as ordinary code inside fenced blocks. No escaping. The frontmatter block on top declares metadata; the body holds the source.

Starter template:

---
monodit: widget
specVersion: "3"
widgetType: basic
name: Widget Name
version: "1.0.0"
minW: 2
minH: 2
---

```html
```

```css
```

```js
```

For AI-assisted development: paste this template into your prompt and describe what you want.


Full Working Examples

Example 1 — Click Counter

A minimal widget that counts clicks and inherits the dashboard theme.

---
monodit: widget
specVersion: "3"
widgetType: basic
name: Click Counter
version: "1.0.0"
---

```html
<div id="app">
<span id="count">0</span>
<button id="btn">Click me</button>
</div>
```

```css
#app {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
}
#count { font-size: 48px; font-weight: bold; }
#btn {
padding: 8px 20px; cursor: pointer; border: none; border-radius: 6px;
background: var(--primary-color); color: #fff; font-size: 14px;
}
```

```js
let count = Number(ctx.storage.get('count') ?? 0);
const countEl = document.getElementById('count');
countEl.textContent = count;
document.getElementById('btn').addEventListener('click', () => {
count++;
countEl.textContent = count;
ctx.storage.set('count', count);
});
```

Example 2 — RSS Feed Reader

Fetches a public RSS feed via ctx.proxyFetch, parses it, and shows the latest 5 headlines.

---
monodit: widget
specVersion: "3"
widgetType: basic
name: RSS Feed
version: "1.0.0"
defaultConfig:
feedUrl: "https://feeds.bbci.co.uk/news/rss.xml"
title: BBC News
---

```html
<div id="root">
<h3 id="title"></h3>
<ul id="list"></ul>
</div>
```

```css
#root {
padding: 12px; font-family: sans-serif;
overflow-y: auto; height: 100%; box-sizing: border-box;
}
h3 { margin: 0 0 8px; font-size: 14px; }
ul { list-style: none; margin: 0; padding: 0; }
li {
padding: 6px 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
font-size: 13px;
}
a { color: inherit; text-decoration: none; cursor: pointer; }
```

```js
async function load(config) {
document.getElementById('title').textContent = config.title || 'RSS Feed';
const list = document.getElementById('list');
list.innerHTML = '<li>Loading…</li>';
try {
const res = await ctx.proxyFetch(config.feedUrl);
const text = await res.text();
const doc = new DOMParser().parseFromString(text, 'application/xml');
const items = Array.from(doc.querySelectorAll('item')).slice(0, 5);
list.innerHTML = items.map((item) => {
const title = item.querySelector('title')?.textContent ?? '';
const link = item.querySelector('link')?.textContent ?? '';
return `<li><a data-href="${link}">${title}</a></li>`;
}).join('');
list.querySelectorAll('a').forEach((a) => {
a.addEventListener('click', () => {
ctx.openUrl(a.getAttribute('data-href'), '_blank');
});
});
} catch {
list.innerHTML = '<li>Failed to load feed</li>';
}
}

load(ctx.config);
ctx.onConfigChange((cfg) => load(cfg));
```

Tip: Use DOMParser (available in the sandbox) to parse RSS/Atom XML. Avoid third-party parser libraries — inline them if truly needed, or keep logic simple.


Example 3 — Shared Todo List

Demonstrates ctx.store for cross-widget state sharing. Two instances of this widget (or any widget that reads the same key) stay in sync.

---
monodit: widget
specVersion: "3"
widgetType: dynamic
name: Shared Todo List
version: "1.0.0"
defaultConfig:
storeKey: todos
storage:
bindings:
- key: primary
backend: d1-json
schemaId: todos.items
schemaVersion: 1
shareable: true
---

```html
<div id="root">
<ul id="list"></ul>
<div id="form">
<input id="input" placeholder="Add item…" />
<button id="add">Add</button>
</div>
</div>
```

```css
#root {
display: flex; flex-direction: column;
height: 100%; padding: 10px; box-sizing: border-box;
font-family: sans-serif;
}
#list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 0; }
li { padding: 4px 0; font-size: 13px; display: flex; gap: 6px; align-items: center; }
li button { font-size: 10px; padding: 2px 6px; cursor: pointer; }
#form { display: flex; gap: 6px; margin-top: 8px; }
#input { flex: 1; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
#add {
padding: 6px 12px; background: var(--primary-color);
color: #fff; border: none; border-radius: 4px; cursor: pointer;
}
```

```js
const KEY = ctx.config.storeKey || 'todos';
const list = document.getElementById('list');

function render(todos = []) {
list.innerHTML = todos.map((t, i) =>
`<li><span>${t}</span><button data-i="${i}">×</button></li>`
).join('');
list.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', async () => {
const current = await ctx.store.get(KEY);
const arr = Array.isArray(current) ? current.slice() : [];
arr.splice(Number(btn.dataset.i), 1);
await ctx.store.set(arr, KEY);
});
});
}

ctx.store.get(KEY).then(render);
ctx.store.subscribe(KEY, render);

document.getElementById('add').addEventListener('click', async () => {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
const current = await ctx.store.get(KEY);
const arr = Array.isArray(current) ? current.slice() : [];
arr.push(text);
await ctx.store.set(arr, KEY);
input.value = '';
});
```

Import & Export

Importing a Widget

  1. Open the dashboard sidebar (edit mode).
  2. Go to the Added tab under Widgets.
  3. Click Import from file and select a .monodit-widget.md file.

If the import succeeds, the widget is added to your dashboard immediately. The manifest's defaultConfig becomes the initial configuration; source is copied locally.

widgetType is validated during import. Dynamic widgets must include a storage contract, and basic widgets cannot declare one.

Import behavior is revision-aware:

  • If the exact same widget revision already exists in Core, import is skipped.
  • If the exact same widget revision already exists in Added, import is skipped.
  • If Core contains the same manifestId but a different revision, the import is allowed as a new Added widget.
  • If Added contains the same manifestId but a different revision, monodit asks for confirmation and then overwrites the existing Added asset.

When an Added widget is overwritten, every widget instance that references that imported asset is updated to the new manifest/source revision, and the newly imported widget is also added to the current dashboard.

Exporting a Widget

Widgets can be exported from either place:

  1. Click the settings icon on a widget instance and use the Export button in the footer.
  2. Or open the widget library, use the card menu on a Core/Added widget, and click Export.

The exported file includes the current source code and manifest metadata such as widgetType, icon, and config (minus any _storage private data). You can share this file with others or import it into another dashboard.

File Naming Convention

Exported files use the pattern <slugified-name>.monodit-widget.md (e.g., rss-feed.monodit-widget.md).


Tips and Constraints

  • No ES modules. You cannot use import/export. Bundle or inline all dependencies.
  • No window.parent properties. The bridge uses window.parent.postMessage() internally, but your widget code cannot read window.parent.document or other cross-origin properties.
  • Quote special YAML values. Strings starting with # (hex colors) and version numbers like 1.0.0 must be wrapped in "..." in the frontmatter. URLs containing : are also safer when quoted.
  • Keep widgets small. The entire dashboard (all widgets combined) has a 512 KB save limit. Large inline assets will hit this limit quickly.
  • Avoid alert() / confirm(). These are blocked in sandboxed iframes.
  • setTimeout / setInterval work, but clean up intervals when appropriate.
  • CSS is scoped to <body>. Your CSS can use body { ... } safely — it only affects your widget's iframe. CSS can use body { ... } safely — it only affects your widget's iframe.