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
getSystemPrompt().app.appendChunk(token).assistant-message events as the next turn.- Build a system prompt that matches the live component library with
getSystemPrompt(). - Send the system prompt + the user’s request to your model (through your own server / edge function so the API key stays private).
- Stream the response tokens into
app.appendChunk(token). - When the user interacts with something that should talk back to the
model, the app fires an
assistant-messageevent — forward it 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.
Choosing the system prompt
getSystemPrompt() emits a prompt that exactly describes the
components currently registered (including any you added with
registerComponents). Two variants:
| Call | Use for |
|---|---|
app.getSystemPrompt() | The full prompt — one-shot generation of a complete app/page. |
app.getSystemPrompt({ mode: "chat" }) | The compact variant — conversational, multi-turn chat surfaces where prompt budget matters. |
The package also ships the prompts as static files
(aktion-runtime/system_prompt.txt and
system_prompt_chat.txt) for server-side use where you
don’t have a live element — but prefer
getSystemPrompt() when you register custom components so the
prompt stays in sync.
The generated prompt covers the full language surface —
every component plus the data layer ($query infinite/polling/GraphQL,
$mutation optimistic/invalidation, $socket/$sse),
$form, nested routes, the $util runtime members
(reactive env, url, style, rules, device
& worker helpers), the sx styling channel, and the expanded
theme tokens. Because it’s generated from the live registry, models always
see the current API — no manual prompt maintenance.
Streaming tokens with appendChunk
appendChunk is incremental: it parses only the new tail and
re-renders the affected lines, so it is the cheap path for token-by-token
output. Use setResponse only to swap a whole document.
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 }));
}
}
Don’t call setResponse on every token — it
re-parses the entire buffer each time. Stream with
appendChunk; see Performance.
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: "gpt-4o",
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" } });
}
// 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.setAttribute("streaming", "");
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.removeAttribute("streaming"); // tells the parser the document is complete
}
}
Why this is robust. While the
streaming attribute is set, the runtime tolerates an
incomplete tail (a half-written component renders a
Skeleton rather than an error). Dropping the attribute at
the end tells the parser the document is final, so any genuinely broken
syntax now surfaces through the error event. 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.
The assistant-message round-trip
Generated UI can ask the model a follow-up — e.g. a button wired to
send a message. That dispatches an assistant-message event on
the host; forward its detail.message as the next user 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
});
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) => reportError(err),
});
The delta protocol
For applications that persist or replay a session, Aktion can represent UI
changes as deltas rather than whole-document swaps — the same
incremental model that powers appendChunk. Persist the program
text (and optionally serializeState()) to resume a session;
see Production & deployment.