From 53eb5ea2b887e2c6d3508a4e769c166cef587001 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Mon, 11 May 2026 15:27:06 +0530 Subject: [PATCH] Refactor inline UI handling and update prompt generation --- packages/claw-plugin/README.md | 27 ++- packages/claw-plugin/generate-prompt.ts | 44 ++--- packages/claw-plugin/package.json | 1 + .../SKILL.md => prompts/openui-inline-ui.md} | 15 +- packages/claw-plugin/src/index.ts | 160 ++++++++++++------ 5 files changed, 144 insertions(+), 103 deletions(-) rename packages/claw-plugin/{skills/openui-inline-ui/SKILL.md => prompts/openui-inline-ui.md} (95%) diff --git a/packages/claw-plugin/README.md b/packages/claw-plugin/README.md index 60bf2bb..7f0f90e 100644 --- a/packages/claw-plugin/README.md +++ b/packages/claw-plugin/README.md @@ -10,24 +10,18 @@ The plugin is a single OpenClaw extension that performs four roles: 1. **Serves the workspace UI.** Registers an HTTP route at `/plugins/openclawos` (via `api.registerHttpRoute`). The route serves the prebuilt static export of the workspace (Next.js `output: "export"`) bundled into the plugin's `static/` directory. Browser tabs load the UI from the gateway origin and connect back over the same-origin WebSocket — no CORS, no allowed-origins config, no tunnel. -2. **Augments agent prompts for OpenClaw OS sessions.** Registers a `before_prompt_build` hook. For each agent run originating from the workspace it prepends a small OpenUI Lang system prompt — roughly 100–200 tokens, just enough to point the model at the available skills. Detection uses the session-key suffix `:openclaw-os`, so runs from other clients (CLI, scripts, third-party apps) are unaffected. +2. **Augments agent prompts for OpenClaw OS sessions.** A `before_prompt_build` hook prepends the full inline-UI OpenUI Lang spec plus surface-routing guidance; a `before_tool_call` hook blocks `app_create` / `app_update` until the agent has `read` `skills/openui-app/SKILL.md` that session. Both are scoped by the session-key suffix `:openclaw-os`, so other clients (CLI, scripts, third-party apps) are unaffected. See "Two UI surfaces" below. 3. **Provides persistent UI primitives.** Lightweight stores for **apps**, **artifacts**, **notifications**, and **uploads** give agents addressable, persistent surfaces the workspace renders and updates across turns. See `app-store.ts`, `artifact-store.ts`, `notification-store.ts`, `upload-store.ts`. 4. **Registers the `openclaw os` CLI command group.** Via `api.registerCli`. The `os url` subcommand prints a token-authenticated workspace URL built from the gateway-validated config — same auth pattern as `openclaw dashboard`. Clipboard and browser-open are left to the calling shell so the plugin stays free of `child_process` (which would trip openclaw's install security scan). -## Skills, not a bloated system prompt +## Two UI surfaces: one inlined, one loaded on demand (and gated) -The system prompt the plugin injects is intentionally thin. It does not contain the full OpenUI Lang reference, the component library, or the app-authoring guide. +The plugin teaches OpenUI Lang in two pieces, split by how often a turn needs each: -Instead, the plugin ships two **skills** at `skills/`: - -| Skill | Purpose | -| :--- | :--- | -| [`openui-inline-ui`](./skills/openui-inline-ui/SKILL.md) | One-shot UI inside a chat reply — charts, tables, forms, follow-ups, multi-section reports. Static (no `$state`, no `Query`). | -| [`openui-app`](./skills/openui-app/SKILL.md) | Durable apps the user reopens — dashboards, trackers, command centers. Full reactive surface: `$state`, `Query`, `Mutation`, scheduled refresh, persistent SQLite. | - -The initial prompt only carries each skill's name and one-line description. The LLM loads a skill's full body on demand, when it actually needs to render UI or build an app — so the base context stays small and turns that don't touch UI generation pay no token tax. +- **Inline UI** — one-shot UI in a chat reply (charts, tables, forms, follow-ups; static, no `$state`/`Query`/`Mutation`). Used by almost every visual answer, so the full spec ([`prompts/openui-inline-ui.md`](./prompts/openui-inline-ui.md), a `generate-prompt.ts` artifact — not a skill) is prepended verbatim to every OpenClaw OS system prompt. +- **Durable apps** — reopenable dashboards/trackers/command centers with the full reactive surface (`$state`, `Query`, `Mutation`, scheduled refresh, SQLite). Larger and needed by a minority of turns, so it stays a load-on-demand skill ([`skills/openui-app/SKILL.md`](./skills/openui-app/SKILL.md)). The model tends to call `app_create` without reading it, so a `before_tool_call` hook blocks `app_create` / `app_update` until the agent has `read` it that session (per-session set persisted to `/plugins/openclaw-os/app-skill-read-sessions.json`, so restarts don't force a re-read). ## Install @@ -88,16 +82,18 @@ pnpm ci # lint + format + typecheck ``` packages/claw-plugin/ ├── src/ -│ ├── index.ts # entrypoint: hook + tools + RPC + HTTP route + CLI +│ ├── index.ts # entrypoint: hooks (prompt + app-skill gate) + tools + RPC + HTTP route + CLI │ ├── app-store.ts # app primitive store │ ├── artifact-store.ts # artifact primitive store (SQLite-backed) │ ├── lint-openui.ts # validation for emitted OpenUI Lang │ ├── notification-store.ts # notification store │ ├── upload-store.ts # upload store -│ └── generated/ # generated prompt assets (do not edit by hand) +│ └── generated/ # generated assets — openui-schema.json (do not edit by hand) +├── prompts/ +│ └── openui-inline-ui.md # inline-UI OpenUI Lang spec — inlined into the system prompt (not a skill) ├── skills/ -│ ├── openui-app/SKILL.md # durable apps skill (loaded on demand) -│ └── openui-inline-ui/SKILL.md # inline UI skill (loaded on demand) +│ └── openui-app/SKILL.md # durable-apps skill — loaded on demand, gated by app_create/app_update +├── generate-prompt.ts # regenerates prompts/, skills/openui-app/, and src/generated/ from the OpenUI library ├── static/ # workspace static export (gitignored, populated by `pnpm bundle-ui`) ├── dist/ # esbuild output (generated by `pnpm build`) ├── openclaw.plugin.json # plugin manifest @@ -110,6 +106,7 @@ packages/claw-plugin/ - Types come from subpath exports: `import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"` and `from "openclaw/plugin-sdk/core"`. See [`AGENTS.md`](../../AGENTS.md) for the full guidance. - The plugin ships compiled JS at `dist/index.js`, bundled by esbuild. `package.json` `main` and `openclaw.extensions` both point there. Older "jiti loads `.ts` directly" behavior was removed in openclaw 2026.5.x. - Plugin RPCs and tools that share names with gateway-core surfaces (`artifacts.*`, `tools.invoke`) are namespaced under `openclawos.*` to avoid collision. +- `app_create` / `app_update` are gated by a `before_tool_call` hook (must `read` `skills/openui-app/SKILL.md` first; state in `/plugins/openclaw-os/app-skill-read-sessions.json`). The inline-UI spec (`prompts/openui-inline-ui.md`) is read at startup by `src/index.ts` and prepended via `before_prompt_build`. Both `:openclaw-os`-only. See "Two UI surfaces" above. - The `static/` directory is treated as opaque content. The HTTP handler serves whatever is in there with sensible MIME types and a path-traversal guard. `pnpm bundle-ui` is the only thing that should write to it. - For end-user setup story, architecture rationale, and what's still TODO, see [`docs/openclaw-os-bundling.md`](../../docs/openclaw-os-bundling.md). diff --git a/packages/claw-plugin/generate-prompt.ts b/packages/claw-plugin/generate-prompt.ts index 28bbc05..24811a7 100644 --- a/packages/claw-plugin/generate-prompt.ts +++ b/packages/claw-plugin/generate-prompt.ts @@ -1,8 +1,9 @@ /** * Build script — emits the artifacts the plugin needs at runtime: * - * skills/openui-app/SKILL.md — durable apps (Query/Mutation/$state) - * skills/openui-inline-ui/SKILL.md — inline UI in chat replies (static) + * skills/openui-app/SKILL.md — durable apps (Query/Mutation/$state); a real skill + * prompts/openui-inline-ui.md — inline UI in chat replies (static); inlined into the + * Claw system prompt by src/index.ts, NOT a skill * src/generated/openui-schema.json — drives the lint loop in lint-openui.ts * * Re-run with `pnpm generate` whenever @openuidev/react-ui changes its @@ -31,15 +32,18 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const generatedDir = join(__dirname, "src", "generated"); const skillsDir = join(__dirname, "skills"); +const promptsDir = join(__dirname, "prompts"); mkdirSync(generatedDir, { recursive: true }); -mkdirSync(join(skillsDir, "openui-inline-ui"), { recursive: true }); +mkdirSync(promptsDir, { recursive: true }); mkdirSync(join(skillsDir, "openui-app"), { recursive: true }); // ───────────────────────────────────────────────────────────────────────────── -// 1. openui-inline-ui skill — inline UI in a chat reply. +// 1. openui-inline-ui prompt — inline UI in a chat reply. // Static surface only: no Query, no Mutation, no $variables, no builtins, // no filters. Just component signatures + Action({@ToAssistant, @OpenUrl}). +// Written as a plain prompt file (no frontmatter): src/index.ts reads it at +// startup and inlines it into the Claw system prompt. It is NOT a skill. // ───────────────────────────────────────────────────────────────────────────── const CHAT_PREAMBLE = `You are rendering generative UI inline in a chat reply, using a small DSL called openui-lang. @@ -83,7 +87,9 @@ COMMON MISTAKES (the renderer drops them or shows broken UI): - AccordionItem same — three args, content array - "col" direction → "column" (or omit; column is the default) - @Map(rows, ...) → there is no @Map in chat (no live data anyway). Just inline literal arrays. -- Triple-backticks INSIDE MarkDownRenderer text → close the outer openui-lang fence early. NEVER nest triple-backticks. Use single backticks or describe code in prose.`; +- Triple-backticks INSIDE MarkDownRenderer text → close the outer openui-lang fence early. NEVER nest triple-backticks. Use single backticks or describe code in prose. + +STREAMING ORDER — define dependencies right after their parent, breadth-first. A reference resolves only once its definition has streamed in, so a child defined far below its parent renders late. In particular: a Form's Buttons argument (2nd positional) is the submit affordance — define \`btns\` and its Button(s) IMMEDIATELY after \`form = Form(...)\`, BEFORE the FormControl fields, or the submit button only pops in at the very end. Same for any container: \`Card([a, b])\` → define \`a\`, then \`b\`, then their internals. Don't push all leaf definitions to the bottom.`; // `inlineMode: true` would inject upstream's "## Inline Mode" block, which // talks about patching existing UI — concept doesn't apply to chat replies @@ -113,30 +119,16 @@ const chatPrompt = chatPromptRaw // `value?: $binding` → `value?: string` on signatures. .replace(/\$binding<([^>]+)>/g, "$1"); -// `always: true` forces openclaw to inline this skill into every system -// prompt for Claw sessions. Without it, skills are read-on-demand and the -// model — left to its own judgment — almost always picks plain text over -// openui-lang for chart/table/comparison/form requests, which is the -// product's whole point. The inline-UI skill is small (~6 KB), so -// always-loading it is worth the prompt-budget cost. -// -// `description` leads with the routing decision (when to pick this skill vs -// openui-app). The negative-scope sentence ("STATIC ONLY...") prevents the -// agent from reaching for $state / Query / Mutation here. -const CHAT_FRONTMATTER = `--- -name: openui-inline-ui -description: Render generative UI inside a chat reply using openui-lang fenced code. Use for charts, tables, comparisons, forms, follow-ups, multi-section reports — any one-shot visual answer. STATIC ONLY: no $state, no Query, no Mutation, no live data. If the user wants something they will reopen later, STOP and use openui-app instead. -always: true ---- - -`; - +// Plain prompt file — NO YAML frontmatter. This is not a skill: src/index.ts +// reads prompts/openui-inline-ui.md at startup and inlines the whole thing into +// the Claw system prompt via the `before_prompt_build` hook. (Auto-injecting it +// as an `always: true` skill too would just duplicate ~250 lines per session.) writeFileSync( - join(skillsDir, "openui-inline-ui", "SKILL.md"), - CHAT_FRONTMATTER + chatPrompt + "\n", + join(promptsDir, "openui-inline-ui.md"), + chatPrompt.trimEnd() + "\n", "utf8", ); -console.log(`✓ skills/openui-inline-ui/SKILL.md (${chatPrompt.length} chars)`); +console.log(`✓ prompts/openui-inline-ui.md (${chatPrompt.length} chars)`); // ───────────────────────────────────────────────────────────────────────────── // 2. openui-app skill — durable apps with live data. diff --git a/packages/claw-plugin/package.json b/packages/claw-plugin/package.json index 5a90079..f5ac443 100644 --- a/packages/claw-plugin/package.json +++ b/packages/claw-plugin/package.json @@ -15,6 +15,7 @@ "dist", "static", "skills", + "prompts", "openclaw.plugin.json", "README.md" ], diff --git a/packages/claw-plugin/skills/openui-inline-ui/SKILL.md b/packages/claw-plugin/prompts/openui-inline-ui.md similarity index 95% rename from packages/claw-plugin/skills/openui-inline-ui/SKILL.md rename to packages/claw-plugin/prompts/openui-inline-ui.md index cf00c78..aa1700f 100644 --- a/packages/claw-plugin/skills/openui-inline-ui/SKILL.md +++ b/packages/claw-plugin/prompts/openui-inline-ui.md @@ -1,9 +1,3 @@ ---- -name: openui-inline-ui -description: Render generative UI inside a chat reply using openui-lang fenced code. Use for charts, tables, comparisons, forms, follow-ups, multi-section reports — any one-shot visual answer. STATIC ONLY: no $state, no Query, no Mutation, no live data. If the user wants something they will reopen later, STOP and use openui-app instead. -always: true ---- - You are rendering generative UI inline in a chat reply, using a small DSL called openui-lang. DSL SHAPE — every program is identifier-equals-component-call assignments: @@ -187,11 +181,13 @@ During streaming, the output is re-parsed on every chunk. Undefined references a **Recommended statement order for optimal streaming:** 1. `root = Card(...)` — UI shell appears immediately -2. Component definitions — fill in as they stream +2. Component definitions — breadth-first: define each component's direct dependencies right after it, before descending into deeper nesting 3. Data values — leaf content last Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in. +**Define dependencies right after their parent, breadth-first.** A reference resolves only once its definition has streamed in, so a child defined far below its parent appears late. Concretely: a `Form`'s `Buttons` argument (2nd positional) controls the most important affordance — the submit button — so define `btns` and its `Button`(s) IMMEDIATELY after `form = Form(...)`, BEFORE the `FormControl` fields. Otherwise the form renders its fields and the submit button only pops in at the very end. Same idea for any container: `Card([a, b])` → define `a`, then `b`, then their internals — don't write all the leaves last. + ## Examples Example 1 — Table with follow-ups: @@ -236,15 +232,16 @@ followups = FollowUpBlock([fu1, fu2]) fu1 = FollowUpItem("Show me only beach destinations") fu2 = FollowUpItem("Turn this into a comparison table") -Example 4 — Form with validation: +Example 4 — Form with validation (note the order: root, then form, then its Buttons, THEN the fields — so the submit button streams in early, not last): root = Card([title, form]) title = TextContent("Contact Us", "large-heavy") form = Form("contact", btns, [nameField, emailField, msgField]) +btns = Buttons([submitBtn]) +submitBtn = Button("Submit", Action([@ToAssistant("Submit")]), "primary") nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 })) emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true })) msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) -btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary")]) ## Important Rules - When asked about data, generate realistic/plausible data diff --git a/packages/claw-plugin/src/index.ts b/packages/claw-plugin/src/index.ts index 8f99be1..2b87745 100644 --- a/packages/claw-plugin/src/index.ts +++ b/packages/claw-plugin/src/index.ts @@ -1,5 +1,5 @@ import { mergeStatements } from "@openuidev/lang-core"; -import { createReadStream } from "node:fs"; +import { createReadStream, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdir, stat } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; @@ -19,69 +19,58 @@ import { UploadStore } from "./upload-store.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -/** - * Tiny preamble injected into Claw sessions. Tells the agent that - * openui-lang is available and points at the two skills the plugin - * ships in `skills/`. Keeps the system prompt small at session start — - * the agent reads the relevant SKILL.md on demand via the `read` tool - * (openclaw auto-lists them via ``). - */ -const CLAW_PREAMBLE = `# Claw client — Generative UI is your default for visual answers - -This chat is rendered by the Claw client. The user is here specifically because they want answers as interactive UI, not walls of text. Two skills give you the language to do that. +const INLINE_UI_PROMPT: string = (() => { + try { + return readFileSync( + path.resolve(__dirname, "..", "prompts", "openui-inline-ui.md"), + "utf-8", + ).trim(); + } catch { + return ""; + } +})(); -## openui-lang — a DSL you do NOT know from training +const CLAW_PREAMBLE = `# Claw client — Generative UI is your default for visual/interactive answers -Generative UI on this client uses \`openui-lang\` — a small assignment-based DSL specific to this product. **Your training data does not contain it.** Always \`read\` the relevant skill before emitting any code; do not guess the syntax from JSX, MDX, or other component DSLs. +This chat is rendered by the **Claw client**. The user is here because they want answers as interactive UI — charts, tables, forms, dashboards — not walls of text. You produce UI with \`openui-lang\`, a small assignment-based DSL specific to this product. **Your training data does not contain openui-lang.** Do not guess its syntax from JSX, MDX, React, or any other component DSL — the authoritative spec for the inline surface is included below; the app surface has its own skill you must read before using it. -## Skills +## Pick the surface: plain text · inline UI · app -### \`openui-inline-ui\` — UI inside an assistant message -Read \`skills/openui-inline-ui/SKILL.md\` BEFORE responding when any of these fire: -- Chart, graph, plot, trend, comparison, breakdown, summary, table, KPI, metric — the user wants to *see* the answer. -- Recommendation or advice request that needs 2+ preferences ("which X should I buy", "help me pick, "what's the best Y for me") → render a Form to collect preferences. Never a numbered question list. -- Answer would exceed ~10 lines → wrap in \`SectionBlock([SectionItem(...)])\` accordion. -- Suggesting next actions → end with \`FollowUpBlock([FollowUpItem(...)])\`. -- Basically this will be very helpful for the user to directly interact with the UI instead of just reading or typing text, decreasing the cognitive load on the user. +**Plain text (no UI)** — for: +- Casual / conversational / meta turns: "hi", "thanks", "what can you do", "what do you mean by X". Match the weight of your answer to the weight of the question — a one-line question gets a one-line answer, not a tabbed app. +- A single-sentence factual answer where a chart adds nothing. +- Output already rendered elsewhere (e.g. file diffs in a tool result). -When triggered, your response MUST contain an \`\`\`openui-lang fenced block. +**Inline \`openui-lang\` block (inside your assistant message)** — when ANY of these fire: +- Chart / graph / plot / trend / comparison / table / breakdown / summary / visualization — the user wants to *see* the answer. +- Compare or rank 2+ things; a series of numbers; a leaderboard. +- Multi-field input ("plan a trip", "set up X", "help me pick", "which Y should I buy for me") → render a \`Form\` with \`FormControl\`s + a submit \`Button\`. Never a numbered list of questions. +- The answer would run past ~10 lines → wrap it in \`SectionBlock([SectionItem(...)])\`. +- You're suggesting next actions → end with \`FollowUpBlock([FollowUpItem(...)])\`. -### \`openui-app\` — durable, persistent apps the user opens repeatedly -Read \`skills/openui-app/SKILL.md\` BEFORE calling \`app_create\`, \`app_update\`, \`get_app\`. Trigger phrases: -- "briefing", "morning briefing", "Monday morning view", "before standup", "daily digest" -- "dashboard", "command center", "war room", "monitor", "tracker", "control panel", "status board", "hub" -- Anything needing live data (Query), write actions (Mutation), or stateful controls that survive reload -- Killer use cases: morning briefings (email + calendar + alerts), engineering command centers (PRs + CI + Linear), founder dashboards (MRR + churn + runway), portfolio dashboards, SEO content planners, social media monitoring +The inline surface is **STATIC**: no \`Query\`, no \`Mutation\`, no \`$state\`. Need live data, refresh, or write actions? That's an app, not inline UI. -When triggered, **call \`app_create\` immediately once the code is ready** — do not finish narrating first. +**An app (\`app_create\`)** — when the user wants something **durable they will reopen**: a dashboard, command center, briefing, tracker, monitor, control panel, status board, hub — or anything needing live data (\`Query\`), write actions (\`Mutation\`), or stateful controls (\`$state\`) that survive a reload. Apps are a **different surface, with extra components and a runtime** — you **MUST \`read\` \`skills/openui-app/SKILL.md\` before calling \`app_create\` or \`app_update\`**; those tools reject the call until you have. Trigger phrases: "briefing", "morning briefing", "before standup", "daily digest", "dashboard", "command center", "war room", "monitor", "tracker", "control panel", "hub". Once the code is ready, **call \`app_create\` immediately** — don't finish narrating first. -## Cross-cutting rules (apply even before you read the skills) +Never explain that you *can* render UI — just render it. -1. \`"col"\` is NOT a valid Stack/Card direction. Use \`"column"\` (or omit — column is the default). \`"vertical"\`/\`"horizontal"\`/\`"v"\`/\`"h"\` are also invalid; only \`"row"\` and \`"column"\`. +## openui-lang — inline surface (authoritative spec) -2. These names DO NOT EXIST anywhere: \`Heading\`, \`KpiCard\`/\`KPI\`/\`StatCard\`/\`Metric\` (build KPIs as \`Card([TextContent(label, "small"), TextContent(value, "large-heavy")], "sunk")\`), \`Section\` (the type is \`SectionBlock\` in chat / \`Accordion\` in apps), \`Markdown\` (use \`MarkDownRenderer\`), \`Badge\` (use \`Tag\`), \`Divider\` (use \`Separator\`), \`Tab\` (use \`TabItem\`), \`Grid\`. \`@Map\` (use \`@Each\`), \`@FormatDate\`/\`@FormatNumber\`/\`@JsonParse\`/\`@Length\`/\`@Find\` are not real builtins. +${INLINE_UI_PROMPT} -3. Inside \`MarkDownRenderer(...)\` text strings, NEVER include triple-backticks. They close the outer \`\`\`openui-lang fence early and the rest of your code renders as raw markdown text. Use single backticks for inline code, or describe code in prose. (Inline-only concern — \`app_create\` takes raw code so the fence collision can't happen there.) +## Cross-cutting rules -4. \`app_create\` and \`app_update\` both validate code and report \`validationErrors\` in their response. The app IS saved either way. To fix lint failures, ALWAYS call \`app_update\` with ONLY the corrected statements (typically 1–10 lines) — the runtime merges by statement name. NEVER re-emit the whole program; that's slower and risks introducing new errors. +1. Inside \`MarkDownRenderer(...)\` text strings, NEVER include triple-backticks — they close the outer \`\`\`openui-lang fence early and the rest of your code renders as raw markdown. Use single backticks for inline code, or describe code in prose. (Inline-only concern; \`app_create\` takes raw code so the fence can't collide there.) -5. The \`openui-inline-ui\` surface is STATIC: no \`Query\`, no \`Mutation\`, no \`$state\`. If you need live data, refresh, or write operations, use \`openui-app\` instead. +2. \`app_create\` / \`app_update\` validate the code and report \`validationErrors\` in the response — the app is saved either way. To fix them, call \`app_update\` with ONLY the corrected statements (typically 1–10 lines); the runtime merges by statement name. NEVER re-emit the whole program. -6. **App needs user config?** If creating an app requires values you don't have (watchlist symbols, monthly burn, target repos, key thresholds, your timezone), STOP, emit a \`Form\` inline via \`openui-inline-ui\` to collect them, THEN call \`app_create\` with those values baked into Query defaults or a config table. Don't guess defaults that won't match the user's reality. Skip the form when the request is fully self-describing, or when the config is multi-row mutable state (that belongs in an in-app Form). +3. **App needs config you don't have?** (API keys, watchlist symbols, monthly burn, target repos, thresholds, timezone) — STOP, emit a \`Form\` inline to collect it, THEN \`app_create\`. Bake plain values into Query defaults or a config table; for secrets/API keys, have the user put them in \`~/.openclaw/workspace/.env\` and read them in the data script — never inline a key into app code. Skip the form only when the request is fully self-describing, or when the config is multi-row mutable state (that belongs in an in-app Form). -7. **"Every morning" / "Monday" / "daily" / "while I sleep" / "pre-fetched" → propose cron in the same response.** Don't wait to be asked. Same rule for heavy scripts (slow APIs, paginated >50 items, multi-source serial calls): wire cron → SQLite snapshot table → app reads from DB. Live \`Query("exec")\` is fine for fast/lightweight scripts; cron + DB is mandatory when refresh time degrades the open-app experience. +4. **"Every morning" / "Monday" / "daily" / "while I sleep" / "pre-fetched"** → propose a cron in the same response, don't wait to be asked. Same for heavy scripts (slow APIs, >50 paginated items, multi-source serial calls): wire cron → SQLite snapshot table → app reads from the DB. Live \`Query("exec")\` is fine for fast/light scripts. ## Refine flow -When the composer text starts with \`Refine app "..." (id: ...)\` or \`Refine artifact "..." (id: ...)\`, the user is iterating on an existing surface. Read \`openui-app\`, then call \`app_update\` (apps) or \`update_markdown_artifact\` (artifacts) with that exact id. Do not create a new one. The patch should be 1–10 statements; never re-emit the whole program. - -## When NOT to render UI - -- Conversational chat ("hi", "thanks", "what do you mean by X"). -- Single-sentence factual answers where a chart adds no value. -- Tool-call output already rendered (e.g. file diffs in a tool result). - -The bottom line: read the relevant skill BEFORE composing your response. Never explain that you can render UI — just do it.`; +When the composer text starts with \`Refine app "..." (id: ...)\` or \`Refine artifact "..." (id: ...)\`, the user is iterating on an existing surface. For apps: \`read\` \`skills/openui-app/SKILL.md\`, then \`app_update\` with that exact id (a 1–10 statement patch — never the whole program). For artifacts: \`update_markdown_artifact\` with that id. Do not create a new one.`; function sanitizeDbSegment(value: string): string { return value.replace(/[^a-zA-Z0-9._-]+/g, "_"); @@ -304,13 +293,6 @@ export default definePluginEntry({ }, { commands: ["os"] }, ); - - // ── Tiny preamble injection ────────────────────────────────────────────── - // The agent fetches the actual openui-lang prompt body via `read` on the - // skill files in `skills/openui-inline-ui/SKILL.md` and - // `skills/openui-app/SKILL.md`. Openclaw auto-lists those in the - // `` block, so this hook only adds the two-line nudge - // that tells the agent which skill to load when. api.on("before_prompt_build", (_event, ctx) => { if (!ctx.sessionKey?.endsWith(":openclaw-os")) { return; @@ -318,6 +300,78 @@ export default definePluginEntry({ return { prependSystemContext: CLAW_PREAMBLE }; }); + const APP_SKILL_GATE_MESSAGE = + "Read `skills/openui-app/SKILL.md` first — it documents the openui-lang " + + "*app* surface (Query, Mutation, $state, Stack, the full component catalog, " + + "the lint loop). Reading it is required before `app_create` / `app_update`. " + + "Then retry this call."; + + let appSkillGate: { path: string; sessions: Set } | null = null; + const getAppSkillGate = (): { path: string; sessions: Set } => { + if (!appSkillGate) { + const file = path.join( + api.runtime.state.resolveStateDir(), + "plugins", + "openclaw-os", + "app-skill-read-sessions.json", + ); + let sessions: Set; + try { + const parsed: unknown = JSON.parse(readFileSync(file, "utf-8")); + sessions = new Set( + Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [], + ); + } catch { + sessions = new Set(); + } + appSkillGate = { path: file, sessions }; + } + return appSkillGate; + }; + const markAppSkillRead = (sessionKey: string): void => { + const gate = getAppSkillGate(); + if (gate.sessions.has(sessionKey)) { + return; + } + gate.sessions.add(sessionKey); + try { + mkdirSync(path.dirname(gate.path), { recursive: true }); + writeFileSync(gate.path, JSON.stringify([...gate.sessions], null, 2), "utf-8"); + } catch (err) { + api.logger.warn(`[openclaw-os-plugin] failed to persist app-skill gate: ${err}`); + } + }; + + api.on("before_tool_call", (event, ctx) => { + const sessionKey = ctx.sessionKey; + if (typeof sessionKey !== "string" || !sessionKey.endsWith(":openclaw-os")) { + return; + } + + // Mark the session once the agent reads the app skill (via the `read` tool). + if (event.toolName === "read") { + const filePath = event.params["file_path"] ?? event.params["path"]; + if ( + typeof filePath === "string" && + filePath.replace(/\\/g, "/").includes("openui-app/SKILL.md") + ) { + markAppSkillRead(sessionKey); + } + return; + } + + if ( + (event.toolName === "app_create" || event.toolName === "app_update") && + !getAppSkillGate().sessions.has(sessionKey) + ) { + api.logger.info( + `[openclaw-os-plugin] ${event.toolName} blocked — openui-app skill not read in session ${sessionKey}`, + ); + return { block: true, blockReason: APP_SKILL_GATE_MESSAGE }; + } + return; + }); + // ── Artifact store — lazy-initialized on first use ────────────────────── let store: ArtifactStore | null = null; const getStore = (): ArtifactStore => {