Skip to main content

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:

StatusMeaning
pendingTool call received, not yet started
approval_requestedWaiting for user to approve or deny
executingTool is running
completedTool finished successfully
errorTool failed
deniedUser clicked Deny
stoppedGeneration 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.