Guide

TypeScript guide.

The .aktion DSL itself is untyped, but everything around it — the host element, custom components, interceptors, and event payloads — ships full type declarations. This guide shows how to wire Aktion into a typed host with no any leaks.

Scope. Types cover the integration surface (the code you write in TypeScript around <aktion-app>). The program text the runtime renders is a JS-subset DSL and is not type-checked — see Language.

Install & tsconfig

Install the runtime and the declarations come with it — there is no separate @types package to add, and no build step to run:

npm install aktion-runtime
# types ship in the package; there is no @types/aktion-runtime

That one dependency carries the custom element, the 282-component library, the SSR helpers, and every type on this page. What it will not do is resolve under a legacy module-resolution mode, because the package publishes its subpaths through an exports map:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",           // or "NodeNext", with "module": "NodeNext"
    "lib": ["ES2022", "DOM", "DOM.Iterable"], // DOM is required — see the table
    "strict": true
  }
}

Those are the settings this package’s own build uses, and they are the smallest set that makes every entry point type-check. Only three of them actually matter:

SettingValueWhy
moduleResolution"bundler", "node16", or "nodenext"Required for the subpaths. Under the legacy "node" (node10) algorithm TypeScript ignores exports entirely: aktion-runtime still resolves through the package’s types field, but /test, /devtools, /language, /vite, and /aktion-modules all fail.
module"esnext" / "preserve" with bundler; "node16" / "nodenext" with those resolversTypeScript enforces the pairing itself: bundler with module: "commonjs" is rejected as TS5095 before it ever looks at an import.
libmust include "DOM"The declarations reference HTMLElement and NodeAktionElement extends HTMLElement, and a component’s render returns a Node. Without DOM you get TS2304 from inside dist/types. skipLibCheck: true hides those errors without fixing your own call sites.
stricttrueOptional, but free: the shipped declarations are strict-clean, so nothing on this page needs a loosened flag.

The error you get from the wrong resolver

TypeScript names the fix for you. With "moduleResolution": "node", importing aktion-runtime/test fails with TS2307 Cannot find module 'aktion-runtime/test' or its corresponding type declarations, followed by “There are types at …dist/types/testing/index.d.ts, but this result could not be resolved under your current 'moduleResolution' setting.” If you see that, change the resolver — do not add a path mapping.

Typed .aktion imports

The Vite plugin lets you import a .aktion file directly. Add one reference line to any .d.ts and those imports stop being any:

/// <reference types="aktion-runtime/aktion-modules" />

With that in place, import app from "./app.aktion" is typed as the CompiledProgram the plugin emits — exactly what element.mountCompiled(app) expects. The /aktion-modules entry is declarations only; there is nothing to import from it at runtime.

Package entry points

The package exposes typed subpath entries. Import from the one that matches your task — splitting them is what keeps a CI lint step from pulling in the DOM:

ImportProvides
aktion-runtimeThe runtime: registers <aktion-app> on import, plus registerComponents, the themes, the SSR helpers, and every core type.
aktion-runtime/testThe Testing-Library-style API (render, renderComponent, within, axe, fetch mocks). See Testing.
aktion-runtime/devtoolsThe DevTools backend and in-page panel. See DevTools.
aktion-runtime/languageDOM‑free language + tooling surface: diagnostics, completions, hover, navigation, semantic tokens, signature help, formatProgram.
aktion-runtime/viteThe Vite plugin for .aktion files (default export).
aktion-runtime/aktion-modulesTypes only. Ambient declare module "*.aktion" declarations — see above.
aktion-runtime/style.cssThe base stylesheet, for hosts that render outside the shadow root.
aktion-runtime/system_prompt.txt
aktion-runtime/system_prompt_chat.txt
The full and chat-sized system prompts as plain text files, for build steps that read them from disk. From a program, prefer element.getSystemPrompt().

Only aktion-runtime and aktion-runtime/vite ship a CommonJS build; the rest are ESM‑only.

The public types

These are the declarations a host integration actually names. Everything else in the bundle is reachable, but this table is the surface worth importing deliberately:

