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:

  1. Widgets are display-only. One direction — the user never answers through a widget (forms are a different mechanism). The emit tool returns immediately.
  2. 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).
  3. 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.
  4. 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

TypeWhat it isReach for it whenSkip it when
Built-in kindschart, image, clock, weather, video, audioThe client renders native UI from a data objectPlain figures and media: charts, pictures, video, a clock. Light, theme-native, no iframeYou 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 servesApp-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 widgetwidgets/widgets.jsonAn iframe onto static HTML the daemon serves for youPure-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:

kinddata
chartchartType 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)
imageurl or dataUrl (one of the two required), caption, alt
clocktz, label, showSeconds, showDate, format24h — all optional
weatherlocation, 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
videourl, poster, caption, mime, autoplay — the url must be http(s); a local file path is refused outright (the card would be dead)
audiourl, 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 kind image. The diagram lands inline in chat.
  • tiktok-dl: every finished download returns http(s) file_urls — the agent emits kind video and 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):

FieldRequiredNotes
idyesMissing or empty: the entry is skipped silently. The registry id is <app-id>.<id>
nameDefaults to the id. Becomes the card's default title when the agent passes none
descriptionThe 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
entryUrlApp-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
sizeFrame 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"
paramsA JSON Schema of type object, checked at emit time — see step 3
refreshMsThe client reloads the iframe on this period. Below 1000 it is ignored. For live-data widgets (the CRM funnel uses 30000)
textFallbackThe template for text-only channels, with {param} placeholders — step 4
intentsNominates 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 refreshMs to 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/*.htmlno build step; its pack.sh copies web/ verbatim to web_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.html keeps 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 description carefully — 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:

  1. allowed-tools listing emit_widget and widget_list.
  2. The explicit reminder — **kind app passes widget plus params, never data** — the single most common agent mistake.
  3. 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 returnsWhyThe 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 idCheck GET /api/widgets; enable the app in Space
widget "x.y" is disabled in Plugins → Widget settingsThe user switched this widget offRe-enable under Plugins → Widget (stored as defaults.disabledWidgets in ~/.senclaw/config.json)
widget "x.y" does not support the chat surfaceThe 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 wrongSharpen the param descriptions and the skill's examples
kind "app" requires "widget"… / params must be an objectThe agent passed data instead of widget plus paramsThe skill must carry the "do NOT pass data" line
the widget registry is not available in this runtimeThe 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 iframeThe entry 404s (the file never reached web_dist/), or the page JS crashedOpen 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 title wins; otherwise the manifest name.
  • 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 as plugin:<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 an enabled flag.
  • PUT /api/widgets/:id with body {"enabled": false} — switch one widget off (stored in defaults.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 declaring intents are 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 would

Widgets ride the normal app zip — publishing an app with widgets is just Publishing a Space App; nothing extra happens on the hub side.