Using AI components
End-to-end guide: configure the LLM backend, mount a streaming chat, add generative UI, and send receipts back to the assistant — all in native ESM, no build step.
Before you start. This guide assumes a working project (akurai new myapp). For the component catalogue and provenance, see AI components. This page is the practical how-to.
1. Setup — point the framework at an LLM
The AI layer needs an OpenAI-compatible /v1/chat/completions endpoint. Configure it with two environment variables:
export AKURAI_LLM_URL=http://localhost:11434 # any OpenAI-compatible host
export AKURAI_LLM_MODEL=llama3.2 # model name the host recognises
AKURAI_LLM_URL is plain HTTP only — the framework's built-in LLM client uses std::net::TcpStream and has no TLS. Point it at a local model server (Ollama, LM Studio, vLLM, your homelab proxy) or a gateway that terminates TLS upstream. Pointing it at https:// will be rejected at startup.
Start the server:
akurai serve
No LLM configured? The framework starts fine. Requests to POST /api/assistant return a structured error the client displays as an inline message — nothing crashes, nothing hangs. You can develop the rest of your UI without a running model.
2. Mount a chat on a page
Chat primitives are native ES module classes in akurai-ui. Load them directly in your page script — no bundler, no import map gymnastics beyond what the framework already injects.
Minimal thread
<!-- frontend/pages/chat/index.html -->
<div id="chat-root"></div>
<script type="module">
import { Thread, Message, Composer, ActionBar } from '/ui/ai/chat.js';
const root = document.getElementById('chat-root');
const thread = new Thread(root, {
endpoint: '/api/assistant',
// optional — initial system message
system: 'You are a helpful assistant for the Acme store.',
});
// Compose the inner layout
thread.mount([
new Message(), // renders each turn (user + assistant)
new Composer(), // input row
new ActionBar(), // copy / retry / edit per message
]);
</script>
Thread owns the conversation state, auto-scrolls to the latest token, and exposes keyboard shortcuts out of the box. Message handles Markdown + syntax-highlighted code via akurai-markdown. Composer handles multiline, Enter to send, Shift+Enter for newline, and optional voice dictation (Web Speech API — no server STT).
For the full API surface, see Chat primitives.
How streaming works
When the user sends a message, Thread opens a POST /api/assistant request. The server proxies the conversation to the configured model and streams back newline-delimited SSE events:
| Event type | Payload | What the client does | |---|---|---| | delta | {"content": "..."} | Appends tokens to the in-progress message. | | tool_call | {"name": "...", "arguments": {...}} | Starts a tool-result slot in the message. | | done | {} | Finalises the assistant turn. | | error | {"message": "..."} | Displays the error inline; does not clear the conversation. |
The client never sees a raw HTTP body — everything arrives as typed events. If the connection drops mid-stream, the partial message is preserved and marked with a retry affordance.
For the full SSE contract, see Assistant API.
3. Generative UI — tool results as components
The assistant can render live UI inside the chat when a tool call returns a structured payload. The runtime matches that payload against a registered component; if there is no match it falls back gracefully to a pretty-printed JSON view.
The component contract
A tool-result component is a plain object with six fields:
const weatherWidget = {
name: 'weather_widget', // unique ID — must match your tool name
category: 'display', // one of the six categories
title: 'Weather', // display name in the gallery
match: (payload) => // return true if this payload is yours
payload?.type === 'weather' && typeof payload.location === 'string',
render: (payload, container) => { // write DOM into container
container.innerHTML = `
<div class="weather-card">
<h2>${payload.location}</h2>
<p>${payload.temperature}°C — ${payload.condition}</p>
</div>`;
},
example: { // used in the /ai-components gallery
type: 'weather', location: 'Akureyri', temperature: 4, condition: 'Cloudy',
},
};
match is called before render. Keep it cheap — a type-tag check is enough. If match returns false for every registered component, the runtime renders the payload as formatted JSON so the developer can see what shape is arriving.
Register a component
import { registerToolComponent } from '/ui/ai/tool-runtime.js';
registerToolComponent(weatherWidget);
Call registerToolComponent before you mount the Thread. Order does not matter beyond that — the runtime re-evaluates the registry on every new tool_call event.
For the full runtime API, see Generative UI.
Payload validation with akurai-schema
If you want to validate the shape rather than inspect it manually in match, use the framework's lightweight schema validator:
import { schema } from '/ui/schema.js';
const weatherSchema = schema.object({
type: schema.literal('weather'),
location: schema.string(),
temperature: schema.number(),
condition: schema.string(),
});
const weatherWidget = {
name: 'weather_widget',
match: (p) => weatherSchema.check(p).ok,
render: (p, el) => { /* ... */ },
// ...
};
schema.check returns { ok: true } or { ok: false, errors: [...] } — no exceptions, no external validator. See Schema.
Receipts — sending interaction results back
Interactive components (approvals, forms, option lists) can send a receipt back to the assistant. A receipt is a structured result that flows into the model's next turn as the tool's return value, closing the round-trip.
import { sendReceipt } from '/ui/ai/tool-runtime.js';
const approvalCard = {
name: 'approval_card',
category: 'confirmation',
title: 'Approval',
match: (p) => p?.type === 'approval_request',
render: (payload, container, context) => {
container.innerHTML = `
<div class="approval-card">
<p>${payload.prompt}</p>
<button id="approve">Approve</button>
<button id="deny">Deny</button>
</div>`;
container.querySelector('#approve').onclick = () =>
sendReceipt(context, { approved: true, action: payload.action });
container.querySelector('#deny').onclick = () =>
sendReceipt(context, { approved: false, action: payload.action });
},
example: { type: 'approval_request', prompt: 'Send the email?', action: 'send_email' },
};
sendReceipt(context, result) posts the result to /api/assistant as a tool result message. The model receives it, continues the conversation, and the stream resumes. The component's container is automatically locked (pointer-events off) once a receipt is sent — the user cannot approve twice.
4. Worked example — weather widget + approval round-trip
The following shows both patterns together: a get_weather tool whose result renders the weather_widget component, and a send_alert tool that requires approval.
Server side (your tool handlers)
The assistant endpoint relays model tool calls to your backend. Register handlers in backend/routes.json or in a custom route file:
{
"POST /api/tools/get_weather": { "handler": "tools/get_weather.js" },
"POST /api/tools/send_alert": { "handler": "tools/send_alert.js" }
}
tools/get_weather.js returns:
{
"type": "weather",
"location": "Akureyri",
"temperature": 4,
"condition": "Cloudy"
}
tools/send_alert.js first returns an approval request:
{
"type": "approval_request",
"prompt": "Send SMS alert to the on-call team?",
"action": "sms_oncall"
}
After the receipt arrives with { "approved": true }, it fires the SMS and returns a confirmation payload.
Client side
import { Thread, Message, Composer, ActionBar } from '/ui/ai/chat.js';
import { registerToolComponent } from '/ui/ai/tool-runtime.js';
import { sendReceipt } from '/ui/ai/tool-runtime.js';
// --- register components ---
registerToolComponent({
name: 'weather_widget',
category: 'display',
title: 'Weather',
match: (p) => p?.type === 'weather',
render: (p, el) => {
el.innerHTML = `
<div class="weather-card">
<h2>${p.location}</h2>
<p class="temp">${p.temperature}°C</p>
<p class="cond">${p.condition}</p>
</div>`;
},
example: { type: 'weather', location: 'Akureyri', temperature: 4, condition: 'Cloudy' },
});
registerToolComponent({
name: 'approval_card',
category: 'confirmation',
title: 'Approval',
match: (p) => p?.type === 'approval_request',
render: (p, el, ctx) => {
el.innerHTML = `
<div class="approval-card">
<p>${p.prompt}</p>
<button id="yes">Approve</button>
<button id="no">Deny</button>
</div>`;
el.querySelector('#yes').onclick = () => sendReceipt(ctx, { approved: true });
el.querySelector('#no').onclick = () => sendReceipt(ctx, { approved: false });
},
example: { type: 'approval_request', prompt: 'Send SMS?', action: 'sms_oncall' },
});
// --- mount the thread ---
const thread = new Thread(document.getElementById('chat-root'), {
endpoint: '/api/assistant',
system: 'Use get_weather for any weather query. Use send_alert to page on-call.',
tools: ['get_weather', 'send_alert'], // declare tools the model may call
});
thread.mount([ new Message(), new Composer(), new ActionBar() ]);
What happens at runtime:
- User types "What's the weather in Akureyri?"
Threadposts to/api/assistant; the model emits atool_callforget_weather.- The SSE event arrives; the runtime calls
matchon every registered component. weather_widget.matchreturnstrue;renderfires and a weather card appears
inline in the assistant turn, mid-stream.
- The stream resumes with the model's text summary.
For the approval round-trip, the model emits tool_call for send_alert. The approval_card renders. The user clicks Approve; sendReceipt posts { approved: true }. The model receives the result and continues.
5. Component catalogue
Browse every component with live examples at /ai-components.
| Category | What it renders | |---|---| | Progress | Multi-step plan trackers, streaming progress bars. | | Input | Option pickers, parameter sliders, preference panels, branching question flows. | | Display | Citations, geo maps, carousels, link previews, stats, terminal output, weather. | | Artifacts | Charts, code blocks, diffs, data tables, message drafts, social post cards. | | Confirmation | Approval cards, order summaries with receipt round-trips. | | Media | Audio players, images, image galleries, video. |
Per-category reference pages are linked from AI components as they ship. Each page documents the payload shape, the name field to match, and the interactions (receipts) the component can emit.
6. Provenance
The component set and streaming UX are a 1:1 port in spirit of two excellent open-source libraries:
- assistant-ui — composable chat
primitives: Thread, Message, Composer, ActionBar, ThreadList, BranchPicker, Reasoning, Suggestions.
- assistant-ui/tool-ui — the
tool-payload → UI mapping, the six component categories, and the receipt protocol.
Both are React + Tailwind + Zod. This framework is the opposite: native ES modules the browser loads directly, a pure-std Rust server, and a from-scratch schema validator. We port the components and behaviours, not the stack. The result is the same chat and generative-UI experience with zero bundler, zero runtime dependencies, and no React.
See also
- AI components — overview, provenance, full component list.
- Assistant API — the
POST /api/assistantSSE contract. - Chat primitives — Thread / Message / Composer / ActionBar API.
- Generative UI — tool-call runtime, receipts, approvals, ThreadList, branches, reasoning, suggestions.
- Schema —
akurai-schemavalidation.