TypeFromWhat it is
AktionElementaktion-runtimeThe <aktion-app> class. Exported as a value too, so instanceof works.
ComponentSpecaktion-runtime{ name, description, props, render } — one custom built‑in. Pass an array to registerComponents.
PropSpecaktion-runtimeOne entry in a spec’s props. See the field table below.
RenderHelpersaktion-runtimeThe third argument handed to render: renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, router.
ComponentLibraryaktion-runtime{ root, components }. defaultLibrary is one; the language service takes one as an argument.
HttpInterceptorsaktion-runtimeThe onRequest / onResponse / onError bag — see Interceptors.
HttpRequest · HttpResponseaktion-runtimeWhat those hooks receive and return.
ThemeTokensaktion-runtimeEvery themeable token as a flat, all‑string record (colorPrimary, radiusButton, chart1…). ThemeInput = string | Partial<ThemeTokens> is what setTheme accepts.
ResolvedThemeaktion-runtime{ name, tokens } — the output of resolveTheme. See Themes.
GlobalAccessPolicyaktion-runtime"all" | "safe" | readonly string[] — which host globals a program may reach. See Security.
RenderToStringOptions
RenderToStringResult
aktion-runtimeSSR input and output — see SSR.
CompiledProgramaktion-runtimeThe linker artefact from linkProject / compileLite, and what mountCompiled takes.
PromptOptionsaktion-runtimeOptions for getSystemPrompt / generatePrompt (mode, preamble, tools, …).
Diagnostic · Position
CompletionItem · HoverInfo
FormatResult
aktion-runtime/languageThe editor-tooling shapes — see Language surface.
Screen · RenderOptions
A11yViolation
aktion-runtime/testWhat render() returns, what it accepts, and what axe() reports.

GlobalAccessPolicy is the one worth reaching for early. If the program text can come from somewhere you do not fully trust, narrow the global surface before the first render:

import { setGlobalAccessPolicy } from "aktion-runtime";
import type { GlobalAccessPolicy } from "aktion-runtime";

const policy: GlobalAccessPolicy = "safe"; // "all" is the default — full globalThis
setGlobalAccessPolicy(policy);             // data + formatting + encoding only

"safe" drops code execution, DOM, network, and storage from the passthrough; an explicit array grants exactly the names you list and nothing else. Security owns the full trust model.

Typing the host element

<aktion-app> is a custom element, and the package augments HTMLElementTagNameMap for it. That means you do not need a cast — both querySelector and createElement already return AktionElement:

import "aktion-runtime"; // side-effect import: defines the element

const app = document.querySelector("aktion-app"); // AktionElement | null — no cast
app?.setResponse(source);      // replace the whole program
app?.appendChunk(tokenDelta);  // append a streaming tail
app?.setTheme("mui-dark");     // a built-in name, or a Partial<ThemeTokens> map

Import the package for its side effect before you query, or the element is still an HTMLUnknownElement at runtime. The methods and accessors you are most likely to reach for:

MemberSignaturePurpose
setResponse(text: string) => voidReplace the program and re-render from scratch.
appendChunk(chunk: string) => voidAppend a streaming delta. Coerces defensively, so a decoder result is fine.
clear() => voidDrop the program and the rendered tree.
responsestring (get/set)Property form of setResponse.
streamingboolean (get/set)While true, transient parse errors are suppressed instead of dispatched.
showErrorsboolean (get/set)Show or hide the built-in error banner.
setTheme(theme: ThemeInput) => voidApply a built-in theme name or a flat token map.
registerComponents(components: ComponentSpec[], rootName?: string) => voidAdd custom built‑ins; the next getSystemPrompt() advertises them.
registerIcons(icons: Record<string, string>) => voidRegister inline SVG by name, usable anywhere a Font Awesome name is.
registerHttpInterceptors(interceptors: HttpInterceptors) => voidHost‑wide HTTP hooks. Calls merge incrementally.
getSystemPrompt(options?: PromptOptions) => stringBuild the prompt for the active library. { mode: "chat" } for the compact variant.
navigate · route(path: string) => void · string (get)Drive and read the in-app router.
serializeState() => Record<string, unknown>Snapshot reactive state as plain JSON-friendly values.
hydrateState(snapshot: Readonly<Record<string, unknown>>) => voidApply a snapshot; later $state defaults do not overwrite it.
loadSnapshot(payload: { programText: string; state: Record<string, unknown> }) => voidSet program and state atomically — the SSR-hydration entry point.
applyDelta(ops: readonly DeltaOp[]) => string[]Patch the running program; returns advisory warnings.
mountCompiled(compiled: CompiledProgram, state?: Record<string, unknown>) => voidMount a linked multi-file program, skipping the runtime parser.
src · loadFromSrc · sourceIdstring | null · (src: string) => Promise<void> · string | nullLoad a .aktion entry over the network and report which module is mounted.
connectDevtools() => voidAttach to a DevTools hook installed after this element mounted. Idempotent.

