lemma-widget

GitHub

用于通过 display_resource 创建轻量级内联 Lemma 组件,支持展示结构化数据、图表及与 Pod 数据交互。包含预置库及自定义开发指南,适用于非文档类的可视化回答场景。

lemma-skills/lemma-widget/SKILL.md lemma-work/lemma-platform

Trigger Scenarios

需要展示结构化数据或可视化内容 需要创建可交互的 inline widget 使用预置库组件展示邮件或日历等

Install

npx skills add lemma-work/lemma-platform --skill lemma-widget -g -y
More Options

Non-standard path

npx skills add https://github.com/lemma-work/lemma-platform/tree/main/lemma-skills/lemma-widget -g -y

Use without installing

npx skills use lemma-work/lemma-platform@lemma-widget

指定 Agent (Claude Code)

npx skills add lemma-work/lemma-platform --skill lemma-widget -a claude-code -g -y

安装 repo 全部 skill

npx skills add lemma-work/lemma-platform --all -g -y

预览 repo 内 skill

npx skills add lemma-work/lemma-platform --list

SKILL.md

Frontmatter
{
    "name": "lemma-widget",
    "description": "Create lightweight inline Lemma widgets for conversations via display_resource(type=\"WIDGET\"): self-contained HTML\/CSS\/JS for metrics, lists, comparisons, timelines, record details, previews, and charts, optionally powered by live pod data through the browser Lemma SDK. Use an app, not a widget, when the UI needs React, routing, or substantial application state. Not for a ```lemma-widget block written into a doc or page: that needs no skill, and the doc conversation says how."
}

Lemma Widget

