Integration

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>.

Live
$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:

  1. Register the element by importing the bundle once in client‑side code — import "aktion-runtime"; defines the <aktion-app> custom element on the page.
  2. Give it a program. Either set the response attribute (response="$app(…)") or call setResponse(text) on the element. To stream tokens from an LLM, set the streaming attribute and call appendChunk(text) as chunks arrive, then drop streaming when the stream ends.
  3. Listen for events. The element emits standard bubbling, composed CustomEvents: assistant-message (a follow‑up message, on event.detail.message), route-change (the route changed — event.detail is { path, previousPath, source }), and error — parse failures, src fetch/link failures, and runtime‑budget aborts, delivered on event.detail.errors as an array of { line, column, message }. While the streaming attribute is set, error dispatch 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:

AttributeValuesEffect
responseprogram textThe program to render. Mirrored by the response property and setResponse().
srcURL or pathFetch the entry .aktion file, link its whole import graph, and mount it. Resolved against the document.
themelight, dark, shadcn, shadcn-dark, mui, mui-dark, heroui, heroui-dark, signal, signal-dark, softSelects a built‑in theme. The bare framework names also answer to an explicit -light spelling.
streamingbooleanSuppresses the error banner on transient mid‑line parse errors while appendChunk() feeds the element.
showerrorsbooleanRenders a developer error banner inside the shadow DOM.
dirltr / rtl / autoFlips the whole rendered tree for right‑to‑left.
marginnumber or CSS lengthOuter 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 pointWhat it is
aktion-runtimeThe runtime, the component library, the themes, and <aktion-app>. Import it once in client code.
aktion-runtime/viteThe Vite/Rollup plugin for .aktion files — Node only, never in the browser bundle. See Modules → Building with the Vite plugin.
aktion-runtime/testrender, act, waitFor, axe, … for component tests. See Testing.
aktion-runtime/devtoolsmountDevtools() and the devtools element. See DevTools.
aktion-runtime/languageEditor services — diagnostics, completions, hover, formatting. What the VS Code extension is built on.
aktion-runtime/aktion-modulesTypes 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(&quot;Hi&quot;))"></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