DeltaOp is not re-exported

Pass a literal array and let inference do the work. applyDelta is typed against DeltaOp, but that union is not exported from the root entry, so you cannot annotate the variable. An inline literal still checks against every op shape:

const warnings = app.applyDelta([
  { kind: "replace", binding: "count", source: "$count = 5" },
]); // string[] — advisory notes for ops that became no-ops

The other op kinds are patch ({ target, value }), append ({ binding, item }), new ({ source }), and delete ({ binding }). A partial delta always mounts the rest of the patched program rather than throwing.

Host event payloads

The element dispatches three built-in CustomEvents, plus whatever a program raises with $emit. All of them are bubbles: true and composed: true, so a listener on an ancestor sees them:

EventdetailFires when
assistant-message{ message: string }The program asked the host to send a message back to the model.
route-change{ path: string; previousPath: string | null; source: "init" | "hashchange" | "navigate" | "external" }The router committed a navigation. source tells you whether it came from the URL, a navigate() call, or the initial mount.
error{ errors: { line: number; column: number; message: string }[] }Parse, src‑load, or runtime‑budget failures. Not dispatched while streaming is true — a mid-token chunk is expected to be invalid.
your own namewhatever $emit passedAn action or effect called $emit("name", detail). assistant-message, error, and route-change are reserved — see Actions.

The package does not ship an event map, because the fourth row is open‑ended. Declare one for the events your host cares about and hang a small generic helper off it — that is the single place a cast is needed, and you write it once:

interface AktionEventMap {
  "assistant-message": { message: string };
  "route-change": {
    path: string;
    previousPath: string | null;
    source: "init" | "hashchange" | "navigate" | "external";
  };
  "error": { errors: { line: number; column: number; message: string }[] };
  // add your own $emit names here
}

function onAktion<K extends keyof AktionEventMap>(
  el: EventTarget,
  type: K,
  handler: (detail: AktionEventMap[K]) => void,
): () => void {
  const listener = (e: Event) => handler((e as CustomEvent<AktionEventMap[K]>).detail);
  el.addEventListener(type, listener);
  return () => el.removeEventListener(type, listener); // call to unsubscribe
}

Every call site now gets autocompletion on the event name and a correctly-typed detail, with the returned function as the unsubscribe:

const off = onAktion(app, "assistant-message", (detail) => sendToModel(detail.message));
onAktion(app, "route-change", (detail) => analytics.page(detail.path, detail.source));
onAktion(app, "error", (detail) => detail.errors.forEach(reportError));

off(); // detach the assistant-message listener

Typing custom components

A custom built‑in is a ComponentSpec. Its render is (node, props, helpers) => Node: props arrives as Record<string, unknown> because the DSL is untyped, so narrow each value as you read it.

import "aktion-runtime";
import type { ComponentSpec } from "aktion-runtime";

const SVG_NS = "http://www.w3.org/2000/svg";

export const Sparkline: ComponentSpec = {
  name: "Sparkline",
  description: "Inline trend line for a series of numbers.",
  props: [
    { name: "values", type: "number[]", positional: true, required: true, description: "The series to plot" },
    { name: "stroke", type: "string", optional: true, description: "Line colour (default: the theme primary token)" },
  ],
  render: (_node, props) => {
    const values = (props.values as number[] | undefined) ?? [];
    const stroke = (props.stroke as string | undefined) ?? "var(--rui-color-primary)";
    // createElement("svg") builds an HTMLUnknownElement that never paints — SVG needs the namespace.
    const svg = document.createElementNS(SVG_NS, "svg");
    svg.setAttribute("viewBox", "0 0 100 24");
    svg.setAttribute("role", "img");
    const max = Math.max(1, ...values);
    const step = 100 / Math.max(1, values.length - 1);
    const line = document.createElementNS(SVG_NS, "polyline");
    line.setAttribute("fill", "none");
    line.setAttribute("stroke", stroke);
    line.setAttribute("stroke-width", "2");
    line.setAttribute("points", values.map((v, i) => `${i * step},${24 - (v / max) * 24}`).join(" "));
    svg.append(line);
    return svg;
  },
};

