Skip to content

Use your own agent loop with a chat UI

Everything so far assumed AparteClient runs the agent loop in the page. But sometimes the loop lives somewhere else: a backend you fully own, a worker, or an Electron main process talking to a local model. The chat component then becomes display-only — your code pushes messages and tokens in, and aparté renders, streams, scrolls and brands them exactly as if the client were driving.

No AparteClient, no provider, no transport. Two methods from the imperative API (identical on all four wrappers) do all the work:

  • appendMessage(message) — add a message to the thread.
  • injectTokenStream(messageId, tokens) — stream an AsyncIterable<string> into a message, token by token, with the live-streaming UI (cursor, auto-scroll). Resolves when the iterable completes; stopTokenStream() cancels.
  1. Listen to onMessageSent for the user’s message and forward it to your loop. The user bubble is appended automatically on send — don’t add it yourself.
  2. When your loop starts answering, appendMessage an empty assistant message with a fresh id.
  3. injectTokenStream(id, tokens) with your token source.

injectTokenStream is something the wrappers add: it is built on the chat host, not on any element. Driving the raw elements, the three steps are three viewport calls — which is the same loop, written out.

const viewport = document.querySelector('aparte-chat-viewport')!;
document.addEventListener('aparte-send', async (event) => {
const id = crypto.randomUUID();
// Explicit append BEFORE streaming — see the caveat below.
viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
for await (const token of myAgentLoop(event.detail.content)) {
viewport.appendToken(id, token);
}
viewport.completeMessage(id); // stops the streaming caret
});

myAgentLoop is any AsyncIterable<string> — an async generator over a fetch stream, a model SDK, whatever produces tokens.

That empty assistant message from step 2 needs no status: an assistant message with no status and nothing in it is a reply on its way, so the bubble shows the waiting indicator and keeps its action bar away until the first token. As the stream runs, your framework’s message list is kept in sync (once per frame), so getMessages(), persistence and a custom bubble all see the text — not just the DOM.

Your own controls go in the composer, not in a bar below the chat: the toolbar row takes a mode picker, a model selector or a token counter. Driving the loop yourself is exactly the case where you have such controls — see The composer toolbar.

Richer replies: segments instead of plain text

Section titled “Richer replies: segments instead of plain text”

injectTokenStream writes plain text into a message’s content. For a thinking block, a tool call or anything the bubble renders as a typed block, stream segments instead:

chat.ref.current?.addSegment({ id: 'think-1', type: 'thinking', content: '' });
for await (const chunk of reasoning) chat.ref.current?.appendToSegment('think-1', chunk);

appendToSegment writes each chunk straight into the bubble and syncs the framework’s message list once per frame, so a fast local model costs roughly one render per frame rather than one per token — you don’t need your own batching layer.

Two levels of the same call, not a contradiction: a wrapper’s chat.addSegment(segment), updateSegment(id, patch) and appendToSegment(id, chunk) always target the last message, while the raw viewport’s viewport.addSegment(messageId, segment) and viewport.updateSegment(messageId, segmentId, patch) take the message id. Driving a wrapper, use the first — and don’t append another message while a turn is still being written, or the next segment lands on the newcomer.

injectTokenStream pulls from an iterable, but IPC-style sources push events at you (Electron ipcRenderer, WebSocket, postMessage). Bridge with a small async queue:

function createTokenQueue() {
const buffer: string[] = [];
let notify: (() => void) | null = null;
let done = false;
return {
push(token: string) { buffer.push(token); notify?.(); },
end() { done = true; notify?.(); },
async *stream(): AsyncGenerator<string> {
for (;;) {
while (buffer.length) yield buffer.shift()!;
if (done) return;
await new Promise<void>((r) => { notify = r; });
notify = null;
}
},
};
}

Wire it to the pushing side, hand queue.stream() to injectTokenStream:

// Your own bridge — an Electron preload, a WebSocket wrapper, whatever pushes tokens.
declare const myBridge: {
onToken(cb: (token: string) => void): void;
onDone(cb: () => void): void;
};
const queue = createTokenQueue();
myBridge.onToken((t) => queue.push(t));
myBridge.onDone(() => queue.end());
chat.ref.current?.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
await chat.ref.current?.injectTokenStream(id, queue.stream());

