Skip to content

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.

On this page

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.

ts
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);
});

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.

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

ts
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 over

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

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.

ts
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();
});
ts
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.

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

ts
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 buffered

AparteStreamParser 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 stream parseAparteEventStream returns, as something you can for 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() and copyText(text) — an id and a copy that work on a plain http:// address too, where crypto.randomUUID does 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.

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.