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 — 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.