Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions packages/claw-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<state-dir>/plugins/openclaw-os/app-skill-read-sessions.json`, so restarts don't force a re-read).

## Install

Expand Down Expand Up @@ -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
Expand All @@ -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 `<state-dir>/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).

Expand Down
44 changes: 18 additions & 26 deletions packages/claw-plugin/generate-prompt.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -113,30 +119,16 @@ const chatPrompt = chatPromptRaw
// `value?: $binding<string>` → `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.
Expand Down
1 change: 1 addition & 0 deletions packages/claw-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dist",
"static",
"skills",
"prompts",
"openclaw.plugin.json",
"README.md"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading