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:
| Setting | Value | Why |
|---|---|---|
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 resolvers | TypeScript enforces the pairing itself: bundler with module: "commonjs" is rejected as TS5095 before it ever looks at an import. |
lib | must include "DOM" | The declarations reference HTMLElement and Node — AktionElement 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. |
strict | true | Optional, 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:
| Import | Provides |
|---|---|
aktion-runtime | The runtime: registers <aktion-app> on import, plus registerComponents, the themes, the SSR helpers, and every core type. |
aktion-runtime/test | The Testing-Library-style API (render, renderComponent, within, axe, fetch mocks). See Testing. |
aktion-runtime/devtools | The DevTools backend and in-page panel. See DevTools. |
aktion-runtime/language | DOM‑free language + tooling surface: diagnostics, completions, hover, navigation, semantic tokens, signature help, formatProgram. |
aktion-runtime/vite | The Vite plugin for .aktion files (default export). |
aktion-runtime/aktion-modules | Types only. Ambient declare module "*.aktion" declarations — see above. |
aktion-runtime/style.css | The base stylesheet, for hosts that render outside the shadow root. |
aktion-runtime/system_prompt.txtaktion-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:
| Type | From | What it is |
|---|---|---|
AktionElement | aktion-runtime | The <aktion-app> class. Exported as a value too, so instanceof works. |
ComponentSpec | aktion-runtime | { name, description, props, render } — one custom built‑in. Pass an array to registerComponents. |
PropSpec | aktion-runtime | One entry in a spec’s props. See the field table below. |
RenderHelpers | aktion-runtime | The third argument handed to render: renderNode, invoke, setState, resetState, useInstanceState, registerDisposer, router. |
ComponentLibrary | aktion-runtime | { root, components }. defaultLibrary is one; the language service takes one as an argument. |
HttpInterceptors | aktion-runtime | The onRequest / onResponse / onError bag — see Interceptors. |
HttpRequest · HttpResponse | aktion-runtime | What those hooks receive and return. |
ThemeTokens | aktion-runtime | Every themeable token as a flat, all‑string record (colorPrimary, radiusButton, chart1…). ThemeInput = string | Partial<ThemeTokens> is what setTheme accepts. |
ResolvedTheme | aktion-runtime | { name, tokens } — the output of resolveTheme. See Themes. |
GlobalAccessPolicy | aktion-runtime | "all" | "safe" | readonly string[] — which host globals a program may reach. See Security. |
RenderToStringOptionsRenderToStringResult | aktion-runtime | SSR input and output — see SSR. |
CompiledProgram | aktion-runtime | The linker artefact from linkProject / compileLite, and what mountCompiled takes. |
PromptOptions | aktion-runtime | Options for getSystemPrompt / generatePrompt (mode, preamble, tools, …). |
Diagnostic · PositionCompletionItem · HoverInfoFormatResult | aktion-runtime/language | The editor-tooling shapes — see Language surface. |
Screen · RenderOptionsA11yViolation | aktion-runtime/test | What 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:
| Member | Signature | Purpose |
|---|---|---|
setResponse | (text: string) => void | Replace the program and re-render from scratch. |
appendChunk | (chunk: string) => void | Append a streaming delta. Coerces defensively, so a decoder result is fine. |
clear | () => void | Drop the program and the rendered tree. |
response | string (get/set) | Property form of setResponse. |
streaming | boolean (get/set) | While true, transient parse errors are suppressed instead of dispatched. |
showErrors | boolean (get/set) | Show or hide the built-in error banner. |
setTheme | (theme: ThemeInput) => void | Apply a built-in theme name or a flat token map. |
registerComponents | (components: ComponentSpec[], rootName?: string) => void | Add custom built‑ins; the next getSystemPrompt() advertises them. |
registerIcons | (icons: Record<string, string>) => void | Register inline SVG by name, usable anywhere a Font Awesome name is. |
registerHttpInterceptors | (interceptors: HttpInterceptors) => void | Host‑wide HTTP hooks. Calls merge incrementally. |
getSystemPrompt | (options?: PromptOptions) => string | Build 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>>) => void | Apply a snapshot; later $state defaults do not overwrite it. |
loadSnapshot | (payload: { programText: string; state: Record<string, unknown> }) => void | Set 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>) => void | Mount a linked multi-file program, skipping the runtime parser. |
src · loadFromSrc · sourceId | string | null · (src: string) => Promise<void> · string | null | Load a .aktion entry over the network and report which module is mounted. |
connectDevtools | () => void | Attach 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:
| Event | detail | Fires 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 name | whatever $emit passed | An 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:
| Field | Type | Effect |
|---|---|---|
name | string | Required. The key your render reads from props. |
type | string | Required. A documentation-and-binding hint ("string", "number", "boolean", "callable", "Node", "Node[]", or a free-form label like "number[]"). |
positional | boolean | At 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”. |
required | boolean | Marker only — the prompt generator renders it without a ?. Not enforced at runtime. |
optional | boolean | The opposite marker, used the same way. |
enum | readonly string[] | Accepted values. The validator rejects anything else, and the prompt lists them. |
aliases | readonly string[] | Extra named-argument spellings that route to this same slot — how tone and variant coexist across the library. |
description | string | Goes 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:
| Hook | Signature | Contract |
|---|---|---|
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) => void | Observation 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.
| Export | Signature | Returns |
|---|---|---|
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 | null | Active parameter inside a component call. |
formatProgram | (source) => FormatResult | { formatted, errors }. Idempotent. |
getSemanticTokens | (source, library?) => SemanticToken[] | Editor highlighting, with semanticTokenTypes / semanticTokenModifiers legends. |
| navigation set | getDefinition, getReferences, getDocumentSymbols, getDocumentHighlights, getRenameEdits | Go‑to‑definition, outline, and rename edits over one program. |
getLanguageSpec | () => LanguageSpec | The 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:
| Export | Signature | Purpose |
|---|---|---|
renderToString | (program, opts?) => { html, state, head, headAttrs } | SSR — markup, a hydration snapshot, and the $head output. |
renderToStaticMarkup | (program, opts?) => string | SSG — 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) => string | Importer — map common HTML/JSX tags to an Aktion program. |
tailwindToSx | (classString) => object | Map Tailwind utilities (incl. arbitrary values like w-[327px]) to an sx object; misses land in _unmapped. |
cssToSx | (cssText) => object | Map a raw CSS declaration string or rule to an sx object. |
styledToSx | (template) => object | Extract static declarations from a styled‑components / emotion template. |
componentSchema | (library) => LibrarySchema | Stable JSON of every component’s props/types/enums for editor autocomplete. The library argument is required — pass defaultLibrary. |
buildGallery | (library) => string | Self‑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.
RenderToStringOptions | Type | Default |
|---|---|---|
library | ComponentLibrary | defaultLibrary |
path | string | "/" — the in-memory router’s initial path |
initialState | Record<string, unknown> | none — values hydrate over the program’s own declarations |
container | boolean | true — 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
- Set
moduleResolutiontobundlerornodenextfirst — every other import problem on this page is downstream of that one setting. - Do not cast
querySelector("aktion-app"). The tag‑name map is augmented for you; a cast only hides the moment the import goes missing. - Declare one
AktionEventMapper host and never caste.detailat a call site again. - Narrow
propsinside arenderwithas T | undefinedplus a default. The DSL cannot guarantee a type, so the spec is a hint, not a contract. - Destructure all four fields from
renderToString. Ignoringheadis a silent SEO regression, not a type error. - Reach for
aktion-runtime/languagein CI. It needs no DOM, so a lint step costs no happy‑dom setup.