const app = document.querySelector("aktion-app")!;
app.registerComponents([Sparkline]); // the next getSystemPrompt() advertises it

Reading a theme token through var(--rui-color-primary) rather than a literal colour is what keeps a custom component in step with whichever theme the host applied.

The props array is a PropSpec[]. Every field is consumed by three different systems — the evaluator (argument binding), the validator (unknown-prop errors), and the prompt generator — so filling them in is not documentation, it is behaviour:

FieldTypeEffect
namestringRequired. The key your render reads from props.
typestringRequired. A documentation-and-binding hint ("string", "number", "boolean", "callable", "Node", "Node[]", or a free-form label like "number[]").
positionalbooleanAt most one prop per spec. Marks the slot a bare first argument lands in. A spec with no marker falls back to “first prop is positional”.
requiredbooleanMarker only — the prompt generator renders it without a ?. Not enforced at runtime.
optionalbooleanThe opposite marker, used the same way.
enumreadonly string[]Accepted values. The validator rejects anything else, and the prompt lists them.
aliasesreadonly string[]Extra named-argument spellings that route to this same slot — how tone and variant coexist across the library.
descriptionstringGoes into the generated system prompt verbatim. This is what the model reads.

The helpers argument is fully typed too — renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, and router all carry signatures, so authoring a spec stays type-safe end to end.

Typing HTTP interceptors

Register host‑wide HTTP hooks with registerHttpInterceptors(interceptors). They fire around every $http / $query / $mutation request the program makes, which makes them the right place for auth headers and centralised logging:

HookSignatureContract
onRequest(request: HttpRequest) => HttpRequest | Promise<HttpRequest>Return the request to send — usually a spread copy with extra headers.
onResponse(response: HttpResponse, retry: () => Promise<HttpResponse>) => HttpResponse | Promise<HttpResponse>Inspect or replace the response. Call retry() to re-issue the same request once — the token-refresh hook.
onError(error: unknown, request: HttpRequest) => voidObservation only. The return value is ignored; the error still propagates to the program.
import type { HttpInterceptors } from "aktion-runtime";

const interceptors: HttpInterceptors = {
  onRequest: (req) => ({
    ...req,
    headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
  }),
  onResponse: async (res, retry) => (res.status === 401 ? retry() : res),
  onError: (err, req) => console.error("[http]", req.url, err),
};

app.registerHttpInterceptors(interceptors); // merges with anything already registered

Merging is incremental, so a later call passing only onRequest never clears an existing onResponse. HttpRequest is { url, method, headers, body?, signal?, init? } and HttpResponse is { status, headers, body }init is the passthrough for any extra fetch option. See HTTP for the program-side view.

The language & tooling surface

aktion-runtime/language is DOM‑free: it parses, validates, formats, and answers editor questions about program text without touching the renderer. Use it for a CI lint step, a Monaco/LSP integration, or a pre-commit formatter.

import { getDiagnostics, formatProgram, defaultLibrary } from "aktion-runtime/language";
import type { Diagnostic, FormatResult } from "aktion-runtime/language";

const diagnostics: Diagnostic[] = getDiagnostics(source, defaultLibrary);
const blocking = diagnostics.filter((d) => d.severity === "error");

const result: FormatResult = formatProgram(source);
if (result.errors.length === 0) writeFileSync(path, result.formatted);

Note the second argument: getDiagnostics, getCompletions, and getHoverInfo all take a ComponentLibrary, because what counts as an unknown component depends on which one is active. Pass defaultLibrary, or the result of mergeLibraries when the host registers custom specs. Formatting is a no‑op when errors is non-empty, so guard on it before overwriting a file.

ExportSignatureReturns
getDiagnostics(source, library) => Diagnostic[]Parse + schema errors, each with line, column, message, severity.
getLintWarnings(source) => Diagnostic[]Advisory-only warnings; already folded into getDiagnostics.
getCompletions(source, position, library) => CompletionItem[]Completions at a 1‑indexed { line, column }.
getHoverInfo(source, position, library) => HoverInfo | null{ contents, kind } for the symbol under the cursor.
getSignatureHelp(source, position, library?) => SignatureHelp | nullActive parameter inside a component call.
formatProgram(source) => FormatResult{ formatted, errors }. Idempotent.
getSemanticTokens(source, library?) => SemanticToken[]Editor highlighting, with semanticTokenTypes / semanticTokenModifiers legends.
navigation setgetDefinition, getReferences, getDocumentSymbols, getDocumentHighlights, getRenameEditsGo‑to‑definition, outline, and rename edits over one program.
getLanguageSpec() => LanguageSpecThe machine-readable grammar, keyword docs, builtin catalog, and snippets.

