Guide

LLM integration.

This is what Aktion is for: an LLM emits an Aktion program token-by-token and the user watches a correct, interactive UI appear as it streams. The host’s job is to feed those tokens to appendChunk, supply the right system prompt, and shuttle messages back to the model.

The integration loop

Four steps, and only the middle two touch your model provider. Everything else is the same regardless of which provider you use.

1 · PromptBuild the system prompt with getSystemPrompt().
2 · GenerateSend prompt + request to your model via an edge function.
3 · StreamPipe tokens into app.appendChunk(token).
4 · Round-tripForward assistant-message events as the next turn.

Never call the model from the browser. Your API key must stay server-side. The browser talks to your endpoint; that endpoint talks to the model and streams raw text back. The complete example below shows both halves. The one honest exception is a page with no server at all, where each reader supplies their own key — which is exactly what the chat bot demo does.

Decide how much to trust the program

Do this before you write the streaming loop, because it changes one line of your bootstrap. A generated Aktion program is code, not data: under the default policy it resolves unshadowed identifiers against the host realm, so it can reach eval, document, and fetch.

That is fine when you control the whole prompt. It is not fine when anything in the context window came from outside — a retrieved page, an email, a tool result. In that case prompt injection is a code-execution path into your origin.

A prompt-injectable pipeline needs two controls, not zero

Call setGlobalAccessPolicy("safe") at host bootstrap, and render inside a cross-origin <iframe> with a restrictive sandbox attribute. The per-sink sanitisers in the component library defend untrusted data flowing through a trusted program; they do not contain an untrusted program author, and they cannot. Work out which case you are in with the table on Security → Which case are you in?, which also lists exactly what "safe" allows and what it breaks.

A program that only builds UI never needs eval or document, so "safe" costs you very little. $http, storage, $socket, and $sse stay available under every policy — restrict those with interceptors and a CSP connect-src.

Choosing the system prompt

getSystemPrompt() emits a prompt that exactly describes the components currently registered on that element, including any you added with registerComponents. It ships in two variants:

CallTeachesUse for
app.getSystemPrompt() The full language — reactive state, components, actions, effects, $http, routing, theming, and all 282 components across 17 groups. One-shot generation of a complete app or page.
app.getSystemPrompt({ mode: "chat" }) A read-only subset — 86 layout, content, data, chart, and feedback components across 9 groups. No state writes, actions, effects, HTTP, routing, or form controls. Conversational surfaces that render a reply as a rich UI.

Chat mode’s one interactive exception is FollowUpBlock, which the host renders as suggested follow-up prompts — see the round-trip below.

Shaping the prompt

The options bag is the same for both variants. Everything is optional; the defaults produce the two prompts in the table above.

OptionTypeEffect
mode"full" | "chat""full" is the default. "chat" selects the read-only variant.
preamblestringReplaces the opening role sentence — it does not append to it. The rest of the prompt is unchanged.
examplesstring[]Replaces the built-in worked examples. Pass [] to drop them entirely.
additionalRulesstring[]Appends a rules section — house style, banned components, tone.
toolsToolSpec[]Appends an “Available endpoints” section: { name, description, argsExample?, kind? }, where kind is "Query" or "Mutation".
toolExamplesstring[]Worked examples for those endpoints.
inlineModebooleanFull mode only. Permits prose answers, with any UI wrapped in a fenced aktion block.
editModebooleanFull mode only. Asks for only the statements that changed — see the delta protocol.
bindingsbooleanFull mode only. false drops the reactive-state section. Defaults to true.
toolCallsbooleanFull mode only. false drops the $http section. Defaults to true.

Because the prompt is generated from the live registry, a model always sees the current API — there is no prompt file to maintain by hand. Register your custom components first, then build the prompt.

Prompt size and cost

The full prompt is large, because it is a complete language reference. The figures below are the bundled artefacts at this version; they grow with every component you register.

ArtefactSizeRough tokens (≈4 chars each)
aktion-runtime/system_prompt.txt (full)196 172 chars — ~192 KB~49k
aktion-runtime/system_prompt_chat.txt (chat)50 405 chars — ~49 KB~13k

Build it once, cache it forever

Never call getSystemPrompt() per request. It re-renders every component signature on each call. Hold the string in a module constant, and mark the system message as cacheable if your provider supports prompt caching — the bytes are identical across requests as long as the registered library does not change. If ~49k tokens of prefix is still too much, use { mode: "chat" }: it is roughly a quarter of the size.

