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
12 changes: 12 additions & 0 deletions .changeset/first-class-generated-ui-renderers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"gruntend-sdk": minor
---

Add first-class generated UI renderers so applications explicitly choose how a compiled generated-UI frame is committed to its target.

- Export renderer and session contracts from `gruntend-sdk/renderer`.
- Add the built-in DOMPurify renderer at `gruntend-sdk/renderer/dom-purify` with an exact markup policy and direct `DocumentFragment` commits.
- Route Svelte, React, Vue, and Solid adapters through the selected renderer and add shared render and action lifecycle callbacks.
- Add renderer conformance, sanitization, event delegation, and lifecycle coverage.

Framework adapters now require a `renderer` prop. Create a renderer once for the mounted component lifetime and pass it alongside the `GeneratedUi` value. DOMPurify is the only built-in browser renderer; applications can implement the renderer interface for other targets or commit strategies.
46 changes: 36 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Gruntend lets an app expose a small capability surface to an LLM, receive a Java
defineTools() app capability surface
generateCodePlan LLM → JavaScript plan
runCodePlan scoped execution through handlers
ui-runtime html tagged templates → delegated browser UI
generated UI compiler → renderer → delegated browser UI
```

## Install
Expand Down Expand Up @@ -287,9 +287,10 @@ See [docs/RELEASE.md](docs/RELEASE.md) for the full release checklist.

```ts
import { createGeneratedUi, createHtmlTag } from "gruntend-sdk/ui";
import { mountGeneratedUi } from "gruntend-sdk/ui/dom";
import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify";

const html = createHtmlTag();
const renderer = createDomPurifyGeneratedUiRenderer();