SSR, importer & DX exports

The main entry also carries server‑rendering and authoring‑tool helpers, all fully typed:

ExportSignaturePurpose
renderToString(program, opts?) => { html, state, head, headAttrs }SSR — markup, a hydration snapshot, and the $head output.
renderToStaticMarkup(program, opts?) => stringSSG — the html field only.
renderToTextTree(program, opts?) => { text, errors, ok }An indented outline of the rendered tree, for snapshot tests and LLM feedback loops.
htmlToAktion(html) => stringImporter — map common HTML/JSX tags to an Aktion program.
tailwindToSx(classString) => objectMap Tailwind utilities (incl. arbitrary values like w-[327px]) to an sx object; misses land in _unmapped.
cssToSx(cssText) => objectMap a raw CSS declaration string or rule to an sx object.
styledToSx(template) => objectExtract static declarations from a styled‑components / emotion template.
componentSchema(library) => LibrarySchemaStable JSON of every component’s props/types/enums for editor autocomplete. The library argument is required — pass defaultLibrary.
buildGallery(library) => stringSelf‑contained HTML component gallery (Storybook‑style explorer).
suggestComponent(name, library) => string[]“Did you mean” suggestions for an unknown component.

renderToString returns four fields, not two. Dropping head and headAttrs is what silently loses your <title>, meta tags, and Open Graph cards from the served page:

import { renderToString } from "aktion-runtime";

const { html, state, head, headAttrs } = renderToString(source, { path: "/" });

const attrs = Object.entries(headAttrs).map(([k, v]) => `${k}="${v}"`).join(" ");
const page = `<!doctype html>
<html ${attrs}>
  <head>${head}</head>
  <body>
    ${html}
    <script type="application/json" id="aktion-state">${JSON.stringify(state)}</script>
  </body>
</html>`;

Ship state to the browser and hand it to element.loadSnapshot({ programText, state }) so hydration starts from the server’s values. Head owns the head / headAttrs fields; Deployment covers the serving side.

RenderToStringOptionsTypeDefault
libraryComponentLibrarydefaultLibrary
pathstring"/" — the in-memory router’s initial path
initialStateRecord<string, unknown>none — values hydrate over the program’s own declarations
containerbooleantrue — wrap the output in one container element

renderToString needs a DOM

It renders through the real renderer, not a string builder. Called in bare Node it throws [aktion] renderToString requires a DOM. In Node, register happy-dom or jsdom globals before calling it. Register those globals once at server start‑up; a malformed program then SSRs to an empty container instead of throwing.

The testing entry (aktion-runtime/test) additionally exports within(node) for scoped queries and axe(node) for an accessibility audit — both typed (see Testing and Accessibility).

A typed host wrapper

A small wrapper centralises element creation, streaming, and listener cleanup for any framework host. Because createElement is already typed, the whole thing stays cast‑free except for the one detail read:

import "aktion-runtime";
import type { AktionElement } from "aktion-runtime";

export interface AktionHost {
  el: AktionElement;
  stream(delta: string): void;
  reset(full: string): void;
  dispose(): void;
}

export function mountAktion(parent: HTMLElement): AktionHost {
  const el = document.createElement("aktion-app"); // AktionElement — no cast
  parent.append(el);
  const onError = (e: Event) => {
    const { errors } = (e as CustomEvent<{ errors: { message: string }[] }>).detail;
    errors.forEach((x) => console.error("[aktion]", x.message));
  };
  el.addEventListener("error", onError);
  return {
    el,
    stream: (delta) => el.appendChunk(delta),
    reset: (full) => el.setResponse(full),
    dispose: () => { el.removeEventListener("error", onError); el.remove(); },
  };
}

Returning dispose matters more than it looks: a framework that remounts — React strict mode, an HMR boundary, a route change — will call it, and an un-removed error listener is the usual source of duplicate console noise.

Rules of thumb

Next