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 anAsyncIterable<string>into a message, token by token, with the live-streaming UI (cursor, auto-scroll). Resolves when the iterable completes;stopTokenStream()cancels.
The pattern
Section titled “The pattern”- Listen to
onMessageSentfor the user’s message and forward it to your loop. The user bubble is appended automatically on send — don’t add it yourself. - When your loop starts answering,
appendMessagean empty assistant message with a fresh id. 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});import { useCallback } from 'react';import { AparteChat, useAparteChat } from '@aparte/react';import '@aparte/core/styles.css';
export function Chat() { const chat = useAparteChat();
const onMessageSent = useCallback(async (event: { content: string }) => { const id = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. chat.ref.current?.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now(), }); await chat.ref.current?.injectTokenStream(id, myAgentLoop(event.content)); }, [chat.ref]);
return ( <AparteChat ref={chat.ref} messages={chat.messages} onMessagesChange={chat.setMessages} onMessageSent={onMessageSent} /> );}<script setup lang="ts">import { AparteChat, useAparteChat } from '@aparte/vue';import '@aparte/core/styles.css';
const chat = useAparteChat();
async function onMessageSent(event: { content: string }) { const id = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. chat.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await chat.injectTokenStream(id, myAgentLoop(event.content));}</script>
<template> <AparteChat :ref="chat.chatRef" :messages="chat.messages.value" @messages-change="chat.onMessagesChange" @message-sent="onMessageSent" /></template><script lang="ts"> import { AparteChat, createAparteChat } from '@aparte/svelte'; import '@aparte/core/styles.css';
const chat = createAparteChat(); const { messages } = chat; let comp: AparteChat | null = null; $: chat.connect(comp);
async function onMessageSent(event: CustomEvent<{ content: string }>) { const id = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. chat.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await chat.injectTokenStream(id, myAgentLoop(event.detail.content)); }</script>
<AparteChat bind:this={comp} messages={$messages} on:messagesChange={(e) => chat.onMessagesChange(e.detail)} on:messageSent={onMessageSent}/>The imperative API lives on the component instance, so it is reached with a viewChild
rather than through a helper. injectTokenStream also accepts an RxJS Observable<string>
here — the same call, in the shape Angular already speaks.
import { Component, viewChild } from '@angular/core';import { AparteChatComponent } from '@aparte/angular';import type { AparteMessage, AparteSendEventDetail } from '@aparte/core';
@Component({ selector: 'app-chat', standalone: true, imports: [AparteChatComponent], template: ` <aparte-chat [messages]="messages" (messagesChange)="messages = $event" (messageSent)="onMessageSent($event)" ></aparte-chat> `,})export class ChatComponent { readonly chat = viewChild.required(AparteChatComponent); messages: AparteMessage[] = [];
async onMessageSent(event: AparteSendEventDetail) { const id = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. this.chat().appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await this.chat().injectTokenStream(id, myAgentLoop(event.content)); }}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.
Push-based sources: the queue adapter
Section titled “Push-based sources: the queue adapter”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.
The pieces core exports for this
Section titled “The pieces core exports for this”Driving your own loop means doing by hand what AparteClient does for you. These are
exported so you do not have to reimplement them:
| Export | What it does | When you want it |
|---|---|---|
AparteStreamParser | Incrementally 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 |
parseMarkdownToSegments | The one-shot version of the above, for a complete reply | You already have the whole answer (a non-streaming call, or replaying history) |
contentToText | Flattens string | AparteContentPart[] to its text | Your transport or logs need the text of a multimodal message |
readableToAsyncIterable | Wraps a ReadableStream so for await works, honouring an AbortSignal | You are consuming a provider’s parseStream directly — Chromium does not async-iterate streams |
uuid | An id that works on plain http:// — crypto.randomUUID first, a cheap fallback where it does not exist | You generate message or host ids and your app also runs on a LAN address |
copyText | Copies to the clipboard on plain http:// too — navigator.clipboard first, execCommand('copy') where it does not exist | You add a copy button of your own; core’s three use it |
registerAllComponents | References 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 registry | Your build is aggressive, or you load @aparte/core through a dynamic import() |
AparteChatHost | The streaming / branch / host-method orchestration the four wrappers all bind to — everything AparteClient does minus the transport | You are writing a fifth framework binding, or driving core from a framework we do not ship |
populateBubbleFromMessage | Fills an <aparte-chat-bubble> from an AparteMessage — segments, attachments, sibling nav, action bar | You render bubbles yourself instead of letting the viewport own them |
parseAparteEventStream | Reads the NDJSON wire format createAparteChatHandler emits back into AparteStreamEvents | You 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 bufferedvoid trailing;
void contentToText([{ type: 'text', text: 'hello' }]); // 'hello'Reasoning models: thinkingDelimiters
Section titled “Reasoning models: thinkingDelimiters”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.
Make the composer follow your turn
Section titled “Make the composer follow your turn”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:
| Event | What the composer does |
|---|---|
aparte-message-start | streaming = true — the button becomes Stop |
aparte-message-done, aparte-message-error, aparte-message-aborted | streaming = 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.
What you give up
Section titled “What you give up”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.