Those two .txt files are also exported as package subpaths, for server-side use where you have no live element. Prefer getSystemPrompt() whenever you register custom components, so the prompt cannot drift from the library.

Generation modes

A useful product usually offers a couple of presets rather than one prompt. The chat bot demo ships four, all built from the same generator — two prompt variants plus two preambles.

ModePrompt variantBest forApprox. size
Chat · Compact { mode: "chat" } Answering a question with a rendered reply — stats, tables, charts, a follow-up row. Cheapest and least likely to over-build. ~49 KB · ~13k tokens
Chat · Full { mode: "full" } Conversational generation that still needs real interactivity — a working dashboard, a filterable table, a split-view inbox. ~192 KB · ~49k tokens
Website builder { mode: "full", preamble } Marketing pages. The preamble asks for a top Navbar above full-width stacked sections, and rules out AppShell. ~192 KB · ~49k tokens
App builder { mode: "full", preamble } Product UIs. The preamble asks for an AppShell + Sidebar shell with routes, wired actions, and realistic seed rows. ~192 KB · ~49k tokens

The two builder modes differ from plain full mode by their preamble only. Keep a preamble to intent — what kind of surface this is — and let the generated prompt teach composition; hard-coding a fixed page skeleton makes every answer look the same.

// One prompt per mode, built once and reused for every turn in that mode.
const PROMPTS = new Map();

function promptFor(mode) {
  if (!PROMPTS.has(mode)) {
    const app = document.createElement("aktion-app"); // no need to attach it
    PROMPTS.set(mode, app.getSystemPrompt(
      mode === "compact"
        ? { mode: "chat" }
        : { preamble: "You are building a working SaaS application in Aktion. Use an AppShell + Sidebar shell." },
    ));
  }
  return PROMPTS.get(mode);
}

A detached <aktion-app> is enough to build a prompt: getSystemPrompt reads the element’s component library and touches nothing else. Do it after the bundle has run, so the element is upgraded — and use the element you actually render into if you called registerComponents on it.

Streaming tokens with appendChunk

appendChunk appends text to the element’s buffer and schedules a render. setResponse replaces the buffer instead, and that difference is the whole reason to prefer appendChunk while tokens are still arriving.

appendChunk(text)setResponse(text)
The bufferAppended toReplaced
Reactive $statePreservedRebound from scratch
Component-local UI state (open Popover, active Tabs pane)PreservedReset
ParsingWhole buffer, on the next microtaskWhole buffer, on the next microtask
Reach for it whenTokens are arrivingYou have a finished document to mount

Both paths re-parse the entire buffer, so the win is not a cheaper parse — it is that calling setResponse per token throws away every atom value and every open menu on every token. Renders are coalesced into one microtask, and the commit is a DOM morph, so only nodes that actually changed are touched.

async function generate(app, prompt) {
  app.clear(); // reset any previous program & state
  const res = await fetch("/api/generate", { method: "POST", body: JSON.stringify({ prompt }) });
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    app.appendChunk(decoder.decode(value, { stream: true }));
  }
}

Pass { stream: true } to TextDecoder.decode so a multi-byte character split across two network chunks is not corrupted. clear() drops the previous program, its state, and its rendered output; it does not touch the streaming attribute.

The streaming attribute

Half-written text does not parse. Set streaming while tokens are in flight and the runtime stops reporting the errors it expects to see, then clear it to declare the document final.

While streaming is setOnce you clear it
The error banner is suppressed.The banner renders, if showerrors is on.
The error event is not dispatched for parse failures or runtime-budget aborts.Genuinely broken syntax surfaces through the error event.

What it does not do is substitute placeholder UI for the unfinished tail. The parser keeps every statement it managed to complete and reports one error for the fragment, so the runtime renders the finished part and the incomplete component is simply not there yet. That is what produces the top-down reveal — and it is why the prompt tells the model to emit $app(...) first and leaf data last.

app.streaming = true;          // property mirrors the `streaming` attribute
// … appendChunk() per token …
app.streaming = false;         // document is final; errors may now surface

References resolve across the whole top-level scope rather than in source order, so a name used by $app(...) on line 1 can arrive on line 40 and simply renders empty until it does.

Cancelling a generation

