Frameworks.
<aktion-app> is a
framework‑agnostic
web component
— drop it into any app, feed it a program string, and listen to
its events. There is no framework‑specific build of Aktion:
it is one custom element that renders the same way inside React,
Vue, Svelte, Angular, or a bare HTML page.
What it renders
Whatever framework you wire it into, the element does one thing: it
takes a program string and renders the live UI inside its shadow DOM.
Here is a complete program and its output — this is exactly what
appears wherever you place <aktion-app>.
$app(Column([
Card([
CardHeader("Welcome to Aktion", { subtitle: "One web component, every framework" }),
Text("Feed this element a program string and it renders the UI for you."),
Row([
Badge("Framework-agnostic", { tone: "primary" }),
Spacer(),
Button("Get started", { variant: "primary", icon: "arrow-right" })
], { gap: "sm" })
])
], { gap: "lg" }))
The contract
Every integration comes down to the same three steps, regardless of framework:
-
Register the element by importing the bundle
once in client‑side code —
import "aktion-runtime";defines the<aktion-app>custom element on the page. -
Give it a program. Either set the
responseattribute (response="$app(…)") or callsetResponse(text)on the element. To stream tokens from an LLM, set thestreamingattribute and callappendChunk(text)as chunks arrive, then dropstreamingwhen the stream ends. -
Listen for events. The element emits standard
bubbling, composed
CustomEvents:assistant-message(a follow‑up message, onevent.detail.message),route-change(the route changed —event.detailis{ path, previousPath, source }), anderror— parse failures,srcfetch/link failures, and runtime‑budget aborts, delivered onevent.detail.errorsas an array of{ line, column, message }. While thestreamingattribute is set,errordispatch is deferred, because a half‑received chunk is nearly always mid‑token. A program can also raise its own with$emit("name", detail)— those are dispatched from the same element, bubbling and composed, so the host listens for them exactly the same way.
Every event the element dispatches is composed: true, so they cross
the shadow boundary and you can listen on any ancestor instead of on the element
itself — useful when a framework owns the element’s lifecycle.
Other useful methods on the element: setTheme(name | tokens),
registerComponents([…]) to extend the library,
registerHttpInterceptors({ onRequest, onResponse, onError })
for host‑wide auth/logging (calls merge incrementally, so passing only
{ onRequest } does not clear an existing onResponse),
mountCompiled(program, state?) for an already‑linked program,
and getSystemPrompt() to build the LLM system prompt for the active
component set.
Seven attributes are observed, so changing any of them after mount takes effect immediately:
| Attribute | Values | Effect |
|---|---|---|
response | program text | The program to render. Mirrored by the response property and setResponse(). |
src | URL or path | Fetch the entry .aktion file, link its whole import graph, and mount it. Resolved against the document. |
theme | light, dark, shadcn, shadcn-dark, mui, mui-dark, heroui, heroui-dark, signal, signal-dark, soft | Selects a built‑in theme. The bare framework names also answer to an explicit -light spelling. |
streaming | boolean | Suppresses the error banner on transient mid‑line parse errors while appendChunk() feeds the element. |
showerrors | boolean | Renders a developer error banner inside the shadow DOM. |
dir | ltr / rtl / auto | Flips the whole rendered tree for right‑to‑left. |
margin | number or CSS length | Outer spacing around the app shell (margin="12" → 12px; default 20px). Set margin="0" to reach the container edges. |
Two more attributes are read when they are needed rather than observed, so set
them in your markup: scroll-restoration
("auto" / "top" — manage window scroll
across in‑app navigation; absent means no scroll management) and
strict, which arms extra development diagnostics.
The precedence for the program itself is response (attribute,
property, or setResponse()), then src, then the
element’s own inner text. Fetch and link failures surface through the same
error banner and error event as parse errors.
Server‑side rendering. For SSR/SSG, import
renderToString(program, opts) from aktion-runtime.
It returns { html, state, head, headAttrs }: put
html in your page shell, head inside
<head>, spread headAttrs onto
<html>, and serialise state for client
hydration. The renderer needs a DOM, so in Node register
happy-dom / jsdom on globalThis first.
See Performance → SSR and
Head & SEO.
Installing the bundle
The package is published to npm as aktion-runtime; the same bundle is served from the docs CDN for no‑build pages:
# npm (works with any bundler)
npm install aktion-runtime
# Then, once in your client-side entry point:
import "aktion-runtime";
# Or load from the CDN with no build step:
# <script type="module" src="https://asfand-dev.github.io/aktion/dist/aktion.js"></script>
The bare specifier is the browser runtime; the package also publishes side entries you may need during integration:
| Entry point | What it is |
|---|---|
aktion-runtime | The runtime, the component library, the themes, and <aktion-app>. Import it once in client code. |
aktion-runtime/vite | The Vite/Rollup plugin for .aktion files — Node only, never in the browser bundle. See Modules → Building with the Vite plugin. |
aktion-runtime/test | render, act, waitFor, axe, … for component tests. See Testing. |
aktion-runtime/devtools | mountDevtools() and the devtools element. See DevTools. |
aktion-runtime/language | Editor services — diagnostics, completions, hover, formatting. What the VS Code extension is built on. |
aktion-runtime/aktion-modules | Types only. Reference it so TypeScript knows what import app from "./app.aktion" is. |
aktion-runtime/style.css and the two
system_prompt*.txt files are exported too. You rarely need the
stylesheet — the element injects its own into its shadow root.
Program trust & host globals
Before you wire anything up, decide how much you trust the program text. Inside
an Aktion program an unshadowed identifier falls through to the host page’s
globals, so a program is as privileged as a
<script> tag in your app.
That is the right default when the program ships in your repo. When it arrives from an LLM, a tenant database, or a user-editable template, narrow the surface:
import { setGlobalAccessPolicy, getGlobalAccessPolicy } from "aktion-runtime";
setGlobalAccessPolicy("safe"); // data, formatting and encoding only
setGlobalAccessPolicy(["btoa", "URL"]); // ...or name exactly what you allow
getGlobalAccessPolicy(); // → "all" (the default) | "safe" | string[]
Two things make the placement matter. It is process‑global, not
per‑<aktion-app> — one setting governs every
program on the page, and the last call wins. And it only affects identifier
resolution as a program runs, so it must be in place before the first
mount.
So call it once in your bootstrap module, above the code that renders. Each framework snippet below marks the spot.
"safe" is a narrowing, not a sandbox. It removes
eval, Function, the DOM, bare fetch and
storage from a program’s reach — but $http and
storage stay available by design, so restrict origins at the host
too, with registerHttpInterceptors and a
connect-src policy.
For genuinely untrusted program text, also host the app in a cross‑origin sandboxed iframe. Security has the full allow‑list, what breaks under it, and the residual risks.
Framework integrations
Pick your framework below for a copy‑paste integration. Every
snippet does the same three things from the contract above: register the
bundle once, hand <aktion-app> a program, and listen
for its events.
React
In JSX you write the custom element tag directly:
<aktion-app />. Import the bundle once (anywhere in
client code) so the element is registered, then use a
ref plus a useEffect to push the program in
with setResponse and to subscribe to events.
Put setGlobalAccessPolicy in the app entry — module
top‑level, so it runs before React renders anything. It is
process‑global, so once here covers every
<aktion-app> in the tree; calling it inside a component
body or an effect would let the first mount run under the old policy.
// main.tsx — the app entry
import { createRoot } from "react-dom/client";
import { setGlobalAccessPolicy } from "aktion-runtime";
import App from "./App";
// Once, before the first render. Skip this line to keep the default "all".
setGlobalAccessPolicy("safe");
createRoot(document.getElementById("root")!).render(<App />);
// AktionView.tsx
import { useEffect, useRef } from "react";
import "aktion-runtime"; // registers <aktion-app> once
import type { AktionElement } from "aktion-runtime";
export function AktionView({ program }: { program: string }) {
const ref = useRef<AktionElement | null>(null);
// Push the program in whenever it changes.
useEffect(() => {
ref.current?.setResponse(program);
}, [program]);
// Listen for the element's CustomEvents.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onMessage = (e: Event) => {
console.log("follow-up:", (e as CustomEvent).detail.message);
};
el.addEventListener("assistant-message", onMessage);
return () => el.removeEventListener("assistant-message", onMessage);
}, []);
return <aktion-app ref={ref} theme="light" />;
}
Because setResponse() returns early when the text is unchanged,
re‑running that first effect is cheap — React may invoke it twice in
development and the second call is a no‑op. Importing the
AktionElement type instead of hand‑rolling
HTMLElement & { setResponse(…) } gets you every method on the
element, not just the one you remembered.
Add the JSX intrinsic type once so TypeScript accepts the tag:
// aktion.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from "react";
declare global {
namespace JSX {
interface IntrinsicElements {
"aktion-app": DetailedHTMLProps<
HTMLAttributes<HTMLElement> & { theme?: string; response?: string; streaming?: boolean },
HTMLElement
>;
}
}
}
Vue 3
Tell the Vue compiler that aktion-app is a custom element
so it stops trying to resolve it as a Vue component — set
app.config.compilerOptions.isCustomElement (or the
equivalent option in your build tool's Vue plugin). Then bind the
program with :response, or grab a ref and call
setResponse.
// main.ts
import { createApp } from "vue";
import App from "./App.vue";
import "aktion-runtime"; // registers <aktion-app> once
import { setGlobalAccessPolicy } from "aktion-runtime";
// Once, before `app.mount(...)`. Process-global: one call covers every
// <aktion-app> in the app. Omit it to keep the default "all" policy.
setGlobalAccessPolicy("safe");
const app = createApp(App);
// Treat any <aktion-app> tag as a native custom element.
app.config.compilerOptions.isCustomElement = (tag) => tag === "aktion-app";
app.mount("#app");
isCustomElement is a compiler option, so it only applies to
templates your build compiles. If a template is compiled at runtime, set the
same option on the runtime compiler, or Vue logs a
“failed to resolve component” warning and renders nothing.
<!-- App.vue -->
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import type { AktionElement } from "aktion-runtime";
const program = ref(`$app(Card([CardHeader("Hello from Vue")]))`);
// Simplest case: bind it directly.
// <aktion-app :response="program" theme="light" />
// Or use a ref for the imperative API + events:
const el = ref<AktionElement | null>(null);
onMounted(() => {
el.value?.setResponse(program.value);
el.value?.addEventListener("route-change", (e) =>
console.log((e as CustomEvent).detail),
);
});
watch(program, (next) => el.value?.setResponse(next));
</script>
<template>
<aktion-app ref="el" theme="light" />
</template>
:response works either way Vue chooses to write it: the element
exposes response as an accessor whose setter forwards to
setResponse(), and it also observes the
response attribute. Reach for the ref when you need
the rest of the API — appendChunk(),
setTheme(), or the events.
Svelte
Svelte supports custom elements natively, so there is no configuration
step — just import the bundle and use the tag. Pass the program
with the response attribute and subscribe to events with
Svelte's on: directive (it maps straight to
addEventListener, which is what you want for hyphenated custom
event names).
Set the access policy in the client entry, not in a component. A component
module runs the first time that component is imported, which is not reliably
before the first <aktion-app> mounts; the policy is
process‑global, so one call in the entry is both sufficient and correctly
ordered.
// src/main.ts — or +layout.ts / hooks.client.ts in SvelteKit
import { setGlobalAccessPolicy } from "aktion-runtime";
// Once, before any component mounts. Omit to keep the default "all".
setGlobalAccessPolicy("safe");
<!-- AktionView.svelte -->
<script lang="ts">
import "aktion-runtime"; // registers <aktion-app> once
export let program: string = `$app(Card([CardHeader("Hello from Svelte")]))`;
function onAssistantMessage(e: CustomEvent) {
console.log("follow-up:", e.detail.message);
}
</script>
<aktion-app
response={program}
theme="light"
on:assistant-message={onAssistantMessage}
on:route-change={(e) => console.log(e.detail)}
/>
Because the tag is a plain custom element, response={program}
re‑renders whenever program changes — no watcher
needed. For server‑rendered routes (SvelteKit), guard the import so it
only runs in the browser, e.g.
if (browser) await import("aktion-runtime");, since defining a
custom element requires customElements.
Angular
Add CUSTOM_ELEMENTS_SCHEMA to the component (or module) so
Angular's template compiler accepts the unknown tag, import the bundle
once, and bind the program with the [attr.response]
attribute binding.
Call setGlobalAccessPolicy in main.ts, above
bootstrapApplication. It is process‑global rather than
per‑<aktion-app>, so it does not belong in a provider or a
component constructor — there is nothing per‑instance to configure,
and a constructor runs too late for the first mount.
// main.ts
import "aktion-runtime"; // registers <aktion-app> once
import { setGlobalAccessPolicy } from "aktion-runtime";
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
// Once, before bootstrap. Omit to keep the default "all" policy.
setGlobalAccessPolicy("safe");
bootstrapApplication(AppComponent);
// app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
@Component({
standalone: true,
selector: "app-root",
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<aktion-app
[attr.response]="program"
theme="light"
(assistant-message)="onMessage($event)"
(route-change)="onRoute($event)"
(error)="onError($event)">
</aktion-app>
`,
})
export class AppComponent {
program = `$app(Card([CardHeader("Hello, Angular")]))`;
onMessage(event: Event) {
console.log("follow-up:", (event as CustomEvent).detail.message);
}
onRoute(event: Event) {
console.log("route:", (event as CustomEvent).detail);
}
onError(event: Event) {
console.warn("aktion errors:", (event as CustomEvent).detail.errors);
}
}
Angular's () syntax binds by event name, so the hyphenated
assistant-message and route-change names work
unchanged — do not camel‑case them. Note that (error)
also fires for a failed src fetch or link, not only for parse
errors, and that a static theme="light" needs no binding at all.
Vanilla HTML
No build step required. A single module‑type script tag imports
the bundle (registering the element); then place the
<aktion-app> tag and call setResponse.
<!doctype html>
<html>
<body>
<aktion-app theme="light"></aktion-app>
<script type="module">
import { setGlobalAccessPolicy } from "https://asfand-dev.github.io/aktion/dist/aktion.js";
// Once, before the first setResponse. Omit to keep the default "all".
setGlobalAccessPolicy("safe");
const el = document.querySelector("aktion-app");
el.setResponse(`$app(Card([CardHeader("Hello, world")]))`);
el.addEventListener("assistant-message", (e) => {
console.log("follow-up:", e.detail.message);
});
</script>
</body>
</html>
Importing any named export from the bundle registers the element as a side
effect, so there is no separate import "…" line to remember.
The policy call goes above setResponse() — it is
process‑global and only affects programs that run after it.
You can also set the program declaratively with the
response attribute —
<aktion-app response="$app(Text("Hi"))"></aktion-app>
— though the imperative setResponse is easier for
anything longer than a line.
For anything larger, keep the program in its own file and load it with the
src attribute —
<aktion-app src="./app.aktion"></aktion-app>. The file is
resolved relative to the page and linked through the in‑browser project
linker, so an entry that imports other modules fetches its whole
graph on connect.
Next
Get started
Install the bundle, render your first program, and wire up streaming end to end.
Get started → ReferenceLanguage
State, expressions, control flow, and the rules behind component calls.
View reference → ReferenceComponent catalog
Every component, prop, and a live preview — the building blocks for your programs.
Browse components →