Installation

Get started in 30 seconds.

Three steps: load the bundle, mount the tag, and feed it Aktion text. Everything else — parsing, rendering, state, theming, routing, even Font Awesome — is handled inside the shadow DOM.

1. Load the bundle

Use the CDN build for zero setup:

<script type="module"
  src="https://asfand-dev.github.io/aktion/dist/aktion.js">
</script>

Or install from npm if you bundle your own assets:

npm install aktion-runtime
 
// then, somewhere in your app entry:
import "aktion-runtime";

The bundle is a single ES module (~110 KB minified, ~30 KB gzipped). It registers exactly one custom element — <aktion-app> — and ships:

2. Mount the element

Drop the tag anywhere in your HTML:

<aktion-app theme="light"></aktion-app>

Optional attributes:

You can also drop the script text directly as the element’s text content — the runtime reads textContent on connect if no response attribute is set.

3. Send it Aktion

For static UI, set the response attribute (or the matching property):

<aktion-app></aktion-app>
<script>
  const el = document.querySelector("aktion-app");
  el.setResponse(`
    _app_ = Stack([greeting])
    greeting = Card([
      CardHeader("Hello", subtitle: "Generative UI in plain HTML"),
      Markdown("Set this script via **setResponse()** or stream it via **appendChunk()**.")
    ])
  `);
</script>

For streaming responses (e.g. SSE from an LLM), set streaming = true while you append chunks. The UI commits each line as soon as the parser can — setting streaming just tells the error banner not to flash for transient mid-line parse errors.

const el = document.querySelector("aktion-app");
const reader = response.body.getReader();
const decoder = new TextDecoder();

el.clear();
el.streaming = true;
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  el.appendChunk(decoder.decode(value, { stream: true }));
}
el.streaming = false;

Public API at a glance

The element is the entire integration surface:

// Attributes (set in HTML)
<aktion-app
  theme="dark"
  showerrors
  response="...">
</aktion-app>

// Properties (set in JS)
el.response   = "_app_ = ..."   // same as setResponse(text)
el.streaming  = true            // toggle streaming mode
el.showErrors = true            // toggle the developer error banner

// Methods
el.setResponse(text)            // replace the entire script
el.appendChunk(chunk)           // append a streaming chunk
el.clear()                      // wipe state and program text
el.setTheme(theme)              // built-in name, or a Theme token map
el.registerComponents(specs)    // extend the component library at runtime
el.setTools({ name: fn })       // expose async tools to js{} blocks
el.registerHttpInterceptors(...) // hook every http() call
el.navigate(path)               // programmatic router navigation
el.getSystemPrompt()            // build the LLM system prompt
el.applyDelta(ops)              // Delta Protocol structural patches
el.serializeState() / hydrateState(snap) / loadSnapshot({...}) // persistence

// Events (CustomEvent on the element)
"assistant-message"             // detail: { message }   — Button without action:
"route-change"                  // detail: { path, previousPath, source }
"error"                         // detail: { kind, errors }
// + any user-emitted event from an action/effect via `emit`

Hello, world

Try it in a single file. Save the snippet below as index.html and open it — no build, no bundler.

<!doctype html>
<html lang="en">
  <body>
    <aktion-app theme="light"></aktion-app>
    <script type="module">
      import "https://asfand-dev.github.io/aktion/dist/aktion.js";

      const el = document.querySelector("aktion-app");
      el.setResponse(`
        $count = 0
        _app_ = Card([
          CardHeader("Counter demo", subtitle: "Reactive state in 5 lines"),
          Text("Count: " + $count),
          Button("Increment", action: increment)
        ])
        action increment() { $count = $count + 1 }
      `);
    </script>
  </body>
</html>

Developer error banner

While iterating, enable the in-shadow error banner:

<aktion-app showerrors></aktion-app>

When enabled, parse and evaluation errors render as a compact banner above your UI. The banner is automatically suppressed while streaming = true so partial mid-line content doesn’t flash transient errors. Listen for the error event to forward diagnostics to your own logger or DevTools.

el.addEventListener("error", (event) => {
  console.warn("Aktion:", event.detail);
});

System prompt

To make an LLM emit valid Aktion, prepend the bundled system prompt to your conversation. Two flavors ship in dist/:

const SYSTEM_PROMPT = await fetch("/dist/system_prompt.txt").then(r => r.text());

const messages = [
  { role: "system",  content: SYSTEM_PROMPT },
  { role: "user",    content: "Show me a pricing page for a SaaS product." },
];

// Stream the response and forward chunks to the component.
streamLlmResponse(messages, (chunk) => el.appendChunk(chunk));

Both files are regenerated from src/prompt/generator.ts at build time, so they always match the components, themes, and builtins shipped in the same release.

Where to next?