Users stop generations, retype prompts, and navigate away mid-stream. Hold the AbortController for the request in flight, abort it before starting the next one, and treat AbortError as a normal outcome rather than a failure.

let inFlight = null;

async function generate(app, prompt) {
  inFlight?.abort();                  // supersede a generation still running
  const controller = new AbortController();
  inFlight = controller;

  app.clear();
  app.streaming = true;
  try {
    const res = await fetch("/api/generate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
      signal: controller.signal,      // aborts the request AND the body stream
    });
    if (!res.ok) throw new Error(`Generate failed: ${res.status}`);
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      app.appendChunk(decoder.decode(value, { stream: true }));
    }
    app.streaming = false;            // finished normally — let errors surface
  } catch (err) {
    if (err.name === "AbortError") return;   // leave `streaming` set: see below
    app.streaming = false;
    app.setResponse('$app(Callout("Generation failed — try again.", { tone: "danger" }))');
  } finally {
    if (inFlight === controller) inFlight = null;
  }
}

stopButton.addEventListener("click", () => inFlight?.abort());

Passing signal to fetch aborts the response body too, so the reader.read() loop rejects with AbortError rather than hanging. The partial program stays on screen, which is usually what the user wants after pressing Stop.

Deliberately leaving streaming set after an abort

A cancelled buffer ends mid-statement. Clearing the flag would immediately report that fragment as a parse error, so the user gets a red banner for doing exactly what you asked. Leave it set: the partial UI stays clean, and the next generate() call takes ownership of the flag again. Call app.clear() instead if you want the partial program discarded outright.

A complete end-to-end example

Here are both halves of a minimal integration. The edge function holds the API key, parses the provider’s SSE stream, and re-emits plain Aktion text; the browser feeds that text to appendChunk.

// edge: /api/generate  (Vercel / Cloudflare / Netlify edge function)
import { readFileSync } from "node:fs";

const SYSTEM_PROMPT = readFileSync("system_prompt.txt", "utf8"); // or getSystemPrompt() server-side

export async function POST(req) {
  const { prompt } = await req.json();

  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // stays server-side
    },
    body: JSON.stringify({
      model: process.env.OPENAI_MODEL, // your provider's current streaming model id
      stream: true,
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: prompt },
      ],
    }),
  });

  // Transform the provider SSE into a plain text stream of Aktion source.
  const stream = new ReadableStream({
    async start(controller) {
      const reader = upstream.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split("\n");
        buffer = lines.pop() ?? "";
        for (const line of lines) {
          if (!line.startsWith("data: ")) continue;
          const data = line.slice(6).trim();
          if (data === "[DONE]") continue;
          const delta = JSON.parse(data).choices?.[0]?.delta?.content;
          if (delta) controller.enqueue(new TextEncoder().encode(delta));
        }
      }
      controller.close();
    },
  });
  return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}

Keeping the model id in an environment variable rather than the source is not just hygiene: model ids turn over every few months, and a redeploy is cheaper than a code change. The endpoint returns text/plain, so the browser half never has to know which provider answered.

// browser: stream the edge response into the element
import "aktion-runtime";

const app = document.querySelector("aktion-app");

async function generate(prompt) {
  app.clear();                 // reset previous program + state
  app.streaming = true;
  try {
    const res = await fetch("/api/generate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    });
    if (!res.ok) throw new Error(`Generate failed: ${res.status}`);
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      app.appendChunk(decoder.decode(value, { stream: true }));
    }
  } catch (err) {
    // Network / provider failure — show your own fallback UI.
    app.setResponse('$app(Callout("Generation failed — try again.", { tone: "danger" }))');
    console.error(err);
  } finally {
    app.streaming = false;     // tells the parser the document is complete
  }
}

Why this is robust. The transient parse errors of a half-written document never reach your error handling, because streaming suppresses them. Clearing the flag in finally means a genuinely broken program still surfaces through the error event on exactly one code path. Add the AbortController from above and you have the production shape. See Error handling.

Provider-specific stream parsing

Most providers send Server-Sent Events; extract the text delta from each chunk before passing it on. Do this in your edge function and emit raw Aktion text to the browser, or parse in the browser:

ProviderText delta lives in
OpenAI (Chat Completions)choices[0].delta.content per data: SSE line.
Anthropic (Messages)content_block_delta.delta.text events.
OpenRouterOpenAI-compatible: choices[0].delta.content.
AWS BedrockModel-specific payload in the event stream (e.g. Claude’s delta.text).