Writing a ```lemma-widget block into a doc or page? Stop here — this skill is not for that. A doc block is a different contract: plain HTML with its data baked in, no SDK, no display_resource, no loading states, no height cap. The conversation attached to the doc states the whole of it; follow that, write the block with pod_edit_file, and do not render or screenshot it to check.

A widget is the default way to show an answer that is more than short prose. Use display_resource(type="WIDGET") whenever the useful result has structure or visual hierarchy: several values, records, statuses, steps, comparisons, a timeline, a compact table, a preview, or a chart.

A widget is also where an answer continues: it reads live pod data, it can be filtered and opened and sorted in place, and it can offer the person their next question ready to send.

Use plain text only for a single fact, a short explanation, or narration around the widget. If an existing FILE, TABLE, APP, or other pod resource already represents the answer, display that resource directly instead of recreating it as a widget.

Start from the library

When the answer is one real thing someone acts on (an email, an invite, a pull request, a support conversation), do not draw it: show a library widget. They live at /skills/lemma-widget/library/<service>/<name>.html, one per service per object, and their buttons act through the viewer's own connected account:

Service Widgets
gmail email-thread, inbox
outlook email-thread
google-calendar invite, find-a-time, meeting-brief
github pr-review, issue
linear issue
slack thread
intercom conversation
linkedin post

The header comment at the top of each file is its contract: the data shape it reads, the connector, and every operation its buttons call. Fetch the real record, shape it to that data, and pass it:

display_resource(type="WIDGET", path="/skills/lemma-widget/library/gmail/email-thread.html",
                 data={"thread_id": "...", "subject": "...", "messages": [...], "draft": "..."})

A draft reply belongs in data.draft; the person edits it and sends.

gmail/inbox is the exception to fetching first: pass only a Gmail search, data={"query": "is:unread in:inbox"}, and it reads the mail itself as the viewer. Fetching the messages to pass them in costs a connector round-trip and the whole inbox as tool arguments, for a list the widget draws anyway. Without data a widget shows its own sample, marked as one, and its buttons do nothing.

To change one, copy it to /widgets/<service>/<name>.html and edit the copy: that folder is the pod's own library, so look there first. Keep the header true when you edit, and keep the lw- classes and lemma.button wiring: they are what make it look like the rest and ask before anything leaves Lemma. A new widget for the library follows the same shape, a .lw-card with a header section, the object, and its actions.

Widget or app?

  • Widget: one compact inline view; plain HTML/CSS/JS; quick to render in the conversation; little local state.
  • Vite app: React, routing, multiple screens, reusable components, substantial interaction/state, or a UI people will return to as a product.

React, ReactDOM, Tailwind, and the agent web-component bundle belong in a Vite app — Lemma has full app support for that class of UI. A widget stays lightweight, and can be saved as an HTML app later.

A widget is an interactive display: whatever you want to show the person in a form they can work with. Its buttons can do anything in Lemma — write records, run functions and workflows, put something in the conversation, or execute a connector operation — as the person viewing it. ask_user is still how this run gets an answer it needs, because a widget's clicks arrive after the run is over.

Build one

  1. If the widget uses pod data, inspect the real table and column names first:

    lemma tables list
    lemma tables get <table>          # exact columns; `pods describe` folds them
    lemma query run "select * from <table> limit 5"
    
  2. The page gives you the design language and the data. A widget is served inside a page that already has, before your markup runs:

    • every --lemma-widget-* token, light and dark — use var(--lemma-widget-surface, #fff) and friends; never paste the token preamble, it is already there;

    • window.lemma: lemma.query(sql) → rows (rows.truncated when the cap cut it), lemma.records(table, options), lemma.compose(text) and lemma.canCompose(), and lemma.esc / num / money / pct / date. It loads and authenticates the SDK itself on first use — never write an SDK loader;

    • and the shapes most answers take, drawn to the visual standard below (hover, a hidden data table, mono figures, hairlines, state colours):

      Shape Call
      Key numbers, each with its change lemma.stats(el, [{ label, value, previous?, format?, better? }])
      One measure across categories lemma.bars(el, rows, { label, value, format?, top? })
      One measure over time, or a few on one chart lemma.line(el, rows, { x, y, format?, xFormat?, names? }) — y a key or several
      Two measures against each other lemma.scatter(el, rows, { x, y, color?, label?, xFormat?, yFormat? })
      Records to read down, and narrow lemma.table(el, rows, { columns: [{ key, label, format? }], filter? })
      One record lemma.record(el, row, { fields: [{ key, label, format? }] })

      el is an element or its id; format is num, money, pct or date; better: "down" marks a measure where falling is good news (failure rate, cost), so its change wears the right colour.

    So a widget is a finding line, one lemma.query and one draw call — about a kilobyte. assets/widget-quick-v1.html is exactly that: start from it. Write the finding as the sentence you would say out loud; keep the error branch. A widget of its own design is still fine when none of these fit — draw it in SVG on the same tokens, or with a library from cdn.jsdelivr.net (a map is d3 + topojson-client + world-atlas; never inline a map or a data file). Keep it to the finding, one query and the drawing.

  3. Only when none of the shapes fits and you are designing your own — a record's timeline, a finding with its own layout — read the nearest fuller example with load_skill (name="lemma-widget", resource_path below) and take the pattern. They are written on the page kit too: no preamble, no loader.

    The answer you have Example
    A judgement, with a distribution behind it assets/widget-finding-v1.html
    Records you want read down, and narrowed assets/widget-table-v1.html
    One record, and what happened to it assets/widget-record-v1.html
    One measure over time assets/widget-trend-v1.html
    One measure across categories assets/widget-ranked-v1.html
    Something you already know — no pod data assets/widget-note-v1.html

    Their uppercase __PLACEHOLDER__ slots are yours to fill if you borrow one; none may survive into what you display. The finding line is yours, not the data's: a widget that says "22 tickets" where it could say "22 tickets are blocked — the most since June" has made the reader do the work they asked you to do.

  4. Write the fragment once with pod_write_file — /me/c/<date>/<name>.html — and display that path:

    display_resource(type="WIDGET", path="/me/c/2026-09-15/pulse.html")
    

    Compose it straight into that call. Do not draft it in the workspace, run it through a parser, or render it to check it: the backend validates what you display and says exactly what is wrong if anything is.

The backend rejects unresolved placeholders, incomplete hand-written SDK loaders, and malformed markup before display.

The display_resource call

type="WIDGET" takes exactly one of:

  • path — a pod file holding the widget's HTML. The one to reach for.
  • content — the HTML inline, for something small you will not revisit.
  • public_url — a URL to embed instead.

Passing more than one, or none, is rejected. One more WIDGET-only field:

  • loading_messages — up to 4 short lines shown while the widget renders. Setting them on any other resource type is rejected.

name, filters, and query belong to other types.

Put it in a file

A widget's HTML is a file you write, the way everything else you build is. That is not a storage detail — it is the difference between work you can go back to and work you get one shot at:

  • You can read back what you wrote instead of trusting that a few thousand characters came out of a single tool argument intact. One missing < has shipped a widget with a stray bracket over the top of it.
  • You can fix it by editing it. The served widget is whatever the file says now, so correcting one is an edit to a few lines, not a retype of the whole fragment — and a retype is where a working widget picks up a new bug.
  • It is checked before anyone sees it. display_resource validates the file and says what is wrong; fix it with an edit and display again. Nothing broken reaches the person, so there is no need to read it back first.

So: pod_write_file to /me/c/<date>/<name>.html, then display_resource(type="WIDGET", path=...). A pod path, never a workspace one — the widget is read as the person looking at it, and they are not in your sandbox.

content still takes an inline fragment, for something small you are sure of. It is frozen in the tool call the moment it succeeds: no editing it, no taking it back, and a second call is a second widget that lands underneath the first with the mistake still showing above it. If you are inlining, you get one look.

Either way:

  • Never put a sentence in the HTML's first line. Not narration, not "clean version below", not a note about the markup. Content with no tag is rejected, and anything before the first tag renders as a bare unstyled line above the view. What the person should read goes in your reply.
  • Never display a probe. A call that succeeds is shown. "Testing whether this transmits" is a test run in front of the person — and with a file there is finally somewhere to test that is not their screen.
  • A widget people will come back to is an app. Save the HTML as an app and pass its address as public_url.

The examples are a shape to follow, not a file to transcribe. Take the loading/empty/error scaffolding, and write the markup your answer actually needs around it.

Fixed contract

  • content is an HTML fragment: raw markup, body-level tags only. A doctype, <html>, <head>, <body>, or an encoded blob is rejected before display.
  • The markup parses as what it reads. A tag that lost its < becomes a text node: the element never exists, everything it styled renders plain, and the tag shows up as text. That, and a close tag with nothing open, is rejected.
  • A standalone SVG image is a pod file: lemma files upload, then display_resource(type="FILE", path=...). Inline <svg> icons inside an HTML fragment are part of the fragment.
  • All CSS is local. The widget runs in its own iframe and inherits no frontend CSS.
  • JavaScript is plain browser JS — no build step, JSX, React, or framework runtime.
  • Secrets, credentials, pod ids, and environment hostnames stay out of the HTML.
  • Loading, empty, error, and narrow-screen states are all deliberate.
  • Values reach the DOM through textContent, or escaped before innerHTML.
  • The view stays compact: no fixed positioning, no nested scrolling.
  • Height is capped. The inline view clips at 480px with a fade and an Expand control, and a self-reported height is clamped to 2400px. Design for the fold: put the answer at the top, not below a long table. The frame follows the content both ways — a view that narrows itself shrinks the frame with it.
  • A follow-up for the agent goes through lemma.compose, which puts text in the conversation's composer for the person to send, edit, or ignore — see Let it answer back. Everything else a button does, it does itself through the SDK — see Act on it.
  • Use ask_user when the run cannot continue without an answer. A widget's clicks arrive after the run is over; ask_user pauses it.

The page is themed and system-aware: the tokens it supplies carry their prefers-color-scheme: dark values and semantic fallbacks, so a widget written on them is too.

The host sends the whole --lemma-widget-* vocabulary — it is taken by prefix, not from a list of approved names, so a token a host publishes reaches you. Ground and surface (bg, surface, subtle, raised), three weights of ink (text, muted, faint), rules (border, border-strong), the accent and the ink that goes on it (accent, on-accent, accent-soft, accent-line), state and its inks (success/on-success, warning, danger/on-danger, danger-soft, info), chart-1 through chart-5, the corner scale (radius-sm, radius-md, radius, radius-panel), shadow-rest and shadow-raise, font and font-mono, and color-scheme.

A host that has no answer for a name leaves it out rather than guessing, which is the entire reason for the next rule.

Every token reference carries a fallback — var(--lemma-widget-surface, #fff), not var(--lemma-widget-surface). The host delivers the palette by postMessage, so it arrives after first paint, and it never arrives at all when the widget is opened outside the conversation frame. A bare reference resolves to nothing there and the widget renders colorless.

Loading the SDK

You do not. lemma.query, lemma.records and lemma.compose cover most widgets, and await lemma.client() hands back the authenticated browser SDK for anything else. What the kit does for you, and what a hand-written loader would have to get right:

  • It builds the SDK URL from the injected window.__LEMMA_CONFIG__.apiUrl and loads /public/sdk/lemma-client.js on first use.
  • It calls client.initialize(), and a non-authenticated result rejects with "This view can't load the pod's data in this browser" — not "signed out": the widget is a cross-site iframe that some browsers deny the cookie, so the person usually is signed in. Keep the widget useful without data where you can.
  • SDK calls run as the signed-in user under normal RLS and grants.
  • Shared files use /…, personal files use /me. There is no /pod/... prefix.

Common calls, on const client = await lemma.client():

await client.records.list("tickets", { limit: 50 });
await client.records.get("tickets", "record-id");
await client.datastore.query(
  "select status, count(*) as matched from tickets group by status"
);
await client.files.search("quarterly planning", { limit: 10 });
await client.files.children.markdown("/knowledge/report.pdf");
await client.files.children.content(
  "/knowledge/report.pdf/pages/page_0001.jpg"
);

Both children calls resolve to a Blob; call .text() on the markdown one before rendering it.

A widget that stays live opens the change stream rather than polling with setInterval:

const handle = client.datastore.watchChanges({
  table: "tickets",              // options object is required
  onChange: (f) => { /* f.operation, f.record_id, f.payload */ },
});
// later: handle.close();

It is a WebSocket with its own auth, so it is subject to the same cross-site constraint as initialize() above — always keep the non-live render working.

Make it do something

A widget is a small program with a live connection to the pod, not a picture of an answer. Reach for that whenever the answer has more in it than fits on screen at once:

  • Narrow it in place. A status filter, a date range, a search box, a sort — re-query in onchange rather than rendering every row and hoping.
  • Open a row. A list where clicking a record swaps the panel for its detail is one records.get, and it saves the person a round trip through you.
  • Keep it current. datastore.watchChanges for a view someone leaves open.
  • Show the thing itself. files.children.markdown and .content render a document's own pages inside the widget instead of describing them.

What stays out: anything needing React, routing, or several screens — that is an app.

Act on it

await lemma.client() is the full SDK, signed in as the person viewing the widget. A button can do the thing, not just describe it:

const c = await lemma.client();
await c.records.update("deals", deal.id, { stage: "won" });
await c.functions.run("send_quote", { input: { deal_id: deal.id } });
await c.workflows.runs.create("onboard_customer");
await lemma.act("gmail", "send_message", { to, subject, text }, { kind: "http" });

lemma.act(connector, operation, payload) finds the organization's install of the connector itself; kind names the backend the operation names belong to, so a widget written for another one says so instead of failing obscurely. Everything runs under the viewer's own permissions and RLS, so a person who cannot write a table cannot write it through a widget either.

Wire every button with lemma.button(el, { run, confirm?, yes?, tone?, done? }). It spins while run works, writes the result or error under the button's row, and with confirm opens a strip that acts only from its own button. Over a widget's sample data it runs nothing and says what it would have done.

  • Say what happened, from the result. Show the saved row, the run's status, or the error — in the widget, right where the button was. Disable the button while the call is in flight so one click is one write.
  • Name the effect on the button. "Mark won", "Send to Acme", "Delete 3 drafts" — the label is what the person agrees to when they click it. Something that cannot be undone, or that reaches someone outside the pod, says so before it runs: a second click to confirm is enough.
  • Never claim what did not happen. "Sent" is written after the send resolved, not when the button was pressed.

Let it answer back

lemma.compose(text, options?) asks the host to put text in the conversation's composer:

lemma.canCompose().then((ok) => {
  button.hidden = !ok;
  button.onclick = () => lemma.compose("Why is " + account.name + " cooling?");
});

Three things about it, and all three matter:

  • It fills the box; it does not send. The person reads what arrived, edits it or not, and presses enter. So write the text as them asking — "Why is Acme cooling?", not "The user would like to know about Acme."
  • It can be unavailable. It resolves false when nothing is hosting the widget — an app opened from a share link has no conversation anywhere near it. Check lemma.canCompose() before drawing the button, and let the widget be useful without it. A button that quietly does nothing is worse than one that was never there.
  • { newConversation: true } is for a handoff. The default lands in the thread the person is looking at, which is what "ask about this" means. Pass the flag when the point is that the subject gets a thread of its own rather than landing in the middle of something else.

Two or three offers on a widget is plenty, and each should be a question the person would plausibly ask next. A row of eight buttons is a menu, and a menu is an app.

Never count what came back

Both read paths are capped, and the two report it differently. Getting this wrong prints a number that is simply false in front of the person who asked for it.

  • records.list serves one page, at most 1000 rows — a limit above that is rejected, not clamped. Its total is the exact RLS-scoped count of matching rows, so items.length is what you are showing and total is what exists. Say "12 of 480" rather than letting twelve stand for all of it.
  • datastore.query returns { items, total, truncated }, and here total counts rows returned, not rows matched. When truncated is true the row cap cut the result short and items is a prefix of the real answer, so the count is a floor. Narrow the query rather than label a floor as a total.

Read the keys your own query produces. sum(runs_failed) as failed returns failed, and row.runs_failed on that result is undefined. Nothing throws: Number(undefined) || 0 is 0, and the widget renders a confident, plausible, wrong number — "none failed" over a window full of failures. Nothing in the platform can catch this for you, because that column may be a real key of a different query in the same widget. Alias deliberately, and read back the name you aliased to.

Aggregate in SQL. A count taken over a page of records is a count of the page: group in the database (select status, count(*) …) and a widget over a 5,000-row table stops reporting the first hundred rows as the whole table. The metric and chart examples do exactly this; keep their query rather than counting rows in JS.

Visual standard

A widget is drawn inside a conversation, which is the one surface in this app allowed contrast: a message has to sit on something and a card has to read as a card. So the view gets figure and ground — and then these, which are what separate a view from a dashboard shell with your data poured into it.

Shape the view to the answer, not to the query. This is the one that matters. select x, count(*) group by x has a shape, and it is not an answer's shape. Bent into a template it produces a count with no denominator, four cards that say nothing is more important than anything else, and a chart of a field nobody asked about. Start from the sentence you would say out loud, and build the view that supports it.

  • Lead with the finding. A sentence with a number in it, not a number with a label under it. The person asked a question; answer it in the first line.
  • Every figure carries a denominator or a direction. 22 is trivia. 22 of 480, up nine this week, none moved in nine days is an answer. A count with neither is a number you have made somebody else interpret.
  • Hairlines, not boxes. Ruled rows inside one card, not twelve bordered pills each competing to be looked at. Shadow carries weight — shadow-rest — so a card is an object on the page rather than a rectangle with a line around it.
  • Status owns a colour; the accent means "you can act here". success, warning and danger are reserved for state and never for "series four". The accent appears about four times in a view, not forty. Every fill of it is written in on-accent — never white, which measures 1.2:1 on a light accent in dark mode.
  • Figures are mono and tabular (font-mono, font-variant-numeric: tabular-nums). Numbers that do not align cannot be compared, which is the only reason to put them in a column.
  • Weight never exceeds 500. Hierarchy comes from size and the space above something. A 700 is not emphasis, it is shouting in a quiet room.
  • The ground is paper, not screen. Warm stock, not #ffffff on #f5f5f5.
  • End with the next question, in the person's own words. See Let it answer back.

Charts

  • Pick the form from the data's job. Change over time → a line. Magnitude across categories → horizontal bars, so the labels read straight instead of rotated under a column. One number that matters → not a chart at all; say it.
  • Use the page's shapes; draw the rest in SVG. lemma.line, lemma.bars and lemma.scatter load nothing: a widget has 480px of fold and has to paint now, and a chart library from a CDN is a 200KB download and a second failure mode inside an iframe that already has one. Reach for a library when the form needs one — a map, dense multi-series, a real axis — and then give the canvas an explicit height.
  • Never a dual axis. Two measures of different scale are two charts.
  • chart-1 through chart-5, in order, never cycled. They are a measured set: adjacent pairs clear ΔE 8 under deuteranopia and ΔE 15 under normal vision. A sixth category is not a sixth colour — fold the tail into one "Other" in ink, not in a categorical hue, which is what widget-ranked-v1 does.
  • One series needs no legend — the title names it. Two or more always get one, because colour alone is not an identity channel.
  • Text never wears the data colour. Bars and lines carry the hue; labels, values and axes stay in text / muted / faint. A coloured dot beside a label carries identity; a coloured label is just hard to read.
  • Label selectively. The endpoint, the extreme, or the one series the story is about. A number on every point is the axis again, in a worse typeface.
  • Recessive grid: hairline, solid, one step off the surface. Never dashed.
  • Ship the hover layer and the table. A chart in a browser that does not respond to a pointer is a screenshot; a chart whose numbers exist only as pixels is unreadable to anyone not looking at it. Both examples carry a visually-hidden table — keep it.

Format numbers and dates for humans. Keep explanation outside the widget, in your reply.

Before display

  • The chosen view is genuinely more useful than short prose.
  • The HTML lives in a pod file, unless it is small and settled enough to inline.
  • It opens with a tag — not a stray character, not a sentence — and is complete. An inline fragment gets no second look; a file can be edited afterwards.
  • Every value read off a query result uses the name that query aliases it to.
  • Every placeholder was replaced — including the claim slots, which no query can fill for you.
  • Every tag opens with < and closes once; the fragment carries no full-document tags, secrets, hardcoded hosts, or pod ids.
  • Every --lemma-widget-* reference has a fallback value.
  • Pod data comes through lemma.* or await lemma.client(); there is no hand-written SDK loader.
  • No displayed count is the size of a page: totals come from SQL or from records.list's total, and a truncated query result says so.
  • Loading, empty, error, and mobile states are present, and the non-authenticated branch does not tell a signed-in person to sign in.
  • The view is shaped by the answer, not by the query behind it: the first line is the finding, and every figure carries a denominator or a direction.
  • Status wears state colour, the accent appears about four times, every accent fill is written in on-accent, and no weight exceeds 500.
  • A chart labels selectively, keeps text in ink rather than the data colour, responds to a pointer, and carries its numbers as a table as well.
  • Anything the person can click does something here — reads, writes, runs — or offers something to the composer. Every action is named on its button, shows its real result or error, and nothing claims a send that did not resolve.
  • Every compose button is behind lemma.canCompose(), and its text reads as the person's own words.

For React or a full product UI, load lemma-builder and follow references/apps.md. For interaction-tool behavior, see lemma-builder/references/agent-tools.md.

Version History

  • 2ca9e5e Current 2026-10-18 19:31

    新增 gmail/inbox 快速读取未读邮件功能;修复 execute_python 失败时的错误分类与返回逻辑,提升响应速度。

  • 4e1f360 2026-09-22 02:35

    重构了四个示例为基于答案类型的六个命名组件(如发现、表格、记录等);修复了高度自适应和主题令牌匹配问题,改进尺寸测量与状态颜色支持。

  • 8999990 2026-09-08 20:52
  • 6adb1c8 2026-08-27 10:45

    修复非HTML内容(如Base64)被错误渲染的问题;禁止SVG根节点作为Widget内容并引导使用FILE类型;优化正则表达式以消除SVG检查时的灾难性回溯性能隐患。

  • 20197d6 2026-08-04 20:37

    修正了构建步骤中的命令错误,将 'lemma pods describe' 替换为正确的 'lemma tables get <table>' 以获取确切列名。

  • 0b61713 2026-07-19 09:57

Same Skill Collection

lemma-backend/lemma-connectors/skills/integration-creator/SKILL.md
lemma-skills/browser/SKILL.md
lemma-skills/lemma-artifact-author/SKILL.md
lemma-skills/lemma-builder/SKILL.md
lemma-skills/lemma-research/SKILL.md
lemma-skills/lemma-skill-creator/SKILL.md
lemma-skills/lemma-user/SKILL.md
lemma-skills/liteparse-documents/SKILL.md
lemma-skills/lemma-app-design/SKILL.md
lemma-skills/lemma-app-qa/SKILL.md
lemma-skills/lemma-data-analysis/SKILL.md
lemma-skills/lemma-evals/SKILL.md
lemma-skills/lemma-form/SKILL.md

Metadata

Files
0
Version
2ca9e5e
Hash
cfe9c472
Indexed
2026-07-19 09:57

ホーム - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-10-23 03:24
浙ICP备14020137号-1