Starting a new injectTokenStream cancels the previous one, and stopTokenStream() cancels explicitly (a stop button) — the source iterable is return()ed, so a generator’s finally runs and can tear down the underlying request.

Running aparté’s own loop out of process

Section titled “Running aparté’s own loop out of process”

If the external loop is yours to write, you don’t have to reinvent it: runStreamAgent from @aparte/engine is the exact agent loop core runs inline — headless, zero dependencies, no DOM. It runs fine in Node, a worker, or an Electron main process; forward its emitted text over your bridge and inject it here.

Driving your own loop means doing by hand what AparteClient does for you. These are exported so you do not have to reimplement them:

ExportWhat it doesWhen you want it
AparteStreamParserIncrementally splits a model’s text into segments — text, code fences, thinking blocks, and the tagged blocks you registered (blocks option / registerStreamBlock)You are feeding raw deltas and want the same rendering the client produces
parseMarkdownToSegmentsThe one-shot version of the above, for a complete replyYou already have the whole answer (a non-streaming call, or replaying history)
contentToTextFlattens string | AparteContentPart[] to its textYour transport or logs need the text of a multimodal message
readableToAsyncIterableWraps a ReadableStream so for await works, honouring an AbortSignalYou are consuming a provider’s parseStream directly — Chromium does not async-iterate streams
uuidAn id that works on plain http://crypto.randomUUID first, a cheap fallback where it does not existYou generate message or host ids and your app also runs on a LAN address
copyTextCopies to the clipboard on plain http:// too — navigator.clipboard first, execCommand('copy') where it does not existYou add a copy button of your own; core’s three use it
registerAllComponentsReferences all 24 element classes so a bundler cannot tree-shake their customElements.define away, then warns naming any tag that is still not in the registryYour build is aggressive, or you load @aparte/core through a dynamic import()
AparteChatHostThe streaming / branch / host-method orchestration the four wrappers all bind to — everything AparteClient does minus the transportYou are writing a fifth framework binding, or driving core from a framework we do not ship
populateBubbleFromMessageFills an <aparte-chat-bubble> from an AparteMessage — segments, attachments, sibling nav, action barYou render bubbles yourself instead of letting the viewport own them
parseAparteEventStreamReads the NDJSON wire format createAparteChatHandler emits back into AparteStreamEventsYou wrote your own client against an aparté backend endpoint

Wiring your own binding starts here — no AparteClient, so nothing is hostage to it:

import {
registerAllComponents,
AparteChatHost,
populateBubbleFromMessage,
parseAparteEventStream,
readableToAsyncIterable,
uuid,
type AparteMessage,
} from '@aparte/core';
registerAllComponents(); // safe to call more than once
// The host takes its binding up front: you own the message list, it drives the DOM.
const chat = document.querySelector('aparte-chat')!;
let messages: AparteMessage[] = [];
const host = new AparteChatHost({
hostId: uuid(),
host: chat,
viewport: chat.viewport,
getMessages: () => messages,
setMessages: (next) => { messages = next; },
// Required: run `cb` once your framework has painted. With no framework, the
// next frame is the honest answer — the host uses it to measure, not to poll.
afterRender: (cb) => void requestAnimationFrame(() => cb()),
onMessagesChange: (next) => void next, // your framework's re-render
});
const release = host.bind(); // returns its own unbind
// Rendering a bubble yourself instead of letting the viewport own it:
const bubble = document.createElement('aparte-chat-bubble');
populateBubbleFromMessage(bubble, { id: 'a1', role: 'assistant', content: 'hi', timestamp: Date.now() });
// Reading an aparté backend's NDJSON stream without AparteClient. Note the wrapper:
// parseAparteEventStream returns a ReadableStream, and Chromium does not
// async-iterate those — the signal is how a user's "stop" cuts the read.
async function consume(body: ReadableStream<Uint8Array>, signal: AbortSignal) {
for await (const event of readableToAsyncIterable(parseAparteEventStream(body), signal)) void event;
}
void consume;
release();
import { AparteStreamParser, contentToText } from '@aparte/core';
const parser = new AparteStreamParser();
for (const delta of ['Here: ', '```', 'ts\n', 'const x = 1;\n', '```']) {
const { segments } = parser.parse(delta);
for (const segment of segments) void segment; // render as they complete
}
const trailing = parser.finalize(); // flush whatever is still buffered
void trailing;
void contentToText([{ type: 'text', text: 'hello' }]); // 'hello'