const result = await gruntend.runCodePlan(plan.code, {
input: plan.input,
Expand All @@ -298,29 +299,39 @@ const result = await gruntend.runCodePlan(plan.code, {
});

const ui = createGeneratedUi(result.result).unwrap();
const frame = ui.render().unwrap();
const session = renderer.mount(rootElement, ui, {
onError: console.error,
});
```

// frame.html is safe compiled HTML.
// frame.handlers maps delegated handler ids to generated functions.
`GeneratedUiRenderer` is a first-class object strategy. A renderer is selected when a UI session mounts and remains fixed for that session; `session.update(nextUi)` changes only the generated UI value, and `session.destroy()` tears down the mounted UI. The model does not select renderers.

mountGeneratedUi(rootElement, ui);
```
The DOMPurify renderer is the recommended browser renderer. It uses an exact DOMPurify dependency, a Gruntend-specific tag and attribute allowlist, disables arbitrary `data-*` attributes and unknown protocols, fails closed when DOMPurify is unavailable, and commits the sanitized `DocumentFragment` directly with `replaceChildren()`.

Renderer sanitization and plan-code isolation are separate boundaries. DOMPurify hardens generated markup; it does not sandbox generated JavaScript or reduce the authority of registered tool handlers. Select an isolated executor independently when the plan code is not trusted.

Framework adapters are thin wrappers over the same DOM primitive. They do not own Gruntend state; pass the `GeneratedUi` returned by `createGeneratedUi()`.
Applications with a different target or commit strategy can implement the `GeneratedUiRenderer<TTarget>` interface. Gruntend provides DOMPurify as its only built-in browser renderer.

See [docs/GENERATED_UI.md](docs/GENERATED_UI.md) for the compiler, renderer, DOM session, and security-boundary architecture.

Framework adapters are thin wrappers over the selected renderer. They do not own Gruntend state; pass both the renderer and the `GeneratedUi` returned by `createGeneratedUi()`.

### Svelte

```svelte
<script lang="ts">
import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui";
import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify";
import GeneratedUi from "gruntend-sdk/ui/svelte";

let { ui }: { ui: GeneratedUiModel } = $props();
const renderer = createDomPurifyGeneratedUiRenderer();
</script>

<GeneratedUi
class="agent-generated-ui"
{ui}
{renderer}
onError={(error) => console.error(error)}
/>
```
Expand All @@ -329,13 +340,17 @@ Framework adapters are thin wrappers over the same DOM primitive. They do not ow

```tsx
import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui";
import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify";
import { GeneratedUi } from "gruntend-sdk/ui/react";

const renderer = createDomPurifyGeneratedUiRenderer();

export function AgentResult({ ui }: { ui: GeneratedUiModel }) {
return (
<GeneratedUi
className="agent-generated-ui"
ui={ui}
renderer={renderer}
onError={console.error}
/>
);
Expand All @@ -346,13 +361,17 @@ export function AgentResult({ ui }: { ui: GeneratedUiModel }) {

```tsx
import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui";
import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify";
import { GeneratedUi } from "gruntend-sdk/ui/solid";

const renderer = createDomPurifyGeneratedUiRenderer();

export function AgentResult(props: { ui: GeneratedUiModel }) {
return (
<GeneratedUi
class="agent-generated-ui"
ui={props.ui}
renderer={renderer}
onError={console.error}
/>
);
Expand All @@ -364,21 +383,28 @@ export function AgentResult(props: { ui: GeneratedUiModel }) {
```vue
<script setup lang="ts">
import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui";
import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify";
import GeneratedUi from "gruntend-sdk/ui/vue";

defineProps<{ ui: GeneratedUiModel }>();
const renderer = createDomPurifyGeneratedUiRenderer();

function reportError(error: unknown) {
console.error(error);
}
</script>

<template>
<GeneratedUi class="agent-generated-ui" :ui="ui" @error="reportError" />
<GeneratedUi
class="agent-generated-ui"
:ui="ui"
:renderer="renderer"
@error="reportError"
/>
</template>
```

The Svelte, React, and Solid adapters accept `onError`, `onRender`, `onActionStart`, and `onActionEnd` callbacks. The Vue adapter emits `error`, `render`, `action-start`, and `action-end` events. The consuming app supplies its framework dependency; Gruntend keeps these adapters as optional subpath exports.
The Svelte, React, and Solid adapters require a `renderer` and accept `onError`, `onRender`, `onActionStart`, and `onActionEnd` callbacks. The Vue adapter requires `renderer` and emits `error`, `render`, `action-start`, and `action-end` events. Treat the renderer prop as immutable for the mounted component lifetime. The consuming app supplies its framework dependency; Gruntend keeps these adapters as optional subpath exports.

Framework adapter exports are source-backed through explicit `types` and `import` export conditions for now, so each host app can compile its own framework format. Core runtime exports such as `gruntend-sdk/client`, `gruntend-sdk/code-plan`, `gruntend-sdk/tool`, and `gruntend-sdk/ui` are published from `dist`.

Expand Down
49 changes: 49 additions & 0 deletions docs/GENERATED_UI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated UI architecture

Generated UI is one domain with three small responsibilities:

```text
src/ui/
compiler.ts
policy.ts
renderer.ts
renderers/
dom-session.ts
dom-purify.ts
react/ · solid/ · svelte/ · vue/
index.ts
```

## Compiler

`compiler.ts` captures `html` tagged templates as strings and values, classifies each interpolation, escapes text and attributes, converts event functions into delegated handler identifiers, and validates the resulting tags and attributes against `policy.ts`.

The compiler returns a `GeneratedUiFrame` containing inert HTML and the handler closures referenced by that HTML. It does not access the browser DOM.

## Renderer contract

`renderer.ts` defines `GeneratedUiRenderer<TTarget>` and `GeneratedUiRenderSession`. Applications select a renderer when mounting a generated UI. The renderer remains fixed for that session while `session.update(nextUi)` changes the generated UI value.

The interface is the extension point for non-DOM targets or application-specific commit strategies. Gruntend does not infer or allow generated code to select a renderer.

## Browser renderer

`renderers/dom-purify.ts` is the only built-in browser renderer. It sanitizes the compiled frame with the shared generated-UI policy, receives a `DocumentFragment`, and commits that fragment with `replaceChildren()`.

`renderers/dom-session.ts` contains the browser lifecycle shared by that renderer: delegated events, restricted event payloads, handler execution, rerendering, stale-action protection, updates, and cleanup.

There is no built-in direct-`innerHTML` renderer and no legacy mounting shortcut. Applications that deliberately need a different strategy implement `GeneratedUiRenderer<TTarget>` explicitly.

## Boundaries

These boundaries solve different problems:

| Boundary | Responsibility |
| ------------------------- | ----------------------------------------------------------------- |
| TypeScript renderer types | Match renderers with valid host targets |
| UI compiler and policy | Reject unsafe template structure and encode handler closures |
| DOMPurify renderer | Sanitize the final browser markup before insertion |
| Code-plan executor | Evaluate generated JavaScript with the selected trust profile |
| Tool handlers | Enforce permissions, persistence rules, and application authority |

Renderer typing is not a security boundary at runtime. DOMPurify does not sandbox generated JavaScript, and executor isolation does not authorize tool calls.
2 changes: 1 addition & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ cd "$tmp_dir"
npm init -y
npm install ./gruntend-sdk-0.1.0.tgz
node --input-type=module -e 'import { createGruntendClient } from "gruntend-sdk/client"; import { createJailJsCodePlanExecutor } from "gruntend-sdk/executor/jailjs"; import { defineTools } from "gruntend-sdk/tool"; console.log(Boolean(createGruntendClient({ tools: defineTools({}), executor: createJailJsCodePlanExecutor() }).registry));'
node --input-type=module -e 'await Promise.all(["gruntend-sdk/client","gruntend-sdk/code-plan","gruntend-sdk/generate","gruntend-sdk/registry","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","gruntend-sdk/ui/dom","gruntend-sdk/ui-runtime"].map((id) => import(id))); console.log("core subpath imports ok");'
node --input-type=module -e 'await Promise.all(["gruntend-sdk/client","gruntend-sdk/code-plan","gruntend-sdk/generate","gruntend-sdk/registry","gruntend-sdk/renderer","gruntend-sdk/renderer/dom-purify","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","gruntend-sdk/ui-runtime"].map((id) => import(id))); console.log("core subpath imports ok");'
```

Framework adapter exports (`gruntend-sdk/ui/react`, `gruntend-sdk/ui/solid`, `gruntend-sdk/ui/svelte`, and `gruntend-sdk/ui/vue`) are source-backed and should be verified by the consuming framework build.
Expand Down
4 changes: 2 additions & 2 deletions examples/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This example has:
- normal app routes for browsing that data
- a Gruntend tool namespace over app-owned remote handlers
- a chat-style agent route with a mocked code-plan generator
- tagged-template message islands rendered through `gruntend-sdk/ui` and `gruntend-sdk/ui/svelte`
- tagged-template message islands mounted through an explicitly selected strict DOMPurify renderer and `gruntend-sdk/ui/svelte`
- a per-run browser executor selector: JailJS by default or lazily initialized QuickJS/WASM

## Run
Expand Down Expand Up @@ -64,7 +64,7 @@ The agent route is mocked on purpose and returns deterministic Gruntend code pla

On the overview route, choose **JailJS · controlled** or **QuickJS · isolated** before running a task. The selection applies to the complete plan and its generated UI closures. QuickJS is loaded only when first selected.

The chat transcript renders generated UI returned from code plans as native JavaScript plus the Gruntend `html` tagged template. Event handlers use function interpolation, for example `onclick=${handler}`. The UI compiler rewrites those handlers to inert delegated attributes such as `data-gr-click="h0"`, so the browser never receives real inline JavaScript. The selectable menu prompt demonstrates local generated component state plus app tool calls for duplicated items.
The chat transcript renders generated UI returned from code plans as native JavaScript plus the Gruntend `html` tagged template. Event handlers use function interpolation, for example `onclick=${handler}`. The UI compiler rewrites those handlers to inert delegated attributes such as `data-gr-click="h0"`, and the page selects `createDomPurifyGeneratedUiRenderer()` once for each mounted UI session. The renderer applies the Gruntend allowlist through DOMPurify and inserts the returned fragment directly, so the browser receives neither real inline JavaScript nor unsanitized generated markup. The selectable menu prompt demonstrates local generated component state plus app tool calls for duplicated items.

## Switching back to a real LLM later

Expand Down
26 changes: 8 additions & 18 deletions examples/sveltekit/src/lib/agent/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,13 @@ import type { appTools } from "./tools";
type MenusOutput = { readonly menus: Menu[] };
type MenuOutput = { readonly menu: Menu };
type MenuItemsOutput = { readonly items: MenuItem[] };
type MenuItemOutput = { readonly item: MenuItem };
type CustomersOutput = { readonly customers: Customer[] };
type OrdersOutput = { readonly orders: Order[] };
type OrderOutput = { readonly order: Order };
type PaymentsOutput = { readonly payments: Payment[] };
type ReservationsOutput = { readonly reservations: Reservation[] };
type TablesOutput = { readonly tables: RestaurantTable[] };
type ShiftsOutput = { readonly shifts: Shift[] };
type UsersOutput = { readonly users: User[] };
type UserOutput = { readonly user: User };

type BrowserHandlerOptions = {
readonly canMutate: () => boolean;
Expand All @@ -67,7 +64,7 @@ export function createBrowserHandlers(
"menus.create": async ({ input, ok, err }) =>
runMutation(
options,
() => createMenuCommand(toPlainInput(input)) as Promise<MenuOutput>,
async () => await createMenuCommand(toPlainInput(input)),
ok,
err,
),
Expand All @@ -83,37 +80,31 @@ export function createBrowserHandlers(
"menu.item.create": async ({ input, ok, err }) =>
runMutation(
options,
() =>
createMenuItemCommand(toPlainInput(input)) as Promise<MenuItemOutput>,
async () => await createMenuItemCommand(toPlainInput(input)),
ok,
err,
),

"menu.item.duplicate": async ({ input, ok, err }) =>
runMutation(
options,
() =>
duplicateMenuItemCommand(
toPlainInput(input),
) as Promise<MenuItemOutput>,
async () => await duplicateMenuItemCommand(toPlainInput(input)),
ok,
err,
),

"menu.item.update": async ({ input, ok, err }) =>
runMutation(
options,
() =>
updateMenuItemCommand(toPlainInput(input)) as Promise<MenuItemOutput>,
async () => await updateMenuItemCommand(toPlainInput(input)),
ok,
err,
),

"menu.item.delete": async ({ input, ok, err }) =>
runMutation(
options,
() =>
deleteMenuItemCommand(toPlainInput(input)) as Promise<MenuItemOutput>,
async () => await deleteMenuItemCommand(toPlainInput(input)),
ok,
err,
),
Expand All @@ -127,16 +118,15 @@ export function createBrowserHandlers(
"orders.create": async ({ input, ok, err }) =>
runMutation(
options,
() => createOrderCommand(toPlainInput(input)) as Promise<OrderOutput>,
async () => await createOrderCommand(toPlainInput(input)),
ok,
err,
),

"orders.status.update": async ({ input, ok, err }) =>
runMutation(
options,
() =>
updateOrderStatusCommand(toPlainInput(input)) as Promise<OrderOutput>,
async () => await updateOrderStatusCommand(toPlainInput(input)),
ok,
err,
),
Expand Down Expand Up @@ -167,7 +157,7 @@ export function createBrowserHandlers(
"users.create": async ({ input, ok, err }) =>
runMutation(
options,
() => createUserCommand(toPlainInput(input)) as Promise<UserOutput>,
async () => await createUserCommand(toPlainInput(input)),
ok,
err,
),
Expand Down
Loading
Loading