Bring your own loop (display-only): Driving the transcript from a loop you own: appendMessage, segments, token streams, finishing a turn, the wrappers' imperative API, the backend transport. # Keep the key on your server > Send each request through your own /api/chat — the page names a provider, your server holds the key, calls the model and streams the reply back — with the chat unchanged. ## Write the route [Section titled “Write the route”](#write-the-route) ```bash npm install @aparte/core @aparte/provider-openai-compat ``` ```ts // app/api/chat/route.ts (Next.js) import { createAparteChatHandler } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup export const POST = createAparteChatHandler({ providers: { openai: createOpenAICompatProvider(presets.OPENAI) }, resolveKey: (providerId) => process.env[`${providerId.toUpperCase()}_KEY`], authorize: async (req) => Boolean(await getSession(req)), // required: this route spends your key }); ``` `createAparteChatHandler` returns a Fetch handler, `Request` to `Response`, for a Next.js route in the Node runtime, Node, Deno or Bun. It reads `{ providerId, request }`, runs that provider on the server — the request, the key, the vendor call, the stream — and streams the reply back as NDJSON, one event per line; a request with `stream: false` gets a `{ text }` body. On those runtimes `@aparte/core` resolves to its `node` entry, which has no DOM ([On the server](/frameworks/elements/#on-the-server)); a Worker or an edge runtime does not ask for that entry. | Option | What it does | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `providers` | the providers, keyed by the `providerId` the page sends: an unknown id is a 400, a provider that is not a [format adapter](/providers/#write-your-own-provider) a 500 | | `authorize(req)` | required, run before anything else: `false` answers 401, a `Response` answers itself, `true` goes on | | `resolveKey(providerId)` | the vendor key, from your environment or a secret store; `undefined` for a keyless server | | `fetchImpl` | the `fetch` that calls the vendor | These are the `AparteChatHandlerOptions`. authorize has to authenticate `Boolean(req.headers.get('cookie'))` passes any request that carries any cookie, a cross-site one included, and the route then spends your key for anyone who can load it. Call the session lookup that guards your other routes. ## What the route accepts [Section titled “What the route accepts”](#what-the-route-accepts) The page sends a `providerId`, never a URL: the vendor’s address comes from the provider in your map, so no request can point your server at another host. A vendor’s error keeps its status, and its body reaches the page as `{ error: { message, code?, type? } }`, without the vendor’s own text. The rest of the request is the page’s: `modelId`, the messages, the system prompt, the tools and `maxTokens` reach the provider as sent, so `authorize` decides who calls the route, not what they send. To limit the models or the size, check the body in a wrapper of yours, or in `authorize` through `req.clone().json()`: clone it, because the handler reads the body itself. ## Point the page at it [Section titled “Point the page at it”](#point-the-page-at-it) ```ts import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENAI)); // the id the route knows, no key aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1-mini' }); new AparteClient().start(); ``` The page posts `{ providerId, request }` to your route and streams the reply into the chat; no key is in it. Register a provider under each id the route knows: the page reads what a model can do from it, and refuses to send to an id it does not have. `BackendTransportOptions` takes the `endpoint`, `headers` sent with every request, and `buildBody(request, providerId)` for a route that expects another body. A cookie goes along only to a route on the page’s own origin, so a route on another origin is authenticated through `headers`. The tools and the loop stay in the page: the route makes one model call at a time. When the tools live on your server, move the loop there too, with [the server loop](/guides/server-loop/). ## If your server already uses the AI SDK [Section titled “If your server already uses the AI SDK”](#if-your-server-already-uses-the-ai-sdk) ```bash npm install @aparte/core @aparte/provider-ai-sdk ai @ai-sdk/anthropic ``` ```ts // app/api/chat/route.ts (Next.js) — the AI SDK on your server, aparté in the page import { createAnthropic } from '@ai-sdk/anthropic'; import { streamText } from 'ai'; import type { AparteChatRequest, AparteStreamEvent } from '@aparte/core'; import { fullStreamToAparteEvents, toModelMessages, toToolChoice, toToolSet } from '@aparte/provider-ai-sdk'; declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup const MODELS = ['claude-sonnet-4-5', 'claude-haiku-4-5']; export async function POST(req: Request): Promise { if (!(await getSession(req))) return new Response('Unauthorized', { status: 401 }); const { request } = (await req.json()) as { request: AparteChatRequest }; if (!MODELS.includes(request.modelId)) return new Response('Unknown model', { status: 400 }); const result = streamText({ model: createAnthropic({ apiKey: process.env['ANTHROPIC_API_KEY'] })(request.modelId), messages: toModelMessages(request.messages), temperature: request.temperature, maxOutputTokens: request.maxTokens, seed: request.seed, abortSignal: req.signal, ...(request.tools?.length ? { tools: toToolSet(request.tools), toolChoice: toToolChoice(request.toolChoice) ?? 'auto' } : {}), }); if (request.stream === false) { try { return Response.json({ text: await result.text }); } catch (err) { console.error('[api/chat] vendor request failed', err); return Response.json({ error: { message: 'Vendor request failed.' } }, { status: 502 }); } } const ndjson = fullStreamToAparteEvents(result.fullStream) .pipeThrough(new TransformStream({ transform: (event, controller) => { if (event.type === 'error') { console.error('[api/chat] vendor stream error', event.message); // the vendor's words stay in your log controller.enqueue({ type: 'error', message: 'Vendor request failed.' }); return; } controller.enqueue(event); }, })) .pipeThrough(new TransformStream({ transform: (event, controller) => controller.enqueue(`${JSON.stringify(event)}\n`), })) .pipeThrough(new TextEncoderStream()); return new Response(ndjson, { headers: { 'content-type': 'application/x-ndjson' } }); } ``` ```ts import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core'; import { createAiSdkProvider } from '@aparte/provider-ai-sdk'; // The id the page sends, and what the model can do; the call itself is the route's. aparteGlobalConfig.registerAIProvider(createAiSdkProvider({ id: 'anthropic', name: 'Anthropic', languageModel: () => { throw new Error('This provider runs on the server.'); }, })); aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'anthropic', defaultModel: 'claude-sonnet-4-5' }); new AparteClient().start(); ``` The handler above runs format adapters, and the [AI SDK bridge](/providers/ai/ai-sdk/) is not one: it makes its own call through `streamText`. Its four helpers make the same route by hand — `toModelMessages` and `toToolChoice` translate the request, `toToolSet` gives the SDK the tools as declarations, which the page’s loop runs, and `fullStreamToAparteEvents` turns the SDK’s stream into the events the page reads. The page registers a provider under the id it sends and names one of the route’s models; its `languageModel` is never called, since the route makes the call. Two things in this route are what the handler does for you: the vendor’s error replaced before it leaves the server, and the `{ text }` answer to a request with `stream: false`. The check on the model is one the handler does not make; add it to either route. ## Write the stream yourself [Section titled “Write the stream yourself”](#write-the-stream-yourself) ```text {"type":"text","delta":"Hel"} {"type":"text","delta":"lo."} {"type":"done","usage":{"inputTokens":12,"outputTokens":3,"totalTokens":15}} ``` A route of your own answers `AparteBackendTransport` with NDJSON, one `AparteStreamEvent` per line — aparté’s events, not the AI SDK’s data stream protocol. `parseAparteEventStream(body)` reads that stream back into events. A transport of your own is set with `aparteGlobalConfig.setTransport`, like the two built in ([`AparteTransport`](/reference/core/transports/#apartetransport)). ## Next [Section titled “Next”](#next) * [Connect a model The providers your route's map takes.](/providers/) * [Run the loop on your server The tools on your server too, next to the loop.](/guides/server-loop/) # 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”](#stream-tokens-in) * Vanilla ```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); }); ``` * React ```tsx 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 ( ); } ``` * Vue ```vue ``` * Svelte ```svelte chat.onMessagesChange(m)} onmessageSent={onMessageSent} /> ``` * Angular ```ts 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: ``, }) 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`: 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](/guides/conversation-persistence/) saves the reply when it ends. In plain HTML, [Make Stop work](#make-stop-work) shows the events to send yourself. ## From a source that pushes [Section titled “From a source that pushes”](#from-a-source-that-pushes) ```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 { for (;;) { while (buffer.length) yield buffer.shift()!; if (done) return; await new Promise((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”](#show-reasoning-and-tool-calls) ```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. ## Make Stop work [Section titled “Make Stop work”](#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 ``, 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](/guides/conversation-persistence/) saves a reply when it ends. The [events reference](/reference/events/) 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(); }); ``` ## Replay a stored conversation [Section titled “Replay a stored conversation”](#replay-a-stored-conversation) ```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. ## What you give up [Section titled “What you give up”](#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](/guides/conversations-branching/#with-your-own-loop). Files the person attaches arrive on the send event, for your loop to read, as in [Attachments](/guides/attachments/#with-your-own-loop). `requestUserInput` still asks the person anything, from your code ([Ask the user a question](/guides/elicitation/)). ## The pieces core exports for this [Section titled “The pieces core exports for this”](#the-pieces-core-exports-for-this) ````ts import { aparteGlobalConfig, AparteStreamParser } from '@aparte/core'; const parser = new AparteStreamParser({ blocks: aparteGlobalConfig.getStreamBlocks(), thinkingDelimiters: [ { start: '', end: '' }, { 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 `` and ``: 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](/guides/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](/reference/core/client/) has its binding. ## aparté’s loop, elsewhere [Section titled “aparté’s loop, elsewhere”](#apartés-loop-elsewhere) If the loop is yours to write, `runStreamAgent` from [`@aparte/engine`](/guides/engine/) is the loop `AparteClient` runs — headless, with no DOM — in Node, a worker or an Electron process. On a server, [the server loop](/guides/server-loop/) runs it next to your tools and keeps the page’s tool rows, Stop and approvals. ## Next [Section titled “Next”](#next) * [The agent engine The loop on its own, in any JavaScript runtime.](/guides/engine/) * [Run the loop on your server The same loop, with the page drawing it.](/guides/server-loop/) * [Frameworks The chat in React, Vue, Svelte and Angular, and its ref.](/frameworks/) # Run the agent loop on its own > The loop that streams a reply and runs its tools, as a package of its own — run it headless in Node or a worker, wrap the one AparteClient runs, own its history, or pause it on a call and resume later. ## Run it headless [Section titled “Run it headless”](#run-it-headless) ```bash npm install @aparte/engine ``` ```ts import { runStreamAgent, type StreamChatEvent, type StreamChatRequest } from '@aparte/engine'; // A stand-in for the model: it calls the clock, then answers with what the clock said. async function* reply(request: StreamChatRequest): AsyncIterable { const last = request.messages[request.messages.length - 1]; if (last?.role === 'tool') yield { type: 'text', delta: `It is ${String(last.content)}.` }; else yield { type: 'tool_use', id: 'c1', name: 'clock', input: {} }; yield { type: 'done' }; } await runStreamAgent({ messageId: 'a1', baseRequest: { messages: [{ role: 'user', content: 'What time is it?' }], modelId: 'my-model' }, transportCall: async (request) => reply(request), toolLookup: (name) => (name === 'clock' ? async () => ({ content: new Date().toISOString() }) : undefined), emitter: (event) => { if (event.type === 'text-delta') process.stdout.write(event.delta); }, signal: new AbortController().signal, }); ``` `runStreamAgent` is the loop `AparteClient` runs: it calls the model, reads the stream, runs the tools the model calls, gives their results back and calls again, until the model answers or a cap is reached. It has no dependency and no DOM, so it runs in the browser, Node, a worker or an Electron process, and installs alone; it is ESM only, and `@aparte/core` depends on it. It resolves to the run’s usage, and throws when the stream reports an error or a tool fails other than by an abort. `transportCall` returns the model’s events — `text`, `thinking`, `tool_use`, `error` and `done` — or a plain string from a model that does not stream. Each turn calls it again with the conversation so far, the `tool` messages answering the last calls included, as `reply` reads above. ## Options [Section titled “Options”](#options) | Option | What it is | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `messageId` | the id of the reply this run writes; the loop does not read it, a runner that forwards the run does | | `baseRequest` | the first request: its `messages` and `modelId`, the tools, `toolChoice` | | `transportCall(request)` | calls the model with the request of each turn | | `toolLookup(name)` | the handler of a tool, `(call, signal) => Promise<{ content, structuredContent? }>` | | `toolConfigLookup(name)` | a tool’s `maxTurns`, `needsApproval` and `external` | | `approvalResolver(call, signal)` | answers a call that needs approval: `{ approved, payload?, instruction?, reason? }` | | `emitter(event)` | receives every event of the run | | `signal` | stops the run | | `maxTurns`, `toolTimeoutMs` | the turns of one run, ten by default, and a tool’s time, five minutes by default | | `onHistoryAppend(message)` | each message the loop adds to the history, for [a host that keeps its own](#own-the-history) | | `deferral` | [pause on a call](#pause-and-resume) rather than wait for it | | `idGen`, `providerId` | the id of the call the loop makes for a forced `toolChoice`, and your provider’s id, passed untouched to a runner that sends the run elsewhere | A tool’s `needsApproval` may be a function of the call, answering `true`, `false`, `'ask'` or `'deny'`. A gated call goes to `approvalResolver`, a denied one included, which is then refused without a wait being announced, its `reason` being what the model reads; without a resolver, a gated or denied call is aborted and the run ends. A tool marked `external` runs elsewhere: with `deferral` the run pauses for its result, and without it the call is aborted and the run ends. A `toolChoice` of `{ name, input }` runs that tool’s handler before the model is called, and the run goes on with no tools; `{ name }` alone makes the model call it on the first turn, and the later turns are back to `'auto'`. ## Events [Section titled “Events”](#events) | Event | When | | ---------------------------------------------------------- | ------------------------------------------------------------------- | | `run-start`, `turn-start` | the run begins; each streamed reply from the model begins | | `text-delta`, `thinking-delta` | the reply’s text and reasoning arrive | | `text-flush` | a turn’s text is complete | | `tool-start` | the model calls a tool: `{ toolCallId, name, input }` | | `tool-awaiting-approval`, `tool-approved`, `tool-rejected` | a call waits for a decision, and gets it | | `tool-resolved`, `tool-failed`, `tool-aborted` | a call’s result, its error, or its end without either | | `turn-limit-exceeded` | a cap was reached: `{ scope: 'global' \| 'tool', limit }` | | `run-aborted` | the run was stopped; `run-done` follows | | `run-done` | the run ends, with its usage | | `run-suspended` | the run ends paused, with its pending calls, in place of `run-done` | A run that fails emits neither of the last two: `runStreamAgent` throws. `StreamRunEvent` types them all, and the [engine reference](/reference/engine/) lists every field. ## The streamRunner seam [Section titled “The streamRunner seam”](#the-streamrunner-seam) ```ts import { AparteClient } from '@aparte/core'; import { runStreamAgent } from '@aparte/engine'; new AparteClient({ streamRunner: (opts) => runStreamAgent({ ...opts, emitter: (event) => { if (event.type === 'tool-resolved') console.info('tool answered', event.toolCallId); opts.emitter(event); }, }), }).start(); ``` `AparteClient` runs `runStreamAgent` by itself; `streamRunner` is the place to change that. Wrap it to watch or change a run, as above — a wrapped loop still calls the model from the page, so a provider has to be selected — or give the client another function with the same signature, an `AparteStreamRunner`: a loop of your own that sends the same events, which the chat draws as it draws its own. `createAgentStreamRunner` ([server loop](/guides/server-loop/)) and `createAgUiRunner` ([AG-UI](/providers/agent/ag-ui/)) are two. ## Own the history [Section titled “Own the history”](#own-the-history) ```ts const log = new PromptLog(); // your append-only transcript await runStreamAgent({ // …messageId, emitter, signal, toolLookup baseRequest: { messages: [], modelId: 'my-model' }, onHistoryAppend: (message) => log.append(message), // the calls, each result, each reply transportCall: () => myCompletion(log.render()), // your bytes, extended, never rebuilt }); ``` For a server with a prefix cache — llama.cpp slots, vLLM — keep the transcript yourself: `transportCall` may ignore `request.messages`, and `onHistoryAppend` reports each message the loop adds, in order, before the call that carries it. It never reports the messages you passed in `baseRequest`: write the person’s turn into your log yourself before the run. The assistant’s message is reported once, on its first call, and its `content` and `toolCalls` are completed later in the same turn: keep the reference and read it when you write, rather than a copy taken on arrival. Through the client, add it at the seam: `streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend })`. Writing tools, calls and results into a raw prompt stays yours. ## Pause and resume [Section titled “Pause and resume”](#pause-and-resume) ```ts import { runStreamAgent, unansweredTail } from '@aparte/engine'; // First run: it pauses on the call that needs a person. const history = [...run.baseRequest.messages]; await runStreamAgent({ ...run, deferral: {}, onHistoryAppend: (message) => history.push(message), emitter: (event) => { if (event.type === 'run-suspended') savePending(history, event.pending); // [{ toolCallId, name, input, reason }] }, }); // Later, with the answer: the history ends on the paused call, which runs first. await runStreamAgent({ ...run, baseRequest: { ...run.baseRequest, messages: history }, deferral: { outcomes: { [callId]: { kind: 'decision', approved: true } } }, }); unansweredTail(history); // the calls a history leaves open ``` With `deferral`, a call that needs a person, or an `external` one, is not awaited: the run reads the reply to its end, leaves its calls pending, and ends on `run-suspended`, whose `pending` says why each one waits — `'approval'`, `'external'`, or `'queued'` behind them. Keep the history as it grows, through `onHistoryAppend`: it ends on the assistant message that declares the paused calls, and the run that resumes needs it. A later run whose history ends on those calls, with their answers in `deferral.outcomes`, picks up where it stopped: a decision, a tool’s result, or a failure. A denied call is not paused: `approvalResolver` refuses it, as above. The [server loop](/guides/server-loop/) is built on this. The loop passes on the usage the model reports, and decides nothing about a long conversation: [Long conversations](/guides/long-conversations/) shows the gauge and the compaction that answer it. ## Next [Section titled “Next”](#next) * [Run the loop on your server The loop next to your tools, with the page drawing it.](/guides/server-loop/) * [Your own loop Show a loop that is not aparté's in the chat.](/guides/bring-your-own-loop/) * [Engine reference Every option, event and type, generated from the source.](/reference/engine/) # @aparte/engine — API reference > Generated reference for @aparte/engine — runStreamAgent and its stream events. ## Interfaces [Section titled “Interfaces”](#interfaces) ### StreamUsage [Section titled “StreamUsage”](#streamusage) Defined in: [packages/engine/src/agent/stream-events.ts:34](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L34) Token usage. Structurally a superset-compatible mirror of `AparteUsage`: the five common fields plus an index signature that carries the provider-specific rest (ttft/decode/phases/…) opaquely — the loop transports usage, never reads past these five. #### Properties [Section titled “Properties”](#properties) ##### inputTokens [Section titled “inputTokens”](#inputtokens) ```ts inputTokens: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:35](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L35) ##### outputTokens [Section titled “outputTokens”](#outputtokens) ```ts outputTokens: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:36](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L36) ##### totalTokens? [Section titled “totalTokens?”](#totaltokens) ```ts optional totalTokens?: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:37](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L37) ##### cacheReadTokens? [Section titled “cacheReadTokens?”](#cachereadtokens) ```ts optional cacheReadTokens?: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:38](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L38) ##### durationMs? [Section titled “durationMs?”](#durationms) ```ts optional durationMs?: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:39](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L39) *** ### StreamToolCall [Section titled “StreamToolCall”](#streamtoolcall) Defined in: [packages/engine/src/agent/stream-events.ts:57](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L57) One tool call as surfaced by the provider stream (mirrors `AparteToolCall`). #### Properties [Section titled “Properties”](#properties-1) ##### id [Section titled “id”](#id) ```ts id: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:58](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L58) ##### name [Section titled “name”](#name) ```ts name: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:59](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L59) ##### input [Section titled “input”](#input) ```ts input: Record; ``` Defined in: [packages/engine/src/agent/stream-events.ts:68](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L68) `Record`, mirroring core exactly, not `unknown`. `unknown` looked safer — the loop does not read tool input, it forwards it — but the seam needs assignability in BOTH directions (`transportCall` is contravariant), and `unknown` is not assignable to `Record`. Core’s composition compiles against it. *** ### StreamTool [Section titled “StreamTool”](#streamtool) Defined in: [packages/engine/src/agent/stream-events.ts:90](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L90) A tool declaration, structurally mirroring core’s `AparteTool`. The loop forwards the inventory to the transport and reads `maxTurns` and `needsApproval` from its own lookup — it never interprets the schema. Mirrored exactly all the same, because the seam needs assignability in both directions; core’s composition compiles against it. #### Properties [Section titled “Properties”](#properties-2) ##### name [Section titled “name”](#name-1) ```ts name: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:91](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L91) ##### description [Section titled “description”](#description) ```ts description: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:92](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L92) ##### inputSchema [Section titled “inputSchema”](#inputschema) ```ts inputSchema: Record; ``` Defined in: [packages/engine/src/agent/stream-events.ts:93](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L93) ##### systemPrompt? [Section titled “systemPrompt?”](#systemprompt) ```ts optional systemPrompt?: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:94](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L94) ##### maxTurns? [Section titled “maxTurns?”](#maxturns) ```ts optional maxTurns?: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:95](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L95) ##### needsApproval? [Section titled “needsApproval?”](#needsapproval) ```ts optional needsApproval?: boolean; ``` Defined in: [packages/engine/src/agent/stream-events.ts:96](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L96) *** ### StreamTextPart [Section titled “StreamTextPart”](#streamtextpart) Defined in: [packages/engine/src/agent/stream-events.ts:113](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L113) Multimodal content parts — a structural mirror of core’s `AparteContentPart`. Mirrored rather than imported for the same reason as everything else in this file: engine stays zero-import at runtime. Mirrored EXACTLY rather than loosened to `unknown[]`, which was the first attempt: `transportCall` is contravariant, so the message type has to be assignable to core’s AND core’s to it, and `unknown[]` fails the second direction. Core compiles `streamRunner: runStreamAgent`, so a part added on one side and not the other is a typecheck error there rather than a silent divergence. The loop never looks inside content — it carries messages to the transport and appends results. This exists to make the seam type-check, not to be read. #### Properties [Section titled “Properties”](#properties-3) ##### type [Section titled “type”](#type) ```ts type: "text"; ``` Defined in: [packages/engine/src/agent/stream-events.ts:113](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L113) ##### text [Section titled “text”](#text) ```ts text: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:113](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L113) *** ### StreamImagePart [Section titled “StreamImagePart”](#streamimagepart) Defined in: [packages/engine/src/agent/stream-events.ts:114](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L114) #### Properties [Section titled “Properties”](#properties-4) ##### type [Section titled “type”](#type-1) ```ts type: "image"; ``` Defined in: [packages/engine/src/agent/stream-events.ts:114](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L114) ##### image [Section titled “image”](#image) ```ts image: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:114](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L114) ##### mimeType? [Section titled “mimeType?”](#mimetype) ```ts optional mimeType?: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:114](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L114) *** ### StreamAgentMessage [Section titled “StreamAgentMessage”](#streamagentmessage) Defined in: [packages/engine/src/agent/stream-events.ts:131](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L131) A conversation message (mirrors `AparteChatMessage`). `role` used to be a bare `string`, justified by a comment saying the loop needed to push the envelope roles of a tool turn “without importing core’s union”. But core’s union already contained them — so the reason had not been true for some time, and the looseness cost something real: `transportCall` is contravariant, so this message type must ALSO be assignable to core’s, and a `string` role is not assignable to a closed union. That was the last link in the chain that stopped `streamRunner: runStreamAgent` from compiling. Mirrored structurally rather than imported, to keep this package zero-import at runtime; core, which depends on this package, compiles the two against each other. #### Properties [Section titled “Properties”](#properties-5) ##### role [Section titled “role”](#role) ```ts role: "user" | "assistant" | "system" | "tool"; ``` Defined in: [packages/engine/src/agent/stream-events.ts:132](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L132) ##### content [Section titled “content”](#content) ```ts content: string | StreamContentPart[]; ``` Defined in: [packages/engine/src/agent/stream-events.ts:142](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L142) Message content — carried, never inspected by this loop. Was plain `string`, because core’s `AparteChatMessage.content` became `string | AparteContentPart[]` for multimodal messages and this mirror never followed. The consequence was not subtle: `new AparteClient({ streamRunner: runStreamAgent })` — the one composition the two packages exist to make, and the headline of five docs pages — did not typecheck. ##### toolCalls? [Section titled “toolCalls?”](#toolcalls) ```ts optional toolCalls?: StreamToolCall[]; ``` Defined in: [packages/engine/src/agent/stream-events.ts:147](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L147) Present on an `'assistant'` message — the whole turn’s calls, grouped. What it said before them is that message’s own `content`, `''` when it said nothing. ##### toolCallId? [Section titled “toolCallId?”](#toolcallid) ```ts optional toolCallId?: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:149](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L149) Present on a `'tool'` message — which call it answers. ##### toolName? [Section titled “toolName?”](#toolname) ```ts optional toolName?: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:151](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L151) Present on a `'tool'` message — the name of the tool that ran. *** ### StreamChatRequest [Section titled “StreamChatRequest”](#streamchatrequest) Defined in: [packages/engine/src/agent/stream-events.ts:180](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L180) The request handed to the transport each turn (mirrors `AparteChatRequest`). The two fields the loop actually reads are DECLARED rather than reached through an index signature. They used to be dynamic (`[key: string]: unknown`), which read like “extra fields pass through” but is a requirement on the target of an assignment, not a permission — it is what made core’s `AparteChatRequest` un-assignable here and broke `streamRunner: runStreamAgent`. Declaring them is both the fix and the more honest description: these are the fields the loop branches on. Anything else a host attaches still passes through untouched, because TypeScript only excess-property-checks object literals. Types kept structural (not imported from core) so this package stays zero-import at runtime; core’s composition compiles the two against each other. #### Properties [Section titled “Properties”](#properties-6) ##### messages [Section titled “messages”](#messages) ```ts messages: StreamAgentMessage[]; ``` Defined in: [packages/engine/src/agent/stream-events.ts:181](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L181) ##### modelId [Section titled “modelId”](#modelid) ```ts modelId: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:188](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L188) Required, because `transportCall` is contravariant: the runner hands this request to a transport typed against core’s `AparteChatRequest`, where `modelId` is required. A mirror that omitted it made the whole runner un-assignable — from the opposite direction to the `messages` mismatch. ##### \_meta? [Section titled “\_meta?”](#_meta) ```ts optional _meta?: Record; ``` Defined in: [packages/engine/src/agent/stream-events.ts:190](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L190) Per-turn hints carried through for the host (`prefixSegments`, and whatever the consumer puts there); the loop reads none. ##### toolChoice? [Section titled “toolChoice?”](#toolchoice) ```ts optional toolChoice?: | { name: string; input?: Record; } | "auto" | "none"; ``` Defined in: [packages/engine/src/agent/stream-events.ts:197](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L197) `'none'` makes the loop drop the tool inventory for that turn. An object forces a tool, and forcing is a turn-1 instruction: the loop sends it on the first transport call of the run and lifts it (to `'auto'`) for every turn after, so the model can answer once the tool has. ##### tools? [Section titled “tools?”](#tools) ```ts optional tools?: StreamTool[]; ``` Defined in: [packages/engine/src/agent/stream-events.ts:199](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L199) The inventory the loop sends, and clears when `toolChoice` is `'none'`. *** ### StreamToolConfig [Section titled “StreamToolConfig”](#streamtoolconfig) Defined in: [packages/engine/src/agent/stream-events.ts:214](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L214) Per-tool loop configuration (mirrors the `AparteTool` subset the loop reads). #### Properties [Section titled “Properties”](#properties-7) ##### maxTurns? [Section titled “maxTurns?”](#maxturns-1) ```ts optional maxTurns?: number; ``` Defined in: [packages/engine/src/agent/stream-events.ts:215](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L215) ##### needsApproval? [Section titled “needsApproval?”](#needsapproval-1) ```ts optional needsApproval?: boolean | ((call) => boolean | "ask" | "deny"); ``` Defined in: [packages/engine/src/agent/stream-events.ts:229](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L229) Whether a call of this tool pauses for a decision. A boolean is the tool’s own declaration; a predicate decides PER CALL, from the arguments — the shape an approval policy needs (“a write to this path asks, a read does not”), and one a boolean could only approximate by gating every call and auto-approving most, which paints `awaiting-approval` on rows nobody was ever going to be asked about. The predicate has three answers, because a policy has three: `false` runs the call, `'ask'` (or `true`) puts it to someone — the loop announces the pause with `tool-awaiting-approval` — and `'deny'` means the decision is already made: the resolver is still consulted (it is the one channel that carries the refusal sentence back), but nothing is announced, since nobody is being asked. ##### external? [Section titled “external?”](#external) ```ts optional external?: boolean; ``` Defined in: [packages/engine/src/agent/stream-events.ts:236](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L236) Known to the model, executed elsewhere — a browser tool behind a server loop. The loop never looks up a handler for it: with `deferral` the call waits for its result in `deferral.outcomes`; without it, the call is aborted like a missing handler. *** ### StreamDeferredCall [Section titled “StreamDeferredCall”](#streamdeferredcall) Defined in: [packages/engine/src/agent/stream-events.ts:247](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L247) A call a suspended run left without an answer. #### Properties [Section titled “Properties”](#properties-8) ##### toolCallId [Section titled “toolCallId”](#toolcallid-1) ```ts toolCallId: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:248](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L248) ##### name [Section titled “name”](#name-2) ```ts name: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:249](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L249) ##### input [Section titled “input”](#input-1) ```ts input: unknown; ``` Defined in: [packages/engine/src/agent/stream-events.ts:250](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L250) ##### reason [Section titled “reason”](#reason) ```ts reason: StreamDeferredReason; ``` Defined in: [packages/engine/src/agent/stream-events.ts:251](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L251) *** ### StreamDeferral [Section titled “StreamDeferral”](#streamdeferral) Defined in: [packages/engine/src/agent/stream-events.ts:264](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L264) Defer instead of block — see `StreamRunOptions.deferral`. Data only, so a run it answers can be days old and on another machine. #### Properties [Section titled “Properties”](#properties-9) ##### outcomes? [Section titled “outcomes?”](#outcomes) ```ts optional outcomes?: Record; ``` Defined in: [packages/engine/src/agent/stream-events.ts:266](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L266) Answers for calls a previous run left pending, by tool call id. ##### token? [Section titled “token?”](#token) ```ts optional token?: string; ``` Defined in: [packages/engine/src/agent/stream-events.ts:268](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L268) Opaque to the loop, which never reads it: a remote runner carries its continuation ticket here. *** ### StreamRunOptions [Section titled “StreamRunOptions”](#streamrunoptions) Defined in: [packages/engine/src/agent/stream-run.ts:43](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L43) #### Properties [Section titled “Properties”](#properties-10) ##### messageId [Section titled “messageId”](#messageid) ```ts messageId: string; ``` Defined in: [packages/engine/src/agent/stream-run.ts:45](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L45) Id of the assistant message being streamed (opaque; carried in events). ##### baseRequest [Section titled “baseRequest”](#baserequest) ```ts baseRequest: StreamChatRequest; ``` Defined in: [packages/engine/src/agent/stream-run.ts:47](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L47) Turn-1 request; the loop clones its `messages` and enriches them per turn. ##### transportCall [Section titled “transportCall”](#transportcall) ```ts transportCall: (request) => Promise< | string | AsyncIterable>; ``` Defined in: [packages/engine/src/agent/stream-run.ts:54](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L54) Calls the transport with the (possibly enriched) request. Returns the structured stream, or a plain string for a non-streaming provider. Mirrors `getTransport().chat(provider, request, auth, ctx)` with provider/auth/ctx closed over by the adapter. ###### Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | | --------- | ----------------------------------------- | | `request` | [`StreamChatRequest`](#streamchatrequest) | ###### Returns [Section titled “Returns”](#returns) `Promise`< | `string` | `AsyncIterable`<[`StreamChatEvent`](#streamchatevent), `any`, `any`>> ##### toolLookup [Section titled “toolLookup”](#toollookup) ```ts toolLookup: (name) => StreamToolHandler | undefined; ``` Defined in: [packages/engine/src/agent/stream-run.ts:56](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L56) Resolves a tool’s handler by name (mirrors `aparteGlobalConfig.getToolHandler`). ###### Parameters [Section titled “Parameters”](#parameters-1) | Parameter | Type | | --------- | -------- | | `name` | `string` | ###### Returns [Section titled “Returns”](#returns-1) [`StreamToolHandler`](#streamtoolhandler) | `undefined` ##### toolConfigLookup? [Section titled “toolConfigLookup?”](#toolconfiglookup) ```ts optional toolConfigLookup?: (name) => StreamToolConfig | undefined; ``` Defined in: [packages/engine/src/agent/stream-run.ts:58](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L58) Resolves a tool’s loop config by name (maxTurns / needsApproval). ###### Parameters [Section titled “Parameters”](#parameters-2) | Parameter | Type | | --------- | -------- | | `name` | `string` | ###### Returns [Section titled “Returns”](#returns-2) [`StreamToolConfig`](#streamtoolconfig) | `undefined` ##### approvalResolver? [Section titled “approvalResolver?”](#approvalresolver) ```ts optional approvalResolver?: StreamApprovalResolver; ``` Defined in: [packages/engine/src/agent/stream-run.ts:60](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L60) HITL approval resolver for `needsApproval` tools (default: never called). ##### emitter [Section titled “emitter”](#emitter) ```ts emitter: StreamRunEmitter; ``` Defined in: [packages/engine/src/agent/stream-run.ts:62](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L62) Synchronous, ordered event sink consumed by the adapter. ##### signal [Section titled “signal”](#signal) ```ts signal: AbortSignal; ``` Defined in: [packages/engine/src/agent/stream-run.ts:64](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L64) Single abort signal composing `_isAborted` + the stream controller. ##### maxTurns? [Section titled “maxTurns?”](#maxturns-2) ```ts optional maxTurns?: number; ``` Defined in: [packages/engine/src/agent/stream-run.ts:66](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L66) Global turn cap. ###### Default [Section titled “Default”](#default) ```ts 10 ``` ##### toolTimeoutMs? [Section titled “toolTimeoutMs?”](#tooltimeoutms) ```ts optional toolTimeoutMs?: number; ``` Defined in: [packages/engine/src/agent/stream-run.ts:68](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L68) Per-tool-call handler timeout in ms. ###### Default [Section titled “Default”](#default-1) ```ts 300000 ``` ##### onHistoryAppend? [Section titled “onHistoryAppend?”](#onhistoryappend) ```ts optional onHistoryAppend?: (message) => void; ``` Defined in: [packages/engine/src/agent/stream-run.ts:102](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L102) Called for every turn the loop appends to the history — the assistant message carrying the turn’s calls, each `tool` message answering one (resolved or rejected), and a pipeline phase’s reply — in order, and always before the transport call that would carry it. Never called for the messages you passed in `baseRequest`. For hosts that own their own transcript. The loop re-sends its `messages` array every turn, which suits stateless message APIs but not a **prefix cache** (llama.cpp slots, vLLM), where turn N+1 must EXTEND turn N byte for byte. Such a host builds its own request in [transportCall](#transportcall) — ignoring `request.messages` — and mirrors these notifications into its append-only log, instead of reimplementing the loop’s tool bookkeeping. Synchronous and ordered, like [emitter](#emitter). One rule about the envelope: a turn’s assistant message is reported ONCE, when its first call completes, and the calls that complete later in the same turn are already in that same object’s `toolCalls` — the array is shared by reference, not copied. Hold the reference, not a snapshot, and every result you receive after it is declared by it. Two of its fields are finalised after you are notified, then: `toolCalls` gains the turn’s later calls, and `content` is re-read at the turn’s end (a provider that emits `[tool, text]` streams the rest of the sentence after the envelope is opened — see `syncEnvelopeText`). A host that must write BYTES at receipt — an append-only log, which cannot hold a reference — should therefore treat the envelope as provisional and write it only once the turn’s last `tool` message has arrived, or re-read the object it holds before serialising. `content` is the field that moved here, and the failure is louder than it was: a snapshot no longer loses a trailing clause, it loses the whole sentence the assistant said before it called anything. ###### Parameters [Section titled “Parameters”](#parameters-3) | Parameter | Type | | --------- | ------------------------------------------- | | `message` | [`StreamAgentMessage`](#streamagentmessage) | ###### Returns [Section titled “Returns”](#returns-3) `void` ##### idGen? [Section titled “idGen?”](#idgen) ```ts optional idGen?: (prefix) => string; ``` Defined in: [packages/engine/src/agent/stream-run.ts:109](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L109) Generates the ids the loop mints itself — today only the synthetic call of a forced `toolChoice` (`prefix` is `'synthetic-tool'`). The default is a deterministic per-run counter; core’s client injects a crypto-based one. Tool ids otherwise flow from the stream. ###### Parameters [Section titled “Parameters”](#parameters-4) | Parameter | Type | | --------- | -------- | | `prefix` | `string` | ###### Returns [Section titled “Returns”](#returns-4) `string` ##### deferral? [Section titled “deferral?”](#deferral) ```ts optional deferral?: StreamDeferral; ``` Defined in: [packages/engine/src/agent/stream-run.ts:123](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L123) Defer instead of block. With this set, a call the gate sends to a person is not awaited on `approvalResolver`, and an `external` tool is not treated as missing: each takes its answer from `outcomes`, and without one the call is DEFERRED — declared on the turn’s envelope with no `tool` answer. The rest of the model’s reply is still read to its end (its later calls pending too, nothing run after the pause point), and the run ends on `run-suspended` instead of `run-done`. A run whose `baseRequest.messages` ends on an assistant envelope with unanswered calls RESUMES: those calls go through the same `processCall` as a streamed one, in order, before the first transport call. A call the gate denies is never deferred — it is refused, through `approvalResolver`, as without this option. ##### providerId? [Section titled “providerId?”](#providerid) ```ts optional providerId?: string; ``` Defined in: [packages/engine/src/agent/stream-run.ts:128](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L128) The caller’s provider id. Opaque to the loop, like `messageId`: a runner that forwards the run elsewhere (a server) needs it. ## Type Aliases [Section titled “Type Aliases”](#type-aliases) ### StreamChatEvent [Section titled “StreamChatEvent”](#streamchatevent) ```ts type StreamChatEvent = | { type: "text"; delta: string; } | { type: "thinking"; delta: string; } | { type: "tool_use"; } & StreamToolCall | { type: "error"; message: string; } | { type: "done"; usage?: StreamUsage; }; ``` Defined in: [packages/engine/src/agent/stream-events.ts:75](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L75) A structured stream event from the transport (mirrors `AparteStreamEvent`). The `tool_use` variant spreads [StreamToolCall](#streamtoolcall) exactly like core’s does. *** ### StreamContentPart [Section titled “StreamContentPart”](#streamcontentpart) ```ts type StreamContentPart = | StreamTextPart | StreamImagePart; ``` Defined in: [packages/engine/src/agent/stream-events.ts:115](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L115) *** ### StreamToolHandler [Section titled “StreamToolHandler”](#streamtoolhandler) ```ts type StreamToolHandler = (call, signal) => Promise<{ content: string; structuredContent?: unknown; }>; ``` Defined in: [packages/engine/src/agent/stream-events.ts:203](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L203) A tool handler (mirrors the resolved `AparteToolHandler`). #### Parameters [Section titled “Parameters”](#parameters-5) | Parameter | Type | | --------- | ----------------------------------- | | `call` | [`StreamToolCall`](#streamtoolcall) | | `signal` | `AbortSignal` | #### Returns [Section titled “Returns”](#returns-5) `Promise`<{ `content`: `string`; `structuredContent?`: `unknown`; }> *** ### StreamDeferredReason [Section titled “StreamDeferredReason”](#streamdeferredreason) ```ts type StreamDeferredReason = "approval" | "external" | "queued"; ``` Defined in: [packages/engine/src/agent/stream-events.ts:244](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L244) Why a deferred call waits. `approval`: a person must decide. `external`: the tool runs elsewhere and its result must come back. `queued`: nothing — it came after the pause point in the same reply, and runs when the run resumes. *** ### StreamDeferredOutcome [Section titled “StreamDeferredOutcome”](#streamdeferredoutcome) ```ts type StreamDeferredOutcome = | { kind: "decision"; approved: boolean; reason?: string; instruction?: string; payload?: unknown; } | { kind: "result"; content: string; structuredContent?: unknown; } | { kind: "failed"; error: string; }; ``` Defined in: [packages/engine/src/agent/stream-events.ts:255](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L255) The answer to a deferred call, handed to the run that resumes it. *** ### StreamApprovalResolver [Section titled “StreamApprovalResolver”](#streamapprovalresolver) ```ts type StreamApprovalResolver = (call, signal) => Promise<{ approved: boolean; payload?: unknown; instruction?: string; reason?: string; }>; ``` Defined in: [packages/engine/src/agent/stream-events.ts:275](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L275) Resolves a human-in-the-loop approval (mirrors core’s `AparteToolApprovalResolver`). Injected so the loop stays headless. #### Parameters [Section titled “Parameters”](#parameters-6) | Parameter | Type | | ------------ | ----------------------------------------------------------------------------- | | `call` | { `id`: `string`; `name`: `string`; `input`: `Record`<`string`, `unknown`>; } | | `call.id` | `string` | | `call.name` | `string` | | `call.input` | `Record`<`string`, `unknown`> | | `signal` | `AbortSignal` | #### Returns [Section titled “Returns”](#returns-6) `Promise`<{ `approved`: `boolean`; `payload?`: `unknown`; `instruction?`: `string`; `reason?`: `string`; }> *** ### StreamRunEvent [Section titled “StreamRunEvent”](#streamrunevent) ```ts type StreamRunEvent = | { type: "run-start"; } | { type: "turn-start"; } | { type: "text-delta"; delta: string; } | { type: "text-flush"; } | { type: "thinking-delta"; delta: string; } | { type: "tool-start"; toolCallId: string; name: string; input: unknown; } | { type: "tool-awaiting-approval"; toolCallId: string; name: string; input: unknown; } | { type: "tool-approved"; toolCallId: string; } | { type: "tool-rejected"; toolCallId: string; reason: string; } | { type: "tool-failed"; toolCallId: string; error: string; } | { type: "tool-resolved"; toolCallId: string; result: string; structuredResult?: unknown; } | { type: "tool-aborted"; toolCallId: string; } | { type: "turn-limit-exceeded"; scope: "global" | "tool"; limit: number; toolCallId?: string; } | { type: "run-aborted"; } | { type: "run-done"; usage?: StreamUsage; } | { type: "run-suspended"; pending: StreamDeferredCall[]; usage?: StreamUsage; }; ``` Defined in: [packages/engine/src/agent/stream-events.ts:321](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L321) High-level, DOM-free events isomorphic to the inline loop’s `targetElement.*` call sequence. Emitted **synchronously and in order** (see [StreamRunEmitter](#streamrunemitter)) so the adapter reproduces the exact streaming update order. Mapping to the inline loop it replaced, for the adapter: * `run-start` → updateMessage(status:‘streaming’) once at loop entry (the leading write before turn 1) * `turn-start` → reset the per-turn parser / thinking / streaming-segment state (no DOM); one per turn * `text-delta` → parser-driven addSegment/updateSegment, else typeName/updateLastMessage * `text-flush` → textParser.finalize() then addSegment/updateSegment the finalized segments; one per turn, after the inner SSE loop ends (surfaced by the spike — a turn-boundary flush) * `thinking-delta` → addSegment(‘thinking’) then updateSegment(content); first `text-delta` after thinking collapses it (updateSegment collapsed:true) * `tool-start` → renderer lookup + per-tool-name CSS inject into document.head + addSegment * `tool-awaiting-approval` → updateSegment(‘awaiting-approval’) + dispatch `aparte-tool-approval-request` * `tool-approved` → updateSegment(‘pending’) * `tool-rejected` → updateSegment(‘rejected’, result) * `tool-failed` → updateSegment(‘failed’, error) — the handler threw; the run then ends on that error * `tool-resolved` → updateSegment(‘resolved’, result) * `tool-aborted` → updateSegment(‘aborted’) (no-handler path, timeout/abort path, or per-tool maxTurns path) * `turn-limit-exceeded` scope:‘global’ → addSegment(error ‘MAX\_TURNS\_EXCEEDED’); scope:‘tool’ → updateSegment(‘aborted’) * `run-aborted` → dispatch `aparte-message-aborted` (from the inner-loop abort check or the outer turn-boundary abort check) * `run-done` → updateMessage(status:‘completed’) always + setUsage if usage * `run-suspended` → (with `deferral`) the run ends with calls left pending; the adapter marks them and completes the message — the TURN goes on through the run that resumes them #### Union Members [Section titled “Union Members”](#union-members) ##### Type Literal [Section titled “Type Literal”](#type-literal) ```ts { type: "run-start"; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-1) ```ts { type: "turn-start"; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-2) ```ts { type: "text-delta"; delta: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-3) ```ts { type: "text-flush"; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-4) ```ts { type: "thinking-delta"; delta: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-5) ```ts { type: "tool-start"; toolCallId: string; name: string; input: unknown; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-6) ```ts { type: "tool-awaiting-approval"; toolCallId: string; name: string; input: unknown; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-7) ```ts { type: "tool-approved"; toolCallId: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-8) ```ts { type: "tool-rejected"; toolCallId: string; reason: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-9) ```ts { type: "tool-failed"; toolCallId: string; error: string; } ``` The handler threw (not an abort): the row hears of the crash before the run ends on it. *** ##### Type Literal [Section titled “Type Literal”](#type-literal-10) ```ts { type: "tool-resolved"; toolCallId: string; result: string; structuredResult?: unknown; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-11) ```ts { type: "tool-aborted"; toolCallId: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-12) ```ts { type: "turn-limit-exceeded"; scope: "global" | "tool"; limit: number; toolCallId?: string; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-13) ```ts { type: "run-aborted"; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-14) ```ts { type: "run-done"; usage?: StreamUsage; } ``` *** ##### Type Literal [Section titled “Type Literal”](#type-literal-15) ```ts { type: "run-suspended"; pending: StreamDeferredCall[]; usage?: StreamUsage; } ``` Ends a run in place of `run-done` when calls are left for someone else to answer. *** ### StreamRunEmitter [Section titled “StreamRunEmitter”](#streamrunemitter) ```ts type StreamRunEmitter = (event) => void; ``` Defined in: [packages/engine/src/agent/stream-events.ts:346](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-events.ts#L346) Synchronous event sink. Synchronous by contract: the loop must never yield between emitting an event and its ordered successor, or the adapter’s streaming updates would interleave out of order. #### Parameters [Section titled “Parameters”](#parameters-7) | Parameter | Type | | --------- | ----------------------------------- | | `event` | [`StreamRunEvent`](#streamrunevent) | #### Returns [Section titled “Returns”](#returns-7) `void` ## Functions [Section titled “Functions”](#functions) ### runStreamAgent() [Section titled “runStreamAgent()”](#runstreamagent) ```ts function runStreamAgent(opts): Promise; ``` Defined in: [packages/engine/src/agent/stream-run.ts:159](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L159) Run the structured-stream agent loop. Resolves the last turn’s usage (the `done{usage}` last-write-wins, mirroring `_streamLoop`’s return), or `undefined`. Throws on a stream `error` event or a non-abort tool failure — the caller (adapter) routes that to its lifecycle-error handler, exactly as `_handleSend`/`_handleRetry`/`_handleEdit` catch `_streamLoop`. This block has to stay ADJACENT to the function. It used to sit above `warnUnknownStreamEvent`, six lines and a docblock away from what it describes, and a docblock separated from its declaration is attached to nothing: TypeDoc took the helper’s comment as the neighbouring one and `@aparte/engine`’s reference page carried the package’s headline export with no description at all — the very export its own frontmatter names. Same trap the custom-elements analyser has with a class docblock pushed above an import. #### Parameters [Section titled “Parameters”](#parameters-8) | Parameter | Type | | --------- | --------------------------------------- | | `opts` | [`StreamRunOptions`](#streamrunoptions) | #### Returns [Section titled “Returns”](#returns-8) `Promise`<[`StreamUsage`](#streamusage) | `undefined`> *** ### unansweredTail() [Section titled “unansweredTail()”](#unansweredtail) ```ts function unansweredTail(messages): StreamToolCall[]; ``` Defined in: [packages/engine/src/agent/stream-run.ts:581](https://github.com/apartejs/aparte/blob/main/packages/engine/src/agent/stream-run.ts#L581) The calls a history leaves unanswered: those of its LAST assistant envelope that no following `tool` message answers, in declaration order. A resumed run answers them first; a server checks them against the ticket it signed when the run paused. #### Parameters [Section titled “Parameters”](#parameters-9) | Parameter | Type | | ---------- | ------------------------------------------------------- | | `messages` | readonly [`StreamAgentMessage`](#streamagentmessage)\[] | #### Returns [Section titled “Returns”](#returns-9) [`StreamToolCall`](#streamtoolcall)\[] # Wrapper surface > Every slot and every callback the React, Vue, Svelte and Angular wrappers expose, generated from the wrapper source. The default shell of `` leaves you these places to put your own markup. The same slots exist on all four wrappers — that is checked mechanically on every `pnpm gate`, not promised in prose — and each framework declares them in its own idiom. | Slot | React | Vue | Svelte | Angular | | ---------------- | ------------------- | ---------------------------- | ----------------------------------------- | ----------------------- | | `composer` | `composer={…}` | `