Whatever the provider, the contract with Aktion is the same: concatenate the text deltas and feed them to appendChunk. Strip any fenced code block the model wrapped the program in before the first chunk reaches the element.

The assistant-message round-trip

Generated UI can ask the model a follow-up. Any action can dispatch it with $emit("assistant-message", { message }), and FollowUpBlock does it for you — each item sends its own message when clicked, unless you supply onSelect.

followUps = FollowUpBlock({
  title: "Next",
  items: [
    FollowUpItem({ label: "Break it down by region", message: "Show the same numbers split by region" }),
    FollowUpItem({ label: "Compare to last quarter", message: "Compare these numbers to Q3" })
  ]
})

$app(Column([
  StatCard("Revenue", { value: "$48,120", delta: "+12%" }),
  followUps
], { gap: "md" }))

The host sees one assistant-message event carrying detail.message. Push it onto your history and start the next turn:

app.addEventListener("assistant-message", async (e) => {
  const text = e.detail.message;
  history.push({ role: "user", content: text });
  await generate(app, history); // stream the model's reply back in
});

assistant-message, error, and route-change are reserved names — the runtime already dispatches all three, so give your own events different ones or your listener will see both. Pass disabled: true to FollowUpBlock while a generation is in flight to keep the row inert.

The delta protocol

Most follow-up turns are tweaks: rename a heading, add a nav item, drop a panel. Re-emitting the whole program for that is slow and loses the user’s place. app.applyDelta(ops) — a method on the host element — applies a list of typed operations to the program already mounted and carries $state across the diff.

OperationWhat it does
{ kind: "patch", target, value }Writes a $state atom. The program text is untouched — this is the cheap path for “set the range to 30 days”.
{ kind: "replace", binding, source }Replaces a top-level binding’s right-hand side with new source text.
{ kind: "append", binding, item }Appends one element to a top-level array-literal binding.
{ kind: "new", source }Appends a new top-level statement — a binding, a function, an $effect.
{ kind: "delete", binding }Removes a top-level binding, component, action, effect, or hook by name.
const warnings = app.applyDelta([
  { kind: "patch",   target: "range",   value: "30d" },
  { kind: "replace", binding: "header", source: 'PageHeader("Sales", { subtitle: "Last 30 days" })' },
  { kind: "append",  binding: "navItems", item: '{ label: "Reports", href: "/reports" }' },
  { kind: "delete",  binding: "legacyBanner" },
]);

if (warnings.length > 0) console.warn(warnings); // ops whose target was missing

applyDelta returns an array of advisory warnings, one per op that became a no-op, and mounts the rest anyway. A delta that half-misses degrades; it never strands the user with no UI.

Three limits worth knowing before you wire this up

Ops address top-level names only. binding is the name to the left of =, or the name of a function / $effect declaration. You cannot patch a nested expression.

append needs a literal array as the binding’s right-hand side; anything else is skipped with a warning.

Appending re-prints the array from the AST. Elements the printer cannot round-trip — lambdas, if, switch, for — come back as /* unprintable */, so use replace on the whole binding when the array holds lambdas.

To have the model produce deltas rather than whole documents, build the prompt with { editMode: true }: it asks for only the statements that changed, and for name = null to remove one. That reply is still text, so your host decides how to map each changed statement onto a replace or new op.

For persisting or replaying a session, pair app.response with app.serializeState() and restore both in one shot with app.loadSnapshot({ programText, state }) — the same atomic step applyDelta uses internally. See Production & deployment.

Interceptors

Generated programs make their own network calls via $http. Install host interceptors to add auth, retry, or logging without changing the generated code:

app.registerHttpInterceptors({
  onRequest: (req) => ({ ...req, headers: { ...req.headers, Authorization: token } }),
  onResponse: async (res, retry) => {
    if (res.status === 401) { await refresh(); return retry(); } // one-shot retry
    return res;
  },
  onError: (err, req) => reportError(err, req.url),
});

Calls merge incrementally, so registering { onRequest } later does not clear an existing onResponse. This is also the host-level place to enforce an allow-list of origins: a restricted global access policy does not close $http, so onRequest plus a CSP connect-src is what actually bounds where a generated program can talk.

Rules of thumb

Next