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.
getSystemPrompt().app.appendChunk(token).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:
| Call | Teaches | Use 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.
| Option | Type | Effect |
|---|---|---|
mode | "full" | "chat" | "full" is the default. "chat" selects the read-only variant. |
preamble | string | Replaces the opening role sentence — it does not append to it. The rest of the prompt is unchanged. |
examples | string[] | Replaces the built-in worked examples. Pass [] to drop them entirely. |
additionalRules | string[] | Appends a rules section — house style, banned components, tone. |
tools | ToolSpec[] | Appends an “Available endpoints” section: { name, description, argsExample?, kind? }, where kind is "Query" or "Mutation". |
toolExamples | string[] | Worked examples for those endpoints. |
inlineMode | boolean | Full mode only. Permits prose answers, with any UI wrapped in a fenced aktion block. |
editMode | boolean | Full mode only. Asks for only the statements that changed — see the delta protocol. |
bindings | boolean | Full mode only. false drops the reactive-state section. Defaults to true. |
toolCalls | boolean | Full 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.
| Artefact | Size | Rough 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.
| Mode | Prompt variant | Best for | Approx. 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 buffer | Appended to | Replaced |
Reactive $state | Preserved | Rebound from scratch |
Component-local UI state (open Popover, active Tabs pane) | Preserved | Reset |
| Parsing | Whole buffer, on the next microtask | Whole buffer, on the next microtask |
| Reach for it when | Tokens are arriving | You 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 set | Once 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:
| Provider | Text delta lives in |
|---|---|
| OpenAI (Chat Completions) | choices[0].delta.content per data: SSE line. |
| Anthropic (Messages) | content_block_delta.delta.text events. |
| OpenRouter | OpenAI-compatible: choices[0].delta.content. |
| AWS Bedrock | Model-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.
| Operation | What 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
- Build the system prompt once per mode and hold the string; never per request.
- Start with
{ mode: "chat" }. Move to full mode only when the surface genuinely needs state, actions, or routing. - Stream with
appendChunk; reservesetResponsefor mounting a finished document. - Set
streamingfor the whole stream and clear it once the document is final — but not after an abort, where the tail is legitimately unfinished. - Keep one
AbortControllerper in-flight generation and abort it before starting the next. - Use
applyDeltafor tweak turns; re-generate only when the structure really changes. - If anything in the context window came from outside your control, call
setGlobalAccessPolicy("safe")and sandbox the frame.