Skip to content
Gruntend Beta

Generated UI

A plan normally returns data. A UI plan returns an html template or render function instead.

There are two separate operations:

  • generateCodePlan(...) calls a model and returns JavaScript plan code. It does not run that code or call handlers. Generation is optional because you may write or obtain the plan elsewhere.
  • runCodePlan(...) takes existing plan code, evaluates it in the interpreter, and dispatches its tool calls to your handlers. It does not call a model.

For generated UI, first ask the model for a UI plan, then run that plan with the html function available.

import { generateCodePlan } from "gruntend/generate";

const { plan } = await generateCodePlan({
  model,
  tools,
  task,
  input,
  ui: { kind: "tagged-html" },
});

generateCodePlan() returns a GeneratedCodePlan:

interface GeneratedCodePlan {
  readonly summary: string;
  readonly input: Record<string, unknown>;
  readonly code: string;
}
  • summary describes what the model generated.
  • input is passed to the interpreter as input.
  • code is the JavaScript function body passed to runCodePlan().

ui: { kind: "tagged-html" } changes the generation instructions so code returns UI. It does not create or execute the UI.

If you write the plan yourself, skip this generation step and pass your code string directly to runCodePlan().

import { createHtmlTag } from "gruntend/ui";

const html = createHtmlTag();

const result = await gruntend.runCodePlan(plan.code, {
  input: plan.input,
  handlers,
  ui: { html },
});

ui: { html } makes the tagged-template function available inside the interpreter. The plan can now use html alongside input and tools.

A plan may return a template:

const { items } = await tools.menu.items.list({
  menuId: input.menuId,
});

return html`<ul>
  ${items.map((item) => html`<li>${item.name}: $${item.price}</li>`)}
</ul>`;

For local state, return a render function. State is an ordinary JavaScript closure:

var count = 0;
function increment() {
  count += 1;
}

return function render() {
  return html`<button type="button" onclick=${increment}>
    Count: ${count}
  </button>`;
};

After a handler runs, the surface renders again.

Choose a normal DOM element where the generated interface should appear:

<div id="generated-ui"></div>

Then create and mount the generated UI:

import { createGeneratedUi } from "gruntend/ui";
import { mountGeneratedUi } from "gruntend/ui/dom";

if (result.status !== "done") throw new Error(result.error);

const element = document.querySelector("#generated-ui");
if (!(element instanceof HTMLElement)) {
  throw new Error("Generated UI host not found.");
}

const ui = createGeneratedUi(result.result).unwrap();
const mount = mountGeneratedUi(element, ui);

// when the host is removed
mount.destroy();

createGeneratedUi() turns the plan result into a small interface with render() and runHandler(). mountGeneratedUi() then:

  1. renders the template into element
  2. listens for click, submit, input, and change events on that element
  3. finds the delegated handler created by expressions such as onclick=${handler}
  4. runs the original closure handler with a restricted event payload
  5. renders again after the handler finishes, so closure state appears in the DOM

The returned mount handle controls that connection:

  • mount.render() renders the current state again
  • mount.update(nextUi) replaces the generated UI and renders it
  • mount.runHandler(id) invokes a generated handler programmatically
  • mount.destroy() removes event listeners and stops future rendering

Framework adapters perform this same mounting and cleanup through their component lifecycle. See Framework adapters.

onclick=${handler} is compiled to a delegated data-gr-click identifier. Executable inline event strings are rejected. Text and attribute interpolations are escaped.

Generated handlers receive a restricted event payload, not broad DOM access.

A mutation UI should first render the proposed change. Call mutation tools only after explicit confirmation from the person using the interface. Permissions and persistence still belong to the app-owned handler.