Show a loop of your own in the chat
Drive the chat from a loop that runs elsewhere — your backend, a worker, an Electron process — without AparteClient, streaming text, reasoning and tool calls into it.
When the loop lives somewhere else, the chat only shows it: your code puts messages and tokens in,
and the chat draws, streams and scrolls them as it does for AparteClient. No client, no provider,
no transport.
Stream tokens in
Section titled “Stream tokens in”import { uuid } from '@aparte/core';
const chat = document.querySelector('aparte-chat')!;
chat.addEventListener('aparte-send', async (event) => {
const viewport = chat.viewport!;
viewport.appendMessage({ id: uuid(), role: 'user', content: event.detail.content, timestamp: Date.now() });
const id = uuid();
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);
});import { useCallback } from 'react';
import { AparteChat, useAparteChat } from '@aparte/react';
import { uuid } from '@aparte/core';
import '@aparte/core/styles.css';
export function Chat() {
const chat = useAparteChat();
const onMessageSent = useCallback(async (event: { content: string }) => {
const id = uuid();
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 { uuid } from '@aparte/core';
import '@aparte/core/styles.css';
const chat = useAparteChat();
async function onMessageSent(event: { content: string }) {
const id = uuid();
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, type AparteChatImperativeApi } from '@aparte/svelte';
import { uuid } from '@aparte/core';
import '@aparte/core/styles.css';
const chat = createAparteChat();
const { messages } = chat;
let comp: AparteChatImperativeApi | null = null;
$: chat.connect(comp);
async function onMessageSent(event: { content: string }) {
const id = uuid();
chat.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
await chat.injectTokenStream(id, myAgentLoop(event.content));
}
</script>
<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)} onmessageSent={onMessageSent} />import { Component, viewChild } from '@angular/core';
import { AparteChatComponent } from '@aparte/angular';
import { uuid } from '@aparte/core';
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 = uuid();
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’s
SDK, whatever produces tokens. The framework packages add the person’s message when it is sent; in
plain HTML, add it yourself, as above. Then add an empty assistant message, which shows the waiting
dots until the first token, and stream into it: injectTokenStream in a framework, which also
accepts an RxJS Observable in Angular, appendToken and completeMessage on the message list in
plain HTML. Add the message before streaming into it: an id the framework does not know is kept by
the message list alone, out of your messages. A new injectTokenStream cancels the one before, and
stopTokenStream() cancels it; the source is closed, so a generator’s finally runs.
injectTokenStream also sends the turn’s events: the send button is Stop while it streams, Stop
cancels it, and the conversation manager saves the reply when
it ends. In plain HTML, Make Stop work shows the events to send yourself.
From a source that pushes
Section titled “From a source that pushes”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>((resolve) => { notify = resolve; });
notify = null;
}
},
};
}A WebSocket, an Electron bridge or postMessage pushes its tokens rather than being pulled: push
them into the queue, call end() when the reply is done, and stream queue.stream() into the
message.
Show reasoning and tool calls
Section titled “Show reasoning and tool calls”const id = uuid();
chat.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); // the reply the parts go into
chat.addSegment({ id: 'think-1', type: 'thinking', content: '' });
for await (const chunk of reasoning) chat.appendToSegment('think-1', chunk);
chat.updateSegment('think-1', { isStreaming: false });
chat.addSegment({ id: 'call-1', type: 'tool_call', status: 'pending', toolCall: { id: 'tc-1', name: 'search', input: { q: 'pricing' } } });
chat.updateSegment('call-1', { status: 'resolved', result: '3 pages found.' });
chat.updateMessage(id, { status: 'completed' }); // or 'error': the turn is overA reply made of parts — reasoning, a tool call, an answer — is made of segments, each drawn by its
renderer. On a framework chat, addSegment, appendToSegment, updateSegment and
removeSegment act on the last message, so add the assistant message first: after a send, the
last message is the person’s. On the message list, the message is named first:
addSegment(messageId, segment), appendToSegment(messageId, segmentId, chunk),
updateSegment(messageId, segmentId, patch). Once a message has segments, its plain content is
no longer drawn: give the answer a text segment of its own.
A turn of segments does not end by itself: give it a final status with updateMessage, which also
closes the segments still open. In plain HTML the transcript stays read-only until you do; on a
framework chat it is read-only between aparte-message-start and the event that ends the turn.
Make Stop work
Section titled “Make Stop work”On a framework chat, injectTokenStream does it: nothing to add. A loop that writes segments
rather than tokens, or a stream written with appendToken in plain HTML, is not a turn the
composer can see: send the turn’s events yourself, on the chat’s element (the <aparte-chat>,
or the framework component’s root, given an id), bubbling: aparte-message-start turns the send button to Stop, and
aparte-message-done, -error or -aborted turns it back. Stop sends aparte-abort on
window, with the chat’s id as targetId, for your loop to cancel, and the
conversation manager saves a reply when it ends. The
events reference types each detail.
import { uuid } from '@aparte/core';
const chat = document.querySelector('aparte-chat')!;
const lifecycle = (name: string, detail: object) =>
chat.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: { targetId: chat.id, ...detail } }));
let controller = new AbortController();
chat.addEventListener('aparte-send', async (event) => {
const viewport = chat.viewport!;
// …the person's message, added as in the first example
const id = uuid();
controller = new AbortController();
viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
lifecycle('aparte-message-start', { messageId: id, role: 'assistant' });
try {
for await (const token of myAgentLoop(event.detail.content, controller.signal)) viewport.appendToken(id, token);
} finally {
viewport.completeMessage(id);
lifecycle(controller.signal.aborted ? 'aparte-message-aborted' : 'aparte-message-done', { messageId: id, role: 'assistant' });
}
});
window.addEventListener('aparte-abort', (event) => {
if (!event.detail?.targetId || event.detail.targetId === chat.id) controller.abort();
});Replay a stored conversation
Section titled “Replay a stored conversation”import type { AparteChatImperativeApi, AparteMessage } from '@aparte/core';
declare const chat: AparteChatImperativeApi;
declare const stored: AparteMessage[]; // from your backend
chat.clearMessages();
for (const message of stored) chat.appendMessage(message, { historical: true });A historical message is a record, not a turn: its segments are taken as they are and nothing on it
streams, so a tool call saved while it ran does not come back spinning. In plain HTML,
viewport.setMessages(stored) replaces the whole conversation the same way.
What you give up
Section titled “What you give up”The parts AparteClient drives do not run: no tool calls or approvals of its own, no retry or edit
sending again, no request to build. The retry and edit buttons are off, and when you turn them on
with aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }) they only send
aparte-retry and aparte-edit, which your loop answers as in
Conversations and branches. Files the
person attaches arrive on the send event, for your loop to read, as in
Attachments. requestUserInput still asks the person
anything, from your code (Ask the user a question).
The pieces core exports for this
Section titled “The pieces core exports for this”import { aparteGlobalConfig, AparteStreamParser } from '@aparte/core';
const parser = new AparteStreamParser({
blocks: aparteGlobalConfig.getStreamBlocks(),
thinkingDelimiters: [
{ start: '<think>', end: '</think>' },
{ start: '<|begin_of_thought|>', end: '<|end_of_thought|>' },
],
});
for (const delta of ['Here: ', '```', 'ts\n', 'const x = 1;\n', '```']) {
const { segments } = parser.parse(delta); // the segments each delta completes
}
parser.finalize(); // and whatever is still bufferedAparteStreamParser turns a model’s text into segments as it streams — text, code fences,
reasoning, and the tags registered with aparteGlobalConfig.registerStreamBlock when you pass
them as its blocks, as above. Its reasoning delimiters replace the default <think> and
<thinking>: list every pair you want. They are this parser’s own: AparteClient reads the
defaults, so a model that marks its reasoning another way needs this parser in a loop of yours, or
a provider that sends the reasoning as its own events. The rest, each one line:
parseMarkdownToSegments(text, options)— the same, for a whole reply at once, to store or inspect it.readableToAsyncIterable(events, signal)— the event streamparseAparteEventStreamreturns, as something you canfor await, stopped by a signal.parseAparteEventStream(body)— the stream a backend transport endpoint answers, read back into events.contentToText(content)— the text of a message whose content has several parts.uuid()andcopyText(text)— an id and a copy that work on a plainhttp://address too, wherecrypto.randomUUIDdoes not.registerAllComponents()— keeps every element in a bundle whose bundler drops side-effect imports, and warns naming any tag that is not registered.populateBubbleFromMessage(bubble, message)— a message element filled from a message.AparteChatHost— what the framework packages bind to, for a framework of your own; the client reference has its binding.
aparté’s loop, elsewhere
Section titled “aparté’s loop, elsewhere”If the loop is yours to write, runStreamAgent from @aparte/engine is the loop
AparteClient runs — headless, with no DOM — in Node, a worker or an Electron process. On a server,
the server loop runs it next to your tools and keeps the page’s tool rows,
Stop and approvals.