Authoring widgets
Cards inline in the chat box and on the dashboard: choosing between built-in kinds, app and plugin widgets — and the manifest entry, HTML page, text fallback and skill that make one work.
A widget is a card rendered inline in the chat box (Web and Desktop) and on the dashboard — how an app or plugin puts real UI into the conversation: a countdown, a chart, a sales funnel, a table of live numbers. The agent inserts it, calling the emit_widget tool at the moment the context calls for one.
This page is the authoring practice: pick the right widget type, declare it, write the HTML page, teach the agent with a skill, then test and debug. The wire mechanics are summarized in the widgets section of The Space App API; the payload contract and the code live in the SenClaw repo — WIDGET_CONTRACT.md at the root, the registry in src/widgets/mod.rs, the tool in src/tools/emit_widget.rs, the web renderer in web/src/components/WidgetCard.tsx, and apps/widget-pack, the pure-widget sample app every example below is lifted from.
Who does what
declare emit render
┌───────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────────┐
│ 3 SOURCES │ │ AGENT │ │ 3 RENDERERS │
│ • builtin (6 kinds) │──►│ widget_list (discover) │──►│ • Web: WidgetCard.tsx │
│ • app: manifest │ │ emit_widget (insert) │ │ • Desktop: widget_card.dart│
│ widgets[] │ │ │ │ │ • Text channels (TG, Zalo):│
│ • plugin: │ │ ▼ │ │ one-line textFallback │
│ widgets/widgets.json│ │ daemon: resolve registry │ └────────────────────────────┘
└───────────────────────┘ │ + validate params │
│ + build entry URL │
│ → WS chat:widget │
│ → persist chat_widgets │
└──────────────────────────┘Four facts to internalize before writing one:
- Widgets are display-only. One direction — the user never answers through a widget (forms are a different mechanism). The emit tool returns immediately.
- The agent inserts widgets, not the app. The app only declares them and serves the HTML page; for agents to actually use them, ship a skill that teaches them (step 5 below).
- The registry is recomputed on every call — a scan of the enabled rows of the space_apps table plus enabled plugins — so installing or updating an app makes its widgets available at once. No daemon restart.
- Text-only channels cannot render cards. The daemon substitutes a one-line text fallback on Telegram, Zalo and the like — forget this path and those users get a generic one-liner instead of your widget's content.
Choosing the type
| Type | What it is | Reach for it when | Skip it when |
|---|---|---|---|
Built-in kinds — chart, image, clock, weather, video, audio | The client renders native UI from a data object | Plain figures and media: charts, pictures, video, a clock. Light, theme-native, no iframe | You need your own logic or JS, or live self-updating data |
App widget — kind app, manifest widgets[] | An iframe onto an HTML page your app serves | App-specific UI: a CRM funnel, a countdown, a custom table, a frame with live data (fetching your app's API) | Drawing one static chart — builtin chart is far lighter |
Plugin widget — widgets/widgets.json | An iframe onto static HTML the daemon serves for you | Pure-client widgets with no server behind them (plugins have no process) | You need a live API behind the widget |
Experience from widget-pack itself (its own skill gives this advice): in chat, prefer the built-in kinds; save the iframe versions for the dashboard, or for a fixed frame with logic of its own.
Built-in kinds: supply data in the right shape
The agent emits these directly; the app takes part by returning the right data or URL from its MCP tools, with tool descriptions telling the agent what to emit. The data shape per kind:
| kind | data |
|---|---|
chart | chartType one of bar, line, area, pie, scatter; series as name, optional color, and points of x and y; optional xLabel, yLabel, stacked. The tool also takes shortcuts: rows (an array of flat objects — every numeric column becomes a series, the x column auto-detected or named with x), labels plus values, points as pairs or bare numbers; numeric strings parse, decimal commas ("33,5") included. The daemon normalizes everything to the canonical shape (src/widgets/chart_data.rs) |
image | url or dataUrl (one of the two required), caption, alt |
clock | tz, label, showSeconds, showDate, format24h — all optional |
weather | location, unit "C" or "F", current with temp, condition, icon, humidity, wind; optional daily rows of day, hi, lo, icon. icon is one of sunny, partly_cloudy, cloudy, rain, thunderstorm, snow, fog, wind |
video | url, poster, caption, mime, autoplay — the url must be http(s); a local file path is refused outright (the card would be dead) |
audio | url, caption, mime — same url rule as video |
Two app-supplies-the-data patterns running in shipped apps today:
- drawio: the export tool returns
svg_path— a same-origin URL through/api/space/apps/drawio/proxy/...— and its description tells the agent to pass it to emit_widget kindimage. The diagram lands inline in chat. - tiktok-dl: every finished download returns http(s)
file_urls— the agent emits kindvideoand it plays right in the chat.
Beyond the tool, agents inline charts and media mid-reply with code fences named chart, weather, image and so on — same data shortcuts. The app kind stays on the tool path (see the fence section below).
An app widget, step by step
The running example is the countdown widget of the widget-pack app — real code, readable in full under apps/widget-pack in the SenClaw repo.
Step 1 — declare it in senclaw-manifest.json
"widgets": [
{
"id": "countdown", // REQUIRED — the full id becomes "<app-id>.countdown"
"name": "Countdown",
"description": "A live countdown to a moment in time (deadline, event). Param `to` is a YYYY-MM-DD date or an ISO datetime; `label` is the event name.",
"entryUrl": "/widget/countdown.html", // a path your app serves
"size": "small", // small | medium | large | tall
"surfaces": ["chat", "dashboard"], // DEFAULT is ["dashboard"] — omit "chat" and it can never appear in chat
"params": {
"type": "object",
"properties": {
"to": { "type": "string", "description": "Target moment: \"2026-12-31\" or \"2026-12-31T09:00\"" },
"label": { "type": "string", "description": "Event name shown above the digits" }
},
"required": ["to"]
},
"textFallback": "⏳ Countdown {label} to {to} — see it on SenClaw Web/Desktop"
}
]Field by field (the parser is parse_manifest_widgets in src/widgets/mod.rs):
| Field | Required | Notes |
|---|---|---|
id | yes | Missing or empty: the entry is skipped silently. The registry id is <app-id>.<id> |
name | – | Defaults to the id. Becomes the card's default title when the agent passes none |
description | – | The field that matters most. widget_list hands it verbatim to the agent, which decides from it when to insert the widget and what to put in each param. Write it like a tool description: when to use it, what every param means |
entryUrl | – | App-relative (/widget/foo.html). The final entry is the app's runtime origin (the daemon stamps runtime.url on spawn), falling back to /api/space/apps/<id>/proxy plus the entryUrl — and the proxy boots the app on first hit, so the widget renders even when the app was not running |
size | – | Frame height on web: small 180px, medium 320px (the default), large 480px, tall 560px |
surfaces | – | "chat" and/or "dashboard". Absent means ["dashboard"] (old-manifest compatibility) — emitting into chat is then refused with "does not support the chat surface" |
params | – | A JSON Schema of type object, checked at emit time — see step 3 |
refreshMs | – | The client reloads the iframe on this period. Below 1000 it is ignored. For live-data widgets (the CRM funnel uses 30000) |
textFallback | – | The template for text-only channels, with {param} placeholders — step 4 |
intents | – | Nominates the widget as a default-handler candidate for a flow (media and friends), configured under GET/PUT /api/defaults. Rarely needed |
Step 2 — write the HTML page
The principle: one self-contained HTML file (CSS and JS inline) that reads its params from the query string and renders inside the declared frame size. This is the real countdown.html, trimmed of repetition — use it as the mould:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Countdown</title>
<style>
/* Theme: the iframe gets NO theme message from the host — follow the system
with color-scheme + prefers-color-scheme, keep the background transparent
so the card blends in. */
:root { color-scheme: light dark;
--fg: #1f2430; --muted: #7a8194; --accent: #5b8ff9; --bg: transparent; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e8eaf0; --muted: #8a90a3; }
}
html, body { margin: 0; height: 100%; background: var(--bg);
font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); }
.wrap { height: 100%; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px; padding: 12px; box-sizing: border-box; }
/* … digit and label styles … */
</style>
</head>
<body>
<div class="wrap">
<div class="label" id="label"></div>
<div class="digits" id="digits" hidden><!-- days / hours / minutes / seconds --></div>
<div class="msg" id="msg"></div>
</div>
<script>
// 1. Params arrive on the query string — emit_widget builds it from the
// agent's params object.
const q = new URLSearchParams(location.search);
const label = q.get('label') || '';
const rawTo = (q.get('to') || '').trim();
// 2. Validate in the page: a missing or garbage param gets a friendly
// message INSIDE the frame — never a blank card. The daemon's schema
// check only catches missing required fields, not nonsense values.
const iso = /^\d{4}-\d{2}-\d{2}$/.test(rawTo) ? rawTo + 'T00:00:00' : rawTo;
const target = new Date(iso);
if (!rawTo || isNaN(target.getTime())) {
document.getElementById('msg').textContent = rawTo
? 'Not a valid moment: "' + rawTo + '"'
: 'Missing the `to` param (YYYY-MM-DD or ISO datetime).';
} else {
// 3. Live logic: a 1-second interval updates the digits — a "live" widget
// with no refreshMs needed.
/* … tick() … */
}
</script>
</body>
</html>The checklist for the page:
- Self-contained — no external CDNs (widgets must work offline, on loopback); a library you need gets inlined or served by the app itself.
- Theme:
color-scheme: light dark, a prefers-color-scheme block, and a transparent background. Hardcode a white background and dark mode glares. - Fit the frame: design for the declared height (small is 180px); avoid vertical scrolling inside the card.
- Survive bad params: the daemon's validation is lenient (step 3), so the page validates for itself and shows readable errors.
- Live data: either JS ticking on its own (the clock), or fetching your app's API (same-origin through the proxy), or
refreshMsto reload the whole iframe.
Step 2b — serve the page
Two ways, both in production today:
- Plain static HTML (widget-pack): put the files at
web/widget/*.html— no build step; its pack.sh copiesweb/verbatim toweb_dist/next to the binary, and the app serves it statically. The simplest route for widgets. - An app with a Vite build (crm and friends): put the file in Vite's public directory (copied into
dist/untouched) so/widget/foo.htmlkeeps its exact path — the entry path is written in the manifest, so never let Vite hash the file name.
Quick check: open http://127.0.0.1:<app-port>/widget/countdown.html?to=2026-12-31&label=Test straight in a browser — the page must render correctly before the agent enters the picture.
Step 3 — how params are validated (design the schema for it)
The daemon-side validator (validate_params) is deliberately minimal:
- Required fields present.
- Declared primitive type per property: string, number, integer, boolean, array, object.
- Params not declared in the schema pass through — an app may accept more than it publishes. No format, enum, or range checks — that is the page's job.
Two design consequences:
- Params travel as a query string, so complex values (arrays, objects) go as JSON strings the page parses itself. The real pattern — widget-pack's table widget:
"rows": "[[\"Coffee\",25000],[\"Milk coffee\",30000]]"with the schema declaring type string and the description saying "JSON string". - Write each param's
descriptioncarefully — the agent fills params from it, and the validator cannot save a value that is the wrong idea.
Step 4 — textFallback for text-only channels
When the widget targets a jid that is not web: or app: (Telegram, Zalo, QQ…), the chat:widget WebSocket frame cannot arrive — the daemon sends one text message instead:
- With
textFallback: the template renders, each{param}replaced by the emitted value; a param that does not exist becomes the empty string (raw braces never leak), and an unclosed brace stays literal. - Without it: a default line — the widget title plus "open SenClaw → /space/app/(app-id) for the details".
Write the fallback as one complete message with an emoji and the key values ("⏳ Countdown {label} to {to} — see it on SenClaw Web/Desktop") — for users on external channels, this line is the widget.
Step 5 — the skill that teaches the agent
Without a skill, agents almost never discover that your app has widgets to insert. The canonical pattern (structure verbatim from apps/widget-pack/skills/widget-pack/SKILL.md):
---
name: my-app-widgets
description: Insert My App widgets into the chat — pipeline funnel, KPI card — via emit_widget kind "app"
version: 1.0.0
when-to-use: When the user wants to see the sales pipeline or a KPI snapshot right inside the chat
triggers:
- sales pipeline
- pipeline
- kpi
allowed-tools:
- emit_widget
- widget_list
---
# My App — widgets in the chat box
Insert via `emit_widget` with `kind: "app"` (do NOT pass `data` — pass
`widget` + `params`). Call `widget_list` if you need the catalog/params.
**Pipeline** — sales funnel by stage:
```
emit_widget { "kind": "app", "widget": "my-app.pipeline",
"params": { "stage": "won" } }
```
## Notes
- Messaging channels only receive a one-line text fallback — the full widget
shows on the SenClaw Web/Desktop UI.
- Params travel as a query string; compute values yourself and fill them in.Three things the skill must contain:
allowed-toolslisting emit_widget and widget_list.- The explicit reminder — **kind app passes
widgetplusparams, neverdata** — the single most common agent mistake. - A real emit example per widget, with real params — agents imitate examples far better than they read schemas.
Declare the skill in the manifest's skills[] like any other app skill, with triggers matching the keywords users actually type.
Step 6 — test end-to-end, then debug
# 1. Does the registry see it? (id, enabled, entry, surfaces)
curl -s http://127.0.0.1:18788/api/widgets | python3 -m json.tool | grep -A2 '"my-app.'
# 2. Does the page render? — open the entry plus query params straight in a browser
open "http://127.0.0.1:18788/api/space/apps/my-app/proxy/widget/pipeline.html?stage=won"
# 3. Have the agent insert one for real (web chat):
# "use widget_list to see my-app's widgets, then insert the pipeline at stage won"The emit errors you will meet (the exact messages of parse_app_spec):
| The tool returns | Why | The fix |
|---|---|---|
| unknown widget "x.y" — call widget_list… | Wrong id (remember the full id, <app-id>.<short-id>), the app is disabled, or the manifest entry lacks an id | Check GET /api/widgets; enable the app in Space |
| widget "x.y" is disabled in Plugins → Widget settings | The user switched this widget off | Re-enable under Plugins → Widget (stored as defaults.disabledWidgets in ~/.senclaw/config.json) |
| widget "x.y" does not support the chat surface | The manifest lacks "chat" in surfaces (the default is dashboard only) | Add "surfaces": ["chat", "dashboard"] |
| missing required param "…" / param "…" must be of type … | The agent filled params in wrong | Sharpen the param descriptions and the skill's examples |
| kind "app" requires "widget"… / params must be an object | The agent passed data instead of widget plus params | The skill must carry the "do NOT pass data" line |
| the widget registry is not available in this runtime | The tool ran outside the daemon (a standalone MCP subprocess or test) | Kind app only works inside the daemon; built-in kinds still do |
| A blank iframe | The entry 404s (the file never reached web_dist/), or the page JS crashed | Open the entry directly with DevTools; check pack.sh copies widget/ |
After a manifest edit: the registry reads from the space_apps table, so reinstall or refresh the app (re-install the zip, or restart the app from Space) to get the new manifest into the DB — from there the widget is live immediately, no daemon restart.
Runtime behaviour worth knowing
- Emit means persist plus broadcast: each widget gets an id (
widget-<uuid>), lands in the chat_widgets table (FIFO per jid), and goes out as a WebSocket frame of type chat:widget; history loads return it as a message with role "widget" — widgets survive a page reload. - The iframe sandbox equals SpaceAppFrame's: allow-forms allow-modals allow-popups allow-same-origin allow-scripts — a widget carries the same trust as its app.
- Height follows
size(small 180 / medium 320 / large 480 / tall 560, default medium); under the card there is always an "Open app ↗" deep link to/space/app/<app-id>. - A broken entry (app removed, registry cannot resolve it): the card shows a fallback plus the open-app link — never a broken frame.
- chat_jid: the tool accepts it to aim the widget at another chat (the default is the current one) — for agents running in the background pushing a card to a group.
- Title: the agent's
titlewins; otherwise the manifestname. - Old clients meeting an unknown kind render an error chip, not a crash — add new kinds and fields without backward-compat worry.
Plugin widgets: no app server
A marketplace plugin declares widgets with the identical schema, in the file <pluginDir>/widgets/widgets.json (an array of entries):
[
{ "id": "hello", "name": "Hello", "description": "…", "entryUrl": "/hello.html",
"surfaces": ["chat"], "params": { "type": "object", "properties": {} } }
]The differences from an app widget:
- There is no server — the daemon serves the static files itself, at
/api/marketplace/plugins/<plugin>/widget-static/plus the entryUrl. The HTML must be pure client (it may fetch the daemon's same-origin API if needed). - The default surface is
["chat"](plugins exist for the chat box) — the opposite of app widgets'["dashboard"]. - The full id is
<plugin-name>.<short-id>; the catalog lists the source asplugin:<name>. - Loaded only while the plugin is enabled; a broken widgets.json is skipped with a warning in the log — it never takes the daemon down.
Administration: toggles and the catalog
GET /api/widgets— the whole catalog (builtin plus app plus plugin), each entry with anenabledflag.PUT /api/widgets/:idwith body{"enabled": false}— switch one widget off (stored indefaults.disabledWidgets); emitting a disabled widget fails with a clear message.- The user-facing UI is Plugins → Widget (catalog, toggles, defaults).
GET/PUT /api/defaults— the default flow handlers (open link / media / search / note); widgets declaringintentsare the candidates here.
The widget fence: inserting without the tool
In reply text, the agent can write a code fence and the client renders a widget at exactly that spot. Fences named chart, weather, clock, video, audio and image carry the data object directly (chart takes the same shortcuts as the tool):
```chart
{ "labels": ["Mon", "Tue", "Wed"], "values": [37, 33.5, 41] }
```A fence named widget carries a full spec — kind, title, data — including kind app. But a fence spec never passes through the daemon: the web client resolves the entry itself from GET /api/widgets (the path stays fixed by the manifest; params still reach only the query string), while param validation, textFallback and the text-channel fallback all do not run. So: fences suit inline charts and media; kind app belongs on the tool.
Broken or still-streaming JSON renders as an ordinary code block — safe while a reply streams.
The app-widget checklist
[ ] Manifest widgets[]: id + name + description written for an agent to read
[ ] surfaces includes "chat" (the default is dashboard only!)
[ ] size fits the content (small 180 / medium 320 / large 480 / tall 560)
[ ] params: a JSON Schema object, every param description sharp; complex values = JSON strings
[ ] textFallback is one complete message with {param} slots
[ ] Self-contained HTML page at entryUrl: reads URLSearchParams, light/dark theme
(color-scheme + prefers-color-scheme, transparent background), validates its
own params, fits the frame without scrolling
[ ] pack.sh copies the page into web_dist/ (fixed path, no hashing)
[ ] refreshMs (>= 1000) if live data needs whole-frame reloads
[ ] Skill: allowed-tools [emit_widget, widget_list], the "widget+params, NOT data"
reminder, one real emit example per widget
[ ] Test: open the entry directly -> GET /api/widgets shows id + enabled -> have
the agent insert one
[ ] If you serve text channels, read the textFallback line once as a user wouldWidgets ride the normal app zip — publishing an app with widgets is just Publishing a Space App; nothing extra happens on the hub side.