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

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.
  1. Build a system prompt that matches the live component library with getSystemPrompt().
  2. Send the system prompt + the user’s request to your model (through your own server / edge function so the API key stays private).
  3. Stream the response tokens into app.appendChunk(token).
  4. When the user interacts with something that should talk back to the model, the app fires an assistant-message event — 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:

CallUse 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:

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.

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.

Next