By default the parser recognises <think>…</think> and <thinking>…</thinking>. Models that mark their reasoning differently need the delimiters spelled out — pass one pair, or several. Passing any replaces the defaults, so re-list the ones you still want:

import { AparteStreamParser } from '@aparte/core';
import type { AparteThinkingDelimiterPair } from '@aparte/core';
const pairs: AparteThinkingDelimiterPair[] = [
{ start: '<think>', end: '</think>' },
{ start: '<|begin_of_thought|>', end: '<|end_of_thought|>' },
];
const parser = new AparteStreamParser({ thinkingDelimiters: pairs });
void parser;

Matched content becomes a thinking segment, which renders collapsed instead of as part of the reply.

The send button turns into Stop while composer.streaming is true, and that flag is not something you set: it follows four lifecycle events, filtered by detail.targetId. AparteClient dispatches them for you; a host with its own loop dispatches two of them itself — that is the whole contract:

EventWhat the composer does
aparte-message-startstreaming = true — the button becomes Stop
aparte-message-done, aparte-message-error, aparte-message-abortedstreaming = false — and an open elicitation panel is evicted

Dispatch them on the chat host element, bubbling — the <aparte-chat> (the Angular wrapper’s tag too), or the [data-aparte-chat] element the React, Vue and Svelte wrappers render. That is what AparteClient does (bubbles: true, composed: true), and it is the one dispatch that reaches every reader: the composer and <aparte-context> listen on window, which a bubbling event reaches; the conversation controller and AparteChatHost (the wrappers’ binding) listen on the host itself, which a window dispatch never reaches — so a host with its own loop that dispatched on window saw the button follow and the conversation list not. targetId is the host’s id, which is also what the composer’s target names; leave it out only on a page with one chat. Setting status: 'streaming' on the assistant bubble does nothing for the composer: the bubble and the button are two readers of the same turn, and the turn is the events.

import type {
AparteAbortEventDetail,
AparteMessageAbortedEventDetail,
AparteMessageDoneEventDetail,
AparteMessageStartEventDetail,
} from '@aparte/core';
const host = document.querySelector<HTMLElement>('aparte-chat, [data-aparte-chat]')!;
const targetId = host.id;
const lifecycle = <T>(name: string, detail: T) =>
host.dispatchEvent(new CustomEvent<T>(name, { bubbles: true, composed: true, detail }));
document.addEventListener('aparte-send', async (event) => {
const id = crypto.randomUUID();
lifecycle<AparteMessageStartEventDetail>('aparte-message-start', { targetId, messageId: id, role: 'assistant' });
viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
for await (const token of myAgentLoop(event.detail.content)) viewport.appendToken(id, token);
viewport.completeMessage(id);
lifecycle<AparteMessageDoneEventDetail>('aparte-message-done', { targetId, messageId: id, role: 'assistant' });
});
// The other direction. Stop dispatches `aparte-abort` on window — a command, not a
// notification — scoped by `targetId`, so a page with two chats checks it. The composer
// resets itself; the host-bound readers still need the turn closed on the host.
window.addEventListener('aparte-abort', (e: CustomEvent<AparteAbortEventDetail>) => {
if (e.detail?.targetId && e.detail.targetId !== targetId) return;
cancelMyLoop();
lifecycle<AparteMessageAbortedEventDetail>('aparte-message-aborted', { targetId });
});

All four payloads are typed in the events reference, and the readers are the same ones AparteClient drives — dispatching on the host makes each of them follow your turn, not only the button.

Display-only means the pieces AparteClient orchestrates don’t run in the page: no built-in tool-approval flow, no retry/edit re-sending, no request building. Your loop owns those.

Concretely, for retry and edit: the buttons exist, and clicking one emits aparte-retry / aparte-edit and nothing else. Nobody re-sends, and on edit the editor closes and the original text comes back. That is why core ships both off — so a display-only integration shows no button it can’t honour. Either handle those two events in your loop and switch them on:

aparteGlobalConfig.setBubbleActions({ retry: true, edit: true });

…or leave them off, which is the default and costs you nothing. Same story for the ⓘ details popover and the image-tile preview — see What ships enabled. For tool-call rows, thinking sections and other rich segments, addSegment / appendToSegment / updateSegment (same imperative API) stream structured segments the same way injectTokenStream streams plain text.