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:
- Import the bundle once in client-side code so it registers the custom element.
- Render
<aktion-app>wherever you want the LLM output to appear. - Get a reference to the element and call
setResponse(),appendChunk(), or settheme.
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 (< 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)
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
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 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
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 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
// 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 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
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.