Building a UI
The core SDK gives you raw message data via callbacks. You build the UI yourself. This guide covers the key patterns.
Message rendering
The onMessagesChange callback fires whenever messages update, including new text chunks during streaming, tool status changes, or new messages added.
onMessagesChange: (messages) => {
const container = document.getElementById('messages');
container.innerHTML = '';
for (const msg of messages) {
if (msg.role === 'user') {
container.appendChild(renderUserMessage(msg.content));
} else {
container.appendChild(renderAssistantMessage(msg));
}
}
}
Assistant message structure
Each assistant message has a blocks array containing text and tool call blocks in order:
function renderAssistantMessage(msg) {
const wrapper = document.createElement('div');
if (msg.isError) {
wrapper.textContent = msg.content;
wrapper.className = 'error-message';
return wrapper;
}
for (const block of msg.blocks) {
if (block.type === 'text' && block.content) {
const textEl = document.createElement('div');
textEl.innerHTML = markdownToHtml(block.content); // Use your preferred markdown renderer
wrapper.appendChild(textEl);
}
if (block.type === 'tool_calls') {
for (const toolCall of block.toolCalls) {
wrapper.appendChild(renderToolCall(toolCall));
}
}
}
return wrapper;
}
Tool call cards
Each ToolCallDisplay has a status that progresses through the lifecycle:
| Status | Meaning |
|---|---|
pending | Tool call received, not yet started |
approval_requested | Waiting for user to approve or deny |
executing | Tool is running |
completed | Tool finished successfully |
error | Tool failed |
denied | User clicked Deny |
stopped | Generation was stopped while tool was in progress |
function renderToolCall(tc) {
const card = document.createElement('div');
card.className = 'tool-card';
// Header
const header = document.createElement('div');
header.textContent = `${tc.displayName || tc.name} — ${tc.status}`;
if (tc.durationMs) header.textContent += ` (${tc.durationMs}ms)`;
card.appendChild(header);
// Approval buttons
if (tc.status === 'approval_requested') {
const allowBtn = document.createElement('button');
allowBtn.textContent = 'Allow';
allowBtn.onclick = () => agent.approveToolCall(tc.id);
const denyBtn = document.createElement('button');
denyBtn.textContent = 'Deny';
denyBtn.onclick = () => agent.rejectToolCall(tc.id);
card.appendChild(allowBtn);
card.appendChild(denyBtn);
}
// Result
if (tc.result !== undefined) {
const result = document.createElement('pre');
result.textContent = JSON.stringify(tc.result, null, 2);
card.appendChild(result);
}
// Error
if (tc.error) {
const err = document.createElement('div');
err.textContent = tc.error;
err.className = 'error';
card.appendChild(err);
}
return card;
}
Streaming indicator
Use onStreamingChange to show/hide a loading state and toggle send/stop buttons:
onStreamingChange: (isStreaming) => {
document.getElementById('loading').style.display = isStreaming ? 'block' : 'none';
document.getElementById('send-btn').disabled = isStreaming;
document.getElementById('stop-btn').style.display = isStreaming ? 'inline' : 'none';
}
Auto-scroll
During streaming, scroll to the bottom so the user sees new content:
onMessagesChange: (messages) => {
renderMessages(messages);
// Auto-scroll
const container = document.getElementById('messages');
container.scrollTop = container.scrollHeight;
}
Framework integration patterns
Vue 3
import { ref, onMounted } from 'vue';
import { Agent } from '@kapaai/agent-core';
const messages = ref([]);
const isStreaming = ref(false);
const agent = new Agent({
...config,
onMessagesChange: (msgs) => { messages.value = msgs; },
onStreamingChange: (s) => { isStreaming.value = s; },
});
Svelte
import { writable } from 'svelte/store';
import { Agent } from '@kapaai/agent-core';
const messages = writable([]);
const isStreaming = writable(false);
const agent = new Agent({
...config,
onMessagesChange: (msgs) => messages.set(msgs),
onStreamingChange: (s) => isStreaming.set(s),
});
The pattern is always the same: wire onMessagesChange and onStreamingChange to your framework's reactive state.
Full examples
For complete runnable apps built with @kapaai/agent-core, see the vanilla JS example and the headless React example in the examples repo.