Integration

Drop-in setup for every framework.

<aktion-app> is a standard Custom Element, so it works in every framework that renders HTML — React, Next.js, Vue, Nuxt, Angular, Svelte, Solid, Astro, Remix, and plain HTML.

Universal pattern

Three things matter no matter which framework you use:

  1. Import the bundle once in client-side code so it registers the custom element.
  2. Render <aktion-app> wherever you want the LLM output to appear.
  3. Get a reference to the element and call setResponse(), appendChunk(), or set theme.

Pick your stack

Installation

Pick whichever channel suits your project:

# From npm โ€” works for any bundler
npm install aktion-runtime
# yarn add aktion-runtime
# pnpm add aktion-runtime

# Then, somewhere in your client-side entry point:
import "aktion-runtime";

# Or load from CDN โ€” no build step required
<script type="module" src="https://asfand-dev.github.io/aktion/dist/aktion.js"></script>

The package is published as aktion-runtime. The bundle is self-contained: it includes the parser, runtime, the complete component library, the seven built-in themes, and an optional Font Awesome loader (only injected when your script uses icons). You only ever need a single import.


React

React 18+Vite / CRA / Parcel

React (< 19) doesn’t natively understand custom-element props or events, but it does pass attributes through and accepts a ref. The pattern below uses a ref to call methods and a useEffect hook to wire events.

// LLMResponse.tsx
import { useEffect, useRef } from "react";
import "aktion-runtime";

interface Props {
  response: string;
  theme?: "light" | "dark" | "neon" | "pastel" | "glass" | "brutalist" | "skyline";
  onAssistantMessage?: (text: string) => void;
}

export function LLMResponse({ response, theme = "light", onAssistantMessage }: Props) {
  const ref = useRef<HTMLElement & { setResponse: (t: string) => void }>(null);

  useEffect(() => {
    ref.current?.setResponse(response);
  }, [response]);

  useEffect(() => {
    const el = ref.current;
    if (!el || !onAssistantMessage) return;
    const handler = (e: Event) => onAssistantMessage((e as CustomEvent).detail.message);
    el.addEventListener("assistant-message", handler);
    return () => el.removeEventListener("assistant-message", handler);
  }, [onAssistantMessage]);

  return <aktion-app ref={ref} theme={theme} />;
}

Add the JSX intrinsic element type once so TypeScript stops complaining:

// types/aktion.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from "react";
declare global {
  namespace JSX {
    interface IntrinsicElements {
      "aktion-app": DetailedHTMLProps<
        HTMLAttributes<HTMLElement> & {
          theme?: string;
          streaming?: boolean;
          showerrors?: boolean;
        },
        HTMLElement
      >;
    }
  }
}

For React 19+ you can pass props directly as element properties — no ref needed for the simple read-only case:

<aktion-app theme="dark" response={text} />

Next.js (app router)

Next 13+app routerClient component

Custom elements only work in the browser, so the wrapper must be a client component (the import statement registers the element on HTMLElement globals). Mark the file with "use client" at the top.

// app/components/LLMResponse.tsx
"use client";

import { useEffect, useRef } from "react";
import "aktion-runtime";

export function LLMResponse({ response, theme = "light" }: { response: string; theme?: string }) {
  const ref = useRef<any>(null);
  useEffect(() => { ref.current?.setResponse(response); }, [response]);
  return <aktion-app ref={ref} theme={theme} />;
}

// app/page.tsx โ€” server component
import { LLMResponse } from "./components/LLMResponse";

export default function Page() {
  const program = `_app_ = Stack([greeting])
greeting = Card([CardHeader("Hello", subtitle: "Generative UI in Next.js")])`;
  return <LLMResponse response={program} theme="light" />;
}

For SSR-only pages you can skip the wrapper and import via a next/dynamic call with ssr: false:

const LLMResponse = dynamic(() => import("./components/LLMResponse").then(m => m.LLMResponse), {
  ssr: false,
});

Vue 3

Vue 3Vitecomposition API

Tell Vue to skip its component resolver for any tag matching the custom element. In vite.config.ts (or wherever the Vue plugin is configured):

// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [
    vue({
      template: { compilerOptions: { isCustomElement: (tag) => tag === "aktion-app" } },
    }),
  ],
});

Then use it like any other element:

<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import "aktion-runtime";

const target = ref<HTMLElement | null>(null);
const response = ref(`_app_ = Stack([Card([CardHeader("Hello from Vue")])])`);
const theme = ref("light");

onMounted(() => (target.value as any)?.setResponse(response.value));
watch(response, (next) => (target.value as any)?.setResponse(next));
</script>

<template>
  <aktion-app ref="target" :theme="theme" />
</template>

Nuxt 3

Nuxt 3SSR

Nuxt renders on the server by default, so the import has to be deferred until the client. Use a <ClientOnly> wrapper and register the element type in nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    compilerOptions: { isCustomElement: (tag) => tag === "aktion-app" },
  },
});

// plugins/aktion.client.ts
import "aktion-runtime";
export default defineNuxtPlugin(() => {});
<!-- pages/index.vue -->
<template>
  <ClientOnly>
    <aktion-app theme="light" ref="renderer" />
  </ClientOnly>
</template>

Angular

Angular 17+standalone components

Add CUSTOM_ELEMENTS_SCHEMA to the component (or module) so the template compiler accepts the unknown tag, then import the bundle once in main.ts:

// main.ts
import "aktion-runtime";
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent);

// app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, AfterViewInit } from "@angular/core";

@Component({
  standalone: true,
  selector: "app-root",
  template: `<aktion-app #renderer theme="light"></aktion-app>`,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent implements AfterViewInit {
  @ViewChild("renderer") renderer!: ElementRef<HTMLElement & { setResponse(t: string): void }>;

  ngAfterViewInit() {
    this.renderer.nativeElement.setResponse(`_app_ = Stack([Card([CardHeader("Hello, Angular")])])`);
  }
}

Svelte / SvelteKit

Svelte 4 / 5SvelteKit

Svelte already understands custom elements. Import the bundle (in a client-side +page.ts hook or directly in the component) and you’re done.

<!-- routes/+page.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import "aktion-runtime";

  let renderer: any;
  const program = `_app_ = Stack([Card([CardHeader("Hello from Svelte")])])`;
  onMount(() => renderer?.setResponse(program));
</script>

<aktion-app bind:this={renderer} theme="light" />

For server-rendered routes wrap the import in browser-guarded code (e.g. if (browser) await import("aktion-runtime");) so it only runs in the client bundle.


SolidJS

SolidJS 1.x
// App.tsx
import { onMount } from "solid-js";
import "aktion-runtime";

export default function App() {
  let renderer: any;
  onMount(() => renderer?.setResponse(`_app_ = Stack([Card([CardHeader("Hello, Solid")])])`));
  return <aktion-app ref={renderer} theme="light" />;
}

Astro

Astro 4+

Astro can ship the element either as a hydrated island or as a static tag with a small inline script. Both approaches are valid:

---
// src/pages/index.astro
---
<aktion-app id="response" theme="light"></aktion-app>

<script>
  import "aktion-runtime";
  const el = document.getElementById("response") as any;
  el.setResponse(`_app_ = Stack([Card([CardHeader("Hello, Astro")])])`);
</script>

Plain HTML

No build stepWorks offline

The CDN build is the smallest possible footprint — no bundler, no framework, just one script tag.

<!doctype html>
<html>
  <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(`_app_ = Stack([Card([CardHeader("Hello, world")])])`);
    </script>
  </body>
</html>

Common patterns across frameworks

Streaming an LLM response

Set streaming = true while you append tokens, then flip it back when the stream ends. Internally the renderer suppresses transient parse errors while the flag is on.

el.streaming = true;
el.clear();
for await (const chunk of stream) el.appendChunk(chunk);
el.streaming = false;

Reacting to follow-up clicks

Buttons without an explicit action: automatically dispatch an assistant-message event whose event.detail.message is the button label (or the message: prop, when supplied). Hook it into your chat input.

el.addEventListener("assistant-message", (event) => {
  sendToLLM(event.detail.message);
});

Exposing tools to the script

Tools are async functions registered on the host. They’re available to js{} blocks inside actions and effects as ctx.tools.<name>(args).

el.setTools({
  list_orders: async ({ limit }) => (await fetch(`/api/orders?limit=${limit}`)).json(),
  update_order: async ({ id, status }) => {
    await fetch(`/api/orders/${id}`, { method: "PATCH", body: JSON.stringify({ status }) });
    return { ok: true };
  },
});

HTTP interceptors

Hook every http({ ... }) call inside the script — attach auth headers, log requests, fix base URLs.

el.registerHttpInterceptors({
  onRequest: (req) => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } }),
  onResponse: (res) => res,
  onError:    (err) => err,
});

Reacting to navigation

Every router transition fires a route-change event with the new path, the previous path, and the navigation source ("init", "hashchange", "navigate", or "external") — useful for syncing analytics or your own sidebar.

el.addEventListener("route-change", (event) => {
  analytics.track("page-view", event.detail);
  // event.detail = { path, previousPath, source }
});

Generating the system prompt

Either fetch the bundled file or build it programmatically — both return identical text:

// 1. Fetch the canned prompt (full reference)
const promptText = await fetch("https://asfand-dev.github.io/aktion/dist/system_prompt.txt").then(r => r.text());

// Or fetch the compact chat-focused variant
const chatPromptText = await fetch("https://asfand-dev.github.io/aktion/dist/system_prompt_chat.txt").then(r => r.text());

// 2. Generate at runtime (lets you add custom rules or component metadata)
import { SYSTEM_PROMPT_TEXT, generatePrompt, defaultLibrary } from "aktion-runtime";
const same   = SYSTEM_PROMPT_TEXT;
const custom = generatePrompt(defaultLibrary, {
  preamble: "You are an analytics assistant.",
  additionalRules: ["Always end with a FollowUpBlock."],
});

Need a different framework?

The element relies only on the Custom Elements v1 API and ES2020 features. Anywhere you can render HTML and run JavaScript in a modern browser, the bundle just works.