Ask me anything…
Ask me anything…
Shift + Enter for a new lineAsk me anything…
Ask me anything…
Ask me anything…
Ask me anything…
Shift + Enter for a new lineReplies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
Replies can be wrong. Check what matters.
What can I help with?
What are we building today?Ask anything, or start from one of these.
` included, as a string or a promise of one. ## Keep it safe [Section titled “Keep it safe”](#keep-it-safe)
```ts
import DOMPurify from 'dompurify';
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
```
What marked and a highlighter return goes through the chat’s HTML sanitizer before it reaches the page, and so does a streamed reply once it finishes, so the Markdown a model writes cannot run script. While a reply streams, a link or an image with an unsafe address is dropped, and a link to another site opens in a new tab. The built-in sanitizer needs no dependency; [`setHtmlSanitizer`](/reference/core/config/#sethtmlsanitizer) swaps in one of yours, such as DOMPurify. ## Route the links a model writes [Section titled “Route the links a model writes”](#route-the-links-a-model-writes)
```ts
declare const router: { push(path: string): void }; // your app's router
const chat = document.querySelector('aparte-chat');
chat?.addEventListener('aparte-link-click', (event) => {
const url = new URL(event.detail.href, location.href);
if (url.origin !== location.origin) return; // another site: let the browser open it
event.preventDefault(); // this one is yours
router.push(url.pathname + url.search);
});
```
Before the browser follows a link in a reply, the message fires `aparte-link-click`. It bubbles, so one listener on the chat covers every message, and it can be cancelled: call `preventDefault()` and the navigation is yours — your router, a webview, a question first. The detail is `{ href, anchor, messageId }`. ## Two chats, two setups [Section titled “Two chats, two setups”](#two-chats-two-setups)
```ts
import { AparteConfig, attachConfig } from '@aparte/core';
import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown';
import { setupMarkedProvider } from '@aparte/plugin-marked';
import { setupShikiProvider } from '@aparte/plugin-shiki';
const support = new AparteConfig();
setupStreamingMarkdownProvider(support);
setupMarkedProvider({ breaks: true }, support);
setupShikiProvider({ theme: 'github-dark' }, support);
const chat = document.querySelector('#support-chat');
if (chat) attachConfig(chat, support);
```
Every setup takes a configuration as its last argument, the page-wide one by default. A configuration of your own starts from the built-in defaults and reads nothing the page-wide one registered, so set up on it every formatter that chat uses; attached to a chat, it formats that chat alone. The framework packages take it as their `config` prop. ## Next [Section titled “Next”](#next) * [Response The formatted reply, its variants and its options.](/components/response/) * [Code block Code as its own part of a reply, with its language and copy button.](/components/code-block/) * [Customization Your own way to draw a part of a message.](/guides/customization/)
# Run the agent loop on your server
> Run the agent loop next to your tools — a handler on your server, a runner in the page — with approvals that cross the network and survive a reload, and browser tools that still run in the page.
Your tools live on your server — files, a database, secrets — and the model has to call them. With the loop on the server, next to them, there is nothing to expose, and the page keeps its tool rows, Stop, the approval panel and your approval policy. To keep only the key off the browser, the [backend transport](/guides/backend-transport/) is enough; to drive the chat from a loop of your own, see [Your own loop](/guides/bring-your-own-loop/). ## The server [Section titled “The server”](#the-server)
```ts
// app/api/agent/route.ts (Next.js)
import { createAparteAgentHandler } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
export const POST = createAparteAgentHandler({
providers: { openai: createOpenAICompatProvider(presets.OPENAI) },
defaultModel: 'gpt-4.1-mini', // when the page selects none
resolveKey: () => process.env.OPENAI_API_KEY,
authorize: (req) => isSignedIn(req), // required: this route spends your key
secret: process.env.AGENT_SECRET!, // required, 32+ characters
tools: [{
tool: {
name: 'read_file',
description: 'Read a file from the site content.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
},
handler: readFileHandler,
}],
});
```
`createAparteAgentHandler` returns a Fetch handler, `Request` to `Response`, for a Next.js route in the Node runtime, Deno, Bun, or Node behind a small adapter — runtimes that resolve `@aparte/core` to its entry without a DOM ([On the server](/frameworks/elements/#on-the-server)). `authorize` returns `false`, for a 401, or a `Response` of its own. `secret` signs the calls a paused run leaves pending; an array rotates it, the first signing and all verifying. | Option | What it does | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `approvalPolicy` | rules on each call, as `setApprovalPolicy` does in the page | | `clientTools` | whether the page’s own tools are accepted: `true` (the default), `false`, or a list of names | | `prepareRequest(request, req)` | rewrites the request before the run, on every request a resume included: a model, a temperature, a system prompt | | `maxTurns`, `toolTimeoutMs` | the model’s turns in one run, ten by default — a resume after a pause counts again — and a tool’s time, five minutes by default; a tool’s own `maxTurns` applies too | | `ticketMaxAgeMs` | how long a paused run can wait, seven days by default | | `onTicket(id)` | a check on each resume; `false` refuses it with a 409 | | `transport` | how the server calls the model, `new AparteDirectTransport({ byok: true })` by default | | `crypto` | the Web Crypto used to sign, for [Node 18](#node-18) | On Node’s own `http` server, hand it a `Request` and stream the `Response` back. Stop in the browser closes the connection, and the handler stops the run — the model call and any tool still running — when its request is aborted:
```ts
import { createServer } from 'node:http';
// handler = createAparteAgentHandler({ … }), as above
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const closed = new AbortController();
res.on('close', () => closed.abort());
const response = await handler(new Request(`http://localhost${req.url}`, {
method: req.method, headers: req.headers as HeadersInit, body: Buffer.concat(chunks),
signal: closed.signal,
}));
res.writeHead(response.status, Object.fromEntries(response.headers));
if (response.body) for await (const chunk of response.body) res.write(chunk);
res.end();
}).listen(3000);
```
## The browser [Section titled “The browser”](#the-browser)
```ts
import { AparteClient, createAgentStreamRunner } from '@aparte/core';
const client = new AparteClient({
streamRunner: createAgentStreamRunner({
endpoint: '/api/agent',
headers: async () => ({ authorization: `Bearer ${sessionStorage.getItem('token') ?? ''}` }),
}),
});
client.start();
```
The runner sends each turn to your endpoint and replays the server’s events, which the page draws as it draws a loop run in the page. A function for `headers` is called for each request, which is where a fresh token goes, and `fetch` replaces the one it calls. `credentials: 'include'` sends cookies to another origin; the handler sets no CORS headers, so your route or proxy answers the preflight (`OPTIONS`) and adds `Access-Control-Allow-Origin` (the page’s origin) and `Access-Control-Allow-Credentials: true`. The page registers no provider: the server runs its only provider, on `defaultModel`. When the handler has several, the page names one by its key in `providers`:
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1' });
```
A model the page selects with `setModelConfig` wins over `defaultModel`; `prepareRequest` runs after it. ## Approvals across the network [Section titled “Approvals across the network”](#approvals-across-the-network) A server tool with `needsApproval: true`, or a call the server’s `approvalPolicy` answers `ask`, suspends the run: the stream ends with the pending call and a signed ticket, the page asks with the [approval panel](/guides/tools/#require-approval-human-in-the-loop), and the answer resumes the run on the server, in the same reply. The server decides which calls need a person; the page answers, through the person or through its own approval policy, such as [`@aparte/plugin-approval`](/components/approval-mode/)’s modes. The server can also refuse outright, with no one asked:
```ts
createAparteAgentHandler({
// …
approvalPolicy: (call) => (call.name === 'delete_file' ? { verdict: 'deny', reason: 'Not from the website.' } : undefined),
});
```
The person can leave and come back: nothing is stored on the server, the paused call lives in the conversation you already save. A [conversation manager](/guides/conversation-persistence/) saves the reply when the run pauses — the chat sends `aparte-message-paused` — and when it opens a conversation whose last reply is paused, the panel comes back. Saving another way, listen for `aparte-message-paused` on the chat — it bubbles — and save the message as it is, its segments included, since the paused call and its ticket are in them; after `setMessages`, call `await client.resumeDeferred()`. A new message sent instead of an answer closes the paused call. ## Browser tools [Section titled “Browser tools”](#browser-tools) A tool that needs the page stays registered in the page: `setupAskUser()` registers [`ask_user`](/components/question/), and `registerTool` your own. The runner sends its definition along; the server lets the model call it, pauses on it, and the page runs its handler and sends the result back. The page runs it as soon as the model calls it: its `needsApproval` and the page’s approval policy are not consulted on this path, so a browser tool that needs a person asks in its own handler, as `ask_user` does. To refuse browser tools, or accept a list of them:
```ts
createAparteAgentHandler({
// …
clientTools: ['ask_user'], // or false
});
```
A browser tool may not share a server tool’s name: the request is refused. ## Open a file in the page when a server tool runs [Section titled “Open a file in the page when a server tool runs”](#open-a-file-in-the-page-when-a-server-tool-runs) To have the page do something when a call runs — open a file in the editor beside the chat — without a pause, make the tool a server tool that answers by itself, with what the page needs in its `structuredContent`, and act in the page when its call resolves:
```ts
createAparteAgentHandler({
// …
tools: [{
tool: {
name: 'open_file',
description: 'Open a file in the editor beside the chat.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
},
handler: async (call) => ({ toolCallId: call.id, content: 'opened', structuredContent: { openFile: String(call.input.path) } }),
}],
});
```
```ts
import { AparteClient, createAgentStreamRunner } from '@aparte/core';
import type { AparteStreamRunner } from '@aparte/core';
declare function openFile(path: string): void; // your page's own code
const runner = createAgentStreamRunner({ endpoint: '/api/agent' });
const openingFiles: AparteStreamRunner = (opts) => runner({
...opts,
emitter: (event) => {
if (event.type === 'tool-resolved') {
const result = event.structuredResult as { openFile?: string } | undefined;
if (result?.openFile) openFile(result.openFile);
}
opts.emitter(event);
},
});
new AparteClient({ streamRunner: openingFiles }).start();
```
`tool-resolved` arrives once the server tool has succeeded, a call resumed after a pause included, so a failed call opens nothing. A conversation opened later draws its finished calls from its history and runs none of them, so the file is not opened again. The same wrapping works on any runner. ## What the ticket protects, and what it does not [Section titled “What the ticket protects, and what it does not”](#what-the-ticket-protects-and-what-it-does-not) The ticket is a signature over the pending calls: a resume runs only calls this server emitted, for the reply it paused. Three things it does not do: * **It does not make a tool’s input safe.** The model chose the arguments; a tool checks the caller’s rights itself. Its handler gets the request being served as `context.request` — for a call answered after a pause, the request that resumed the run. * **It does not prove a person approved.** The browser sends the decision, so a caller holding the ticket can approve any call it names: `needsApproval` asks a person, and the handler still checks the caller’s rights. * **It is not single-use.** A server that stores nothing cannot tell a replay from a first use; if yours can, `onTicket` refuses a ticket it has seen.
```ts
createAparteAgentHandler({
// …
onTicket: async (id) => {
if (await seen.has(id)) return false; // replayed: refused with a 409
await seen.add(id);
return true;
},
tools: [{
tool: {
name: 'delete_file',
description: 'Delete a file.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
needsApproval: true,
},
handler: async (call, signal, context) => {
const user = await userOf(context?.request);
const path = String(call.input.path);
if (!user?.canWrite(path)) return { toolCallId: call.id, content: 'You cannot delete this file.' };
await removeFile(path, { signal });
return { toolCallId: call.id, content: `Deleted ${path}.` };
},
}],
});
```
An approval’s `payload` — data an approval surface merges into the arguments — is dropped unless the tool opts in with `acceptsApprovalPayload: true`. ## Node 18 [Section titled “Node 18”](#node-18) The ticket is signed with Web Crypto, which browsers, Node 19 and later, Deno, Bun and Workers have. On Node 18, pass it in:
```ts
import { webcrypto } from 'node:crypto';
createAparteAgentHandler({
// …
crypto: webcrypto.subtle as SubtleCrypto,
});
```
## The wire [Section titled “The wire”](#the-wire) NDJSON, one event per line: the [engine’s run events](/guides/engine/) as they are, a `run-suspended` line carrying the `ticket`, and a `run-error` line, never the vendor’s own words, when the run fails after the stream began. A server tool that throws ends the run: the page shows the call as failed — `The tool failed.` — then the reply fails, and the error itself goes to your server’s log; to let the model read a failure and go on, return it as `content`. An error before the stream is JSON with an HTTP status. The body is `{ v: 1, providerId?, messageId, request, clientTools, resume? }`, which the runner builds. Among the 400s, two concern tools: a `toolChoice` that names a tool, and a history ending on unanswered calls with no `resume` ticket.
# Theme the chat to your brand, in light and dark
> Restyle the whole chat with CSS variables — your brand colours, light and dark, density and shape — on the page, on one part of it, or on one chat.
[The chat in a brand of its own](/preview/demo/theme-brand/)
```html
```
Eight variables on a wrapper, and every part of the chat follows them. ## Set your brand colours [Section titled “Set your brand colours”](#set-your-brand-colours)
```css
:root,
[data-aparte-theme="light"] {
--aparte-primary: #b45309; /* the accent: send button, links, the user's bubble */
--aparte-primary-hover: #92400e;
--aparte-bg: #fbf7f0; /* your page's background */
--aparte-surface-1: #ffffff; /* cards, code blocks, the composer */
--aparte-surface-2: #f4ece0; /* headers, inline code */
--aparte-text: #241a12;
--aparte-text-muted: #7c6f60;
--aparte-border: #e7dccb;
}
```
The chat’s colours follow these eight: 241 of core’s variables read another one, such as `--aparte-input-bg`, which is `var(--aparte-surface-1)`, or `--aparte-avatar-bg-user`, which is `var(--aparte-primary)`. Set the bases, not each value they feed. The block wins whichever stylesheet loads first, and its second selector keeps it on an element [forced light](#light-and-dark). A few do not follow them. The status colours — `--aparte-info`, `--aparte-success`, `--aparte-warning`, `--aparte-error` — and `--aparte-secondary` are yours to set in the same block if your brand has an opinion, and so is `--aparte-neutral`, which draws the focus ring, the scrollbar and the neutral buttons. The chat paints no background of its own, so your page shows through it: `--aparte-bg` only tints the progress track and the loading skeleton, and is there for your page too (`body { background: var(--aparte-bg); }`). ### The text on a fill [Section titled “The text on a fill”](#the-text-on-a-fill)
```css
:root {
--aparte-primary: #1a1a2e;
--aparte-on-primary: #f8fafc;
}
```
The label on a filled button, badge or checkbox is worked out from the fill’s lightness, so it stays readable whatever colour you choose. Set its pair to choose it yourself: `--aparte-on-primary`, `--aparte-on-secondary`, `--aparte-on-neutral`, `--aparte-on-info`, `--aparte-on-success`, `--aparte-on-warning` and `--aparte-on-error`. Two numbers tune the worked-out ink: `--aparte-ink-flip`, the fill lightness where it turns from light to dark, and `--aparte-ink-dark`, how dark it gets. ## Light and dark [Section titled “Light and dark”](#light-and-dark)
```html
…
```
With no attribute, the system’s `prefers-color-scheme` decides. `data-aparte-theme` forces either way, on the chat or on any ancestor, so a light panel inside a dark page stays light. The attribute puts core’s palette on the element that carries it, which is why your light block names `[data-aparte-theme="light"]` beside `:root`. ## Give dark its own colours [Section titled “Give dark its own colours”](#give-dark-its-own-colours)
```css
/* light */
:root,
[data-aparte-theme="light"] {
--aparte-primary: #087ea4;
/* …the rest of your light palette */
}
/* dark, when your toggle sets the attribute */
[data-aparte-theme="dark"] {
--aparte-primary: #58c4dc;
/* …the same tokens, in dark */
}
/* dark, when the system decides */
@media (prefers-color-scheme: dark) {
:root:not([data-aparte-theme="light"]) {
--aparte-primary: #58c4dc;
/* …the same tokens, in dark */
}
}
```
Without a dark block, a dark system shows core’s dark palette rather than your light colours. Give each dark block every token your light block sets. Core’s dark palette has a value for the eight, `--aparte-neutral`, the shadows, the scrim behind dialogs and the error colours (`--aparte-error-bg`, `--aparte-error-text`, `--aparte-error-border`, `--aparte-error-solid`), and any of those you leave out takes core’s dark value rather than yours. Keep the selector of the third block as it is: a bare `:root` inside the media query loses to core’s dark palette. ## Theme one chat, or part of the page [Section titled “Theme one chat, or part of the page”](#theme-one-chat-or-part-of-the-page)
```html
```
A variable set on an element reaches everything inside it: one [chat](/components/chat/), a sidebar, a section of the page. A token that reads another one, such as `--aparte-avatar-bg-user` or `--aparte-radius-bubble`, is worked out again on every `aparte-chat`, every `[data-aparte-chat]` root the React, Vue and Svelte components render, and every `[data-aparte-theme]` element, so a chat given its own `--aparte-primary` gets its own user bubble and avatar too. The same rule stops a value you give such a token on `:root` at the chat: set it on the chat, in a rule with a class as above, which outweighs core’s own declaration there whatever order the stylesheets load in. The [CSS variables](/reference/css-variables/) reference marks those tokens **anchored**. In React, Vue and Svelte, the chat’s root is a ``, and the same tokens are worked out again there: give it a class and write `[data-aparte-chat].sales { … }`. ## Make it denser, squarer or larger [Section titled “Make it denser, squarer or larger”](#make-it-denser-squarer-or-larger)
```css
:root {
--aparte-space-unit: 1.5px; /* every gap and padding, tighter */
--aparte-radius-unit: 2px; /* every corner, squarer */
--aparte-font-scale: 1; /* every text size, a notch smaller */
--aparte-btn-size-md: 28px; /* the medium control, and every size read from it */
}
```
The sizes are scales, each a base times a step, so one base moves a whole family: | Scale | Tokens | Base | | -------- | --------------------------------------------------- | ---------------------- | | Spacing | `--aparte-space-1` … `--aparte-space-8` | `--aparte-space-unit` | | Corners | `--aparte-radius-xs` … `--aparte-radius-xl` | `--aparte-radius-unit` | | Text | `--aparte-font-size-2xs` … `--aparte-font-size-2xl` | `--aparte-font-scale` | | Controls | `--aparte-btn-size-sm`, `-md`, `-lg`, `-xl` | each its own | `--aparte-radius-full`, the pill, stays a pill whatever the unit. A single step can be set too, on the chat with a class: `aparte-chat.chat { --aparte-space-6: 8px; }` pulls in the paddings that use it and leaves the rest of the scale alone. The kit’s controls read the same tokens, so a page of [buttons](/components/button/) and [fields](/components/field/) moves with the chat. ## Small recipes [Section titled “Small recipes”](#small-recipes) They set tokens on ``. In React, Vue and Svelte, give the chat’s root the class with `className` and write `[data-aparte-chat].chat`. ### Give the assistant a bubble too [Section titled “Give the assistant a bubble too”](#give-the-assistant-a-bubble-too)
```css
aparte-chat.chat { --aparte-message-content-bg-assistant: var(--aparte-surface-2); }
.aparte-message[data-role="assistant"] .aparte-message-content {
width: fit-content;
padding: var(--aparte-message-content-padding);
}
```
The assistant’s reply is plain text across the column by default, and only the person’s words sit in a bubble. The token colours the reply; the rule gives it the user bubble’s shape. ### Resize the composer [Section titled “Resize the composer”](#resize-the-composer)
```css
aparte-chat.chat { --aparte-composer-control-size: 52px; }
```
The input’s height and every button in the composer follow it. On a touch screen they take `--aparte-touch-target-size` instead. ### Change the code font [Section titled “Change the code font”](#change-the-code-font)
```css
:root { --aparte-code-font-family: 'JetBrains Mono', ui-monospace, monospace; }
```
### Mark a row as chosen [Section titled “Mark a row as chosen”](#mark-a-row-as-chosen)
```css
:root {
--aparte-mark-tint: 12%; /* a quieter ground, for marked rows and the user's bubble */
--aparte-mark-bar: 3px; /* a bar on the start edge, which has none by default */
}
```
```html
Chosen
Accepted
Declined
```
The [mark](/components/mark/) is how the chat shows what was chosen: a select’s option, a checked choice in a question, the answer a question kept. The same class marks a row of yours. `--success`, `--danger` and `--neutral` change the intent; `--quiet` is the one that did not happen, with no tint and muted text. ### Type a theme in TypeScript [Section titled “Type a theme in TypeScript”](#type-a-theme-in-typescript)
```ts
import type { AparteThemeVariables } from '@aparte/core';
export const brand: AparteThemeVariables = {
'--aparte-primary': '#b45309',
'--aparte-surface-1': '#ffffff',
};
for (const [name, value] of Object.entries(brand)) {
if (value) document.documentElement.style.setProperty(name, value);
}
```
A key that does not start with `--aparte-` is a compile error. The name after the prefix is not checked, which is what the next recipe is for. ### Check that every token you use exists [Section titled “Check that every token you use exists”](#check-that-every-token-you-use-exists) A `var(--aparte-text-primary)` that names a token the library does not have fails without a word: the property falls back to its inherited or initial value, and the page looks almost right. The stylesheet you import names every token it declares or reads, so a script can check your CSS against it:
```js
// aparte-tokens.mjs — node aparte-tokens.mjs src/**/*.css
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const sheet = readFileSync(createRequire(import.meta.url).resolve('@aparte/core/styles.css'), 'utf8');
const known = new Set(sheet.match(/--aparte-[\w-]+/g));
let bad = 0;
for (const file of process.argv.slice(2)) {
for (const name of new Set(readFileSync(file, 'utf8').match(/--aparte-[\w-]+/g) ?? [])) {
if (!known.has(name)) { bad++; console.log(`${file}: ${name} is not an @aparte/core token`); }
}
}
process.exit(bad ? 1 : 0);
```
Run it with your tests. A name it reports that you declared yourself is fine, though a token of your own reads better under a prefix of your own. ## Inside a shadow root of your own [Section titled “Inside a shadow root of your own”](#inside-a-shadow-root-of-your-own) Load `@aparte/core/styles.css` inside the shadow root — a `` in it, or `adoptedStyleSheets` — since a document’s stylesheet does not cross into it. The stylesheet declares its tokens on `:host` as well as `:root`, so the chat themes the same way: set the variables on the host or on any element inside, and put `data-aparte-theme` on the host itself, since an attribute on an element outside the shadow root does not reach the chat. `` and `` look their partner up in the document; inside a shadow root, put them in the chat and leave `target` out. ## Next [Section titled “Next”](#next) * [Customization Past colours and sizes: your own icons, avatars, buttons and parts of a message.](/guides/customization/) * [CSS variables Every token, its default, and whether it is anchored.](/reference/css-variables/) * [Layouts A reading column, a full-width feed, a side panel, a floating composer.](/guides/layout/) * [The kit The controls around the chat, in the same theme.](/kit/)
# Give each conversation a short title
> Title conversations from their first message — with a small model that runs in the browser, or with your own titling on a server or a model.
## When a title is decided [Section titled “When a title is decided”](#when-a-title-is-decided) A conversation in a [manager](/guides/conversation-persistence/) takes its title from the person’s first message, written as they typed it — even over a title the conversation was created with. Editing that message gives it a new title while the title is still the manager’s, which `autoTitle: true` says. A title typed in after that, through `updateTitle` or the list’s rename, is never replaced; the list renames through the manager when it has `manage`, or when you answer its `aparte-conversation-rename`. A title provider replaces the “as typed” part: it gets the message and answers a short title. ## Title in the browser [Section titled “Title in the browser”](#title-in-the-browser)
```bash
npm install @aparte/plugin-titler @aparte/titler-latin
```
```ts
import { aparteGlobalConfig } from '@aparte/core';
import { loadTitler } from '@aparte/titler-latin';
import modelUrl from '@aparte/titler-latin/model?url';
import { setupTitler } from '@aparte/plugin-titler';
const manager = aparteGlobalConfig.getConversationManager(); // the one your chat saves to
if (manager) setupTitler(manager, { titler: () => loadTitler(modelUrl) });
```
The model reads the first message in the browser, with no API call, and picks the words that make the title: it copies them in the message’s order and does not rephrase. `loadTitler` runs once, the first time a conversation needs a title, and fetches the model file from your own site; a load that fails is tried again on the next title. The `?url` import is Vite’s way of serving the package’s model file; with another bundler, copy the `.bin` from the package’s `model` folder into what you serve and give `loadTitler` its path. Run the setup once the manager is registered — after `setConversationManager`, or once a framework helper’s `init` has resolved. Left to its default, the model keeps among its six best-scored words those it is confident about, never fewer than three, and every word of a short message. `budget: 4` keeps exactly four of its best-scored words instead. `setupTitler` returns the function that gives the manager back its previous provider, unless another was set since. [Try the model](/models/titler/#demo) on its own page. ## Pick a model [Section titled “Pick a model”](#pick-a-model) | Package | Reads | Model file | | --------------------------- | -------------------------------------------------------------------------------- | ---------- | | `@aparte/titler-latin` | 17 languages: en, fr, es, de, pt, it, nl, pl, sv, da, fi, cs, ro, no, hu, hr, lt | 133 KB | | `@aparte/titler-latin-mini` | the same 17, with a smaller vocabulary | 96 KB | | `@aparte/titler-efigsp` | 6 languages: en, fr, es, de, pt, it | 77 KB | For a model file of your choice, download a `.bin` from the [model repository](https://huggingface.co/apartejs/aparte-titler), serve it from your site, and give its bytes to the runtime alone, `@aparte/titler`:
```ts
import type { AparteConversationManager } from '@aparte/core';
import { loadTitler } from '@aparte/titler';
import { setupTitler } from '@aparte/plugin-titler';
declare const manager: AparteConversationManager;
const bytes = (url: string): Promise =>
fetch(url).then((r) => {
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.arrayBuffer();
});
setupTitler(manager, { titler: () => loadTitler(bytes('/models/titler-fr.bin')) });
```
## Title your own way [Section titled “Title your own way”](#title-your-own-way)
```ts
import type { AparteConversationManager } from '@aparte/core';
declare const manager: AparteConversationManager;
manager.setTitleProvider(async (text) => {
const response = await fetch('/api/title', { method: 'POST', body: text });
return response.ok ? response.text() : '';
});
```
A title provider gets the message’s text and the message, and answers a title, at once or as a promise: a request to your server, a call to a model, a rule of yours. An empty answer, or an error, keeps the message as typed, and an error is logged. `setTitleProvider(null)` puts the default back. ## Without a setup call [Section titled “Without a setup call”](#without-a-setup-call)
```ts
import { AparteConversationManager, type AparteStorageAdapter } from '@aparte/core';
import { loadTitler } from '@aparte/titler-latin';
import modelUrl from '@aparte/titler-latin/model?url';
import { createTitleProvider } from '@aparte/plugin-titler';
declare const adapter: AparteStorageAdapter;
const manager = new AparteConversationManager(adapter, {
titleProvider: createTitleProvider({ titler: () => loadTitler(modelUrl) }),
});
```
`createTitleProvider` is the provider alone, for a manager you build with one. Its options are a `TitlerOptions`, and any object with `title(message, budget?)` — a `TitlerLike` — can be the `titler`. ## Next [Section titled “Next”](#next) * [aparte-titler The model, running in your browser, and the numbers behind it.](/models/titler/) * [Save conversations The manager the titles are written to, and the list that shows them.](/guides/conversation-persistence/) * [Conversation list The sidebar list, with each conversation under its title.](/components/conversation-list/)
# Let the model call your code, and ask a person first
> Register a tool the model can call, show each call as it runs, say what it targets, and make a person approve it before it runs.
## Register a tool [Section titled “Register a tool”](#register-a-tool)
```ts
import { aparteGlobalConfig, AparteClient } from '@aparte/core';
import type { AparteTool, AparteToolHandler } from '@aparte/core';
const getTime: AparteTool = {
name: 'get_time',
description: 'Return the current time in a given IANA timezone.',
inputSchema: {
type: 'object',
properties: { timezone: { type: 'string' } },
required: ['timezone'],
},
};
const handleGetTime: AparteToolHandler = async (call) => ({
toolCallId: call.id,
content: new Date().toLocaleString('en-US', { timeZone: String(call.input['timezone']) }),
});
aparteGlobalConfig.registerTool(getTime, handleGetTime);
new AparteClient().start();
```
`AparteClient` offers every registered tool to the model, runs your handler when the model calls it, gives the result back, and lets the model go on. `inputSchema` is a JSON Schema, sent as it is. The handler gets the call — `{ id, name, input }` — an `AbortSignal` that fires when the turn is stopped or the tool runs too long, and a context whose `target` is the chat the turn belongs to. It answers `{ toolCallId, content }`: `content` is what the model reads, and an optional `structuredContent` reaches a [renderer of yours](#custom-tool-renderer) as `segment.structuredResult`, never the model. A tool’s `systemPrompt` tells the model when to use it, and its `maxTurns` is the last turn of a reply in which it may still run. On the client, `toolTimeoutMs` is the time a handler may take, five minutes by default: a call past its turns or its time is stopped, and the reply ends with it. The client’s `maxTurns` caps the model’s turns in one reply, ten by default, and a reply that reaches it ends on an error message. A model whose declared `capabilities` leave out `function_calling` gets no tools; a model that declares nothing gets them. ## What the row shows [Section titled “What the row shows”](#what-the-row-shows) [Every status a tool call can be in](/preview/demo/tool-statuses/)
```html
```
A tool call in each of its states. Each call is one line in the reply, in the order it happened: the tool’s name and its state. Opened, it shows the arguments the model chose under **Input** and the result under **Output**, as formatted JSON, coloured when a [highlighter](/guides/markdown/) is registered. | State | Word | When | | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `awaiting-approval` | Waiting | a person has not decided yet | | `pending` | Running | the handler runs, with a spinner | | `resolved` | Done | the handler answered | | `rejected` | Rejected | a person, or a [policy](#decide-in-code), refused it | | `aborted` | Stopped | the reply was stopped, or the call could not run — no handler, no one to ask, past its time or its turns — and the reply ends there | | `failed` | Failed | the handler threw | A handler that throws marks its call failed and ends the reply on the error. A failure the model should read and work around is a result instead: return it as `content`. The words are locale keys, and `extendLocale` changes them:
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.extendLocale({
toolRunning: 'Working…', toolCompleted: 'Done', toolRejected: 'Refused',
toolStopped: 'Stopped', toolFailed: 'Failed', approvalWaiting: 'Waiting',
toolInput: 'Arguments', toolOutput: 'Result',
});
```
The row is `.aparte-segment-tool-call`: its line `.aparte-tool-summary`, rounded by `--aparte-tool-row-radius`, the word `.aparte-tool-state`, and the opened part `.aparte-tool-detail`. The [class reference](/reference/classes/) lists them all. ## Show what a call targets [Section titled “Show what a call targets”](#show-what-a-call-targets)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerToolSummary('read_file', (segment) =>
String(segment.toolCall.input['path'] ?? ''));
```
[A tool row that says what it ran on](/preview/demo/tool-summary/)
```html
```
read\_file with the file it read, without opening the row. A summary is the line the row writes after the tool’s name: `read_file skills.json` rather than `read_file` alone. It is read again each time the call changes and when the language switches, and written as text; an empty string, or a summary that throws, shows nothing. A tool drawn by a [renderer of its own](#custom-tool-renderer) shows no summary. ## Require approval (human-in-the-loop) [Section titled “Require approval (human-in-the-loop)”](#require-approval-human-in-the-loop)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.registerTool(
{
name: 'delete_file',
description: 'Delete a file from the workspace.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
needsApproval: true,
},
async (call) => ({ toolCallId: call.id, content: `Deleted ${String(call.input['path'])}` }),
);
```
[A tool call waiting for a person](/preview/demo/approval/)
```html
```
The decision, asked at the composer: the question, the arguments, the choices. With `needsApproval`, the call waits before its handler runs. The row says which tool waits, and the decision is asked at the composer: the question, the arguments the model chose, the choices, and a field to write something else. The panel is ``, which `` and the framework wrappers already hold; a layout you compose yourself has to include it, or, with no one to ask, the call is stopped. Its words are the locale keys `approvalAsk` (`Run {tool}?`), `approveTool`, `rejectTool` and `approvalInstructionPlaceholder`. * **Approve** runs the handler. * **Reject** never runs it: the model reads the refusal, the other calls of that turn are skipped, and the model gets a turn to answer it. * **Writing instead of choosing** is a refusal that carries your words, which the model reads — except with an [AG-UI agent](/providers/agent/ag-ui/), which gets only `{ approved: false }`. * **Stop** ends the reply, and the call shows Stopped; the model is told nothing. Each time a call starts to wait, the chat sends `aparte-tool-approval-request` with `{ toolCallId, toolName, input }`, for a notification of yours. When the loop runs on your server, the same panel answers its tools, even after a reload: [approvals across the network](/guides/server-loop/#approvals-across-the-network). Approval is not authorization The approval runs in the browser: it spares the person a surprise, it does not protect your system. A tool that acts on your backend is checked there, in the endpoint it calls, every time: who is calling, and whether they may. When the loop runs on your server, the handler reads the caller from `context.request` ([server loop](/guides/server-loop/)). ## Decide in code [Section titled “Decide in code”](#decide-in-code)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setApprovalPolicy((call) => {
if (call.name === 'run_command' && String(call.input['cmd']).startsWith('rm '))
return { verdict: 'deny', reason: 'Deleting is off in this workspace.' };
if (call.name.startsWith('read_')) return { verdict: 'allow' };
return undefined; // the tool's own needsApproval decides
});
```
A policy rules on each call before anyone is asked: `allow` runs it, `ask` asks the person as `needsApproval` would, and `deny` refuses it, with the `reason` the model reads. The modes people know from agent apps — plan, ask, auto-edit, auto — with a switch in the composer’s toolbar, are [`@aparte/plugin-approval`](/components/approval-mode/). To answer with no panel at all — from a command line, a webhook, a team channel — give the client an `approvalResolver`. It answers every call of a `needsApproval` tool, and a policy set with `setApprovalPolicy`, the approval modes included, is then not consulted: rule on the call in the resolver, or use a policy, not both.
```ts
import { AparteClient } from '@aparte/core';
declare function askTheTeam(name: string, input: Record, signal: AbortSignal): Promise;
new AparteClient({
approvalResolver: async (call, signal) => ({
approved: await askTheTeam(call.name, call.input, signal),
instruction: 'Use the staging bucket instead.', // read by the model on a refusal
}),
}).start();
```
A resolver may also answer a `payload`, an object merged into the arguments before the handler runs, to correct a path while approving; the row still shows what the model sent. ## Custom tool renderer [Section titled “Custom tool renderer”](#custom-tool-renderer)
```ts
import { aparteGlobalConfig } from '@aparte/core';
import type { AparteToolRenderer } from '@aparte/core';
const searchRenderer: AparteToolRenderer = {
render(segment) {
const el = document.createElement('div');
el.className = 'my-search';
el.textContent = `Searching for ${String(segment.toolCall.input['query'] ?? '')}`;
return el;
},
update(el, segment) {
if (segment.status === 'awaiting-approval') el.textContent = 'Waiting for your approval';
else if (segment.status === 'pending') el.textContent = 'Searching…';
else if (segment.status === 'resolved') el.textContent = segment.result ?? '';
else el.textContent = `Search ${segment.status}`; // rejected, aborted, failed
},
};
aparteGlobalConfig.registerToolRenderer('web_search', searchRenderer);
```
[A tool drawn by a renderer of your own](/preview/demo/tool-renderer/)
```html
```
A tool drawn by a renderer of its own, in place of the row. A renderer draws every call of one tool in place of the row, in every state: `render` when the call appears, `update` when it changes — a decision is made, its result lands — and `relabel` when the language or the icons change. Without `update`, the call is drawn again from `render` at each change. Read `segment.status`, as above: the renderer shows the wait for approval itself, and a refused or failed call carries the refusal or the error in `segment.result`. Return one element or a string of markup. An empty string returned as the call starts keeps it out of the reply; returned at any other time — a later change, a stored conversation drawn again — it brings back the default row. The arguments and the result come from the model and the tool: never write them into markup as they are. Set them as `textContent`, as above, or pass them through `escapeHtml` and `escapeAttr`. A result meant to be shown as a thing — a document, a form, a receipt — comes as the handler’s `structuredContent`, which the renderer reads as `segment.structuredResult`; the [question](/components/question/) and [artifact](/components/artifact/) plugins draw their cards so. A message clips what its parts draw outside it, so a renderer’s menu or popover goes on `document.body`, placed from its trigger’s position:
```ts
function openMenu(trigger: HTMLElement): HTMLElement {
const menu = document.createElement('div');
menu.className = 'aparte-menu';
const r = trigger.getBoundingClientRect();
menu.style.cssText = `position: fixed; left: ${r.left}px; top: ${r.bottom}px;`;
document.body.append(menu);
return menu;
}
```
## Next [Section titled “Next”](#next) * [Ask the user a question A choice, a yes or no, some text or a small form, in the middle of a run.](/guides/elicitation/) * [Run the loop on your server The same tools and approvals, with the loop and the key on your server.](/guides/server-loop/) * [Tool The row in every state, its parts and its options.](/components/tool/)
# Troubleshooting
> The failures people meet first — a bare import the browser cannot resolve, a local model the browser refuses, no provider, the key warning, missing buttons — and what an error tells you.
## `Failed to resolve module specifier "@aparte/core"` [Section titled “Failed to resolve module specifier "@aparte/core"”](#failed-to-resolve-module-specifier-apartecore) The page imports `@aparte/core` by name with no bundler in front of it, and a browser cannot resolve a bare name on its own. Use a bundler — Vite, Next, Astro, esbuild, any of them — as every snippet here assumes, or load the packages from a CDN by URL, as in [From a CDN](/guides/getting-started/#from-a-cdn). ## CORS on local BYOK (LM Studio / Ollama) [Section titled “CORS on local BYOK (LM Studio / Ollama)”](#cors-on-local-byok-lm-studio--ollama) The server’s log shows the request, and the reply ends in an error such as `Failed to fetch` (code `NET_ERROR`): the browser called the local server itself — `http://localhost:1234` for LM Studio, `http://localhost:11434` for Ollama — and refused the answer, because the server did not allow the page’s origin. * **LM Studio:** turn on **Enable CORS** in the server settings, in the Developer tab, then restart the server. * **Ollama:** start it with `OLLAMA_ORIGINS` set to the page’s origin, or `*` while you develop.
```bash
OLLAMA_ORIGINS=* ollama serve
```
```powershell
$env:OLLAMA_ORIGINS = "*"
ollama serve
```
If the Ollama app is running, quit it first, or set `OLLAMA_ORIGINS` as a user environment variable and restart the app. [Local models](/guides/local-models/) has the whole setup for both, and for llama.cpp. ## ”Provider is not registered”, “No provider selected” [Section titled “”Provider is not registered”, “No provider selected””](#provider-is-not-registered-no-provider-selected)
```
[AparteClient] Send dropped: no model is selected. …
```
```ts
import { aparteGlobalConfig, AparteClient, AparteDirectTransport } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OLLAMA));
aparteGlobalConfig.setModelConfig({ defaultProvider: 'ollama', defaultModel: 'llama3.2' });
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
new AparteClient().start();
```
Neither error appears in the chat: the person’s message gets no reply, the console warns, and `aparte-message-error` carries the code. **Provider is not registered:** the selected `defaultProvider` names an id no provider was registered under. **No provider selected:** nothing chose a model — no `setModelConfig`, and no [model selector](/components/model-selector/) has picked one; `aparteGlobalConfig.hasSelectedModel()` is `false` until one is. A provider registered alone with a single model is selected by itself; for a list fetched from the server, `` picks one. To keep the composer from sending until a model is chosen, `aparteGlobalConfig.setRequireModelSelection(true)`. A page that never calls `new AparteClient().start()` looks the same from the outside: nothing streams, because nothing answers the send. ## ”Sending the API key straight from the browser” warning [Section titled “”Sending the API key straight from the browser” warning”](#sending-the-api-key-straight-from-the-browser-warning)
```
[Aparte] AparteDirectTransport is sending the "" API key straight from the browser —
it is visible to anyone who opens devtools. …
```
`AparteDirectTransport` sent a real key from the browser, and says so once per transport. When the key is the person’s own, or the model is local, say so and the warning stops: `new AparteDirectTransport({ byok: true })`; a keyless local provider never triggers it. When the key is yours, anyone with devtools can read and spend it: send the requests through [your server](/guides/backend-transport/) instead. ## `[Unknown segment type: …]` in a message [Section titled “\[Unknown segment type: …\] in a message”](#unknown-segment-type--in-a-message) No renderer claims that segment’s type. * **A type of yours:** register a renderer for it with `registerSegmentRenderer`, or give the segment a `fallback`, a sentence drawn as text when nothing claims it. A `custom` segment’s `subType` is not a key of its own: one renderer answers every `custom` segment and reads its `subType` ([Custom segment types](/guides/customization/#custom-segment-types)). * **A built-in type** — `text`, `thinking`, `code`, `tool_call`, `error` — means the built-in renderers were declined, with `new AparteClient({ autoRegister: false })`. Drop the option, or register what you need yourself. ## The retry, edit or ⓘ buttons are not there [Section titled “The retry, edit or ⓘ buttons are not there”](#the-retry-edit-or-ⓘ-buttons-are-not-there)
```ts
import { aparteGlobalConfig } from '@aparte/core';
aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); // you run an AparteClient
aparteGlobalConfig.setBubbleActions({ feedback: true, info: true }); // you answer these events
aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); // you open it on aparte-attachment-preview
```
Only copy is on by default: the others need something outside the chat to answer them, so they stay off until you say it is there. ⓘ also needs the reply’s token usage: it shows on a reply whose provider reported usage. The bar hides while a reply streams, and a bar with no action in it is not drawn at all. [Buttons](/guides/customization/actions/) lists each one. ## The composer toolbar is not showing [Section titled “The composer toolbar is not showing”](#the-composer-toolbar-is-not-showing) An empty toolbar is hidden: it carries `data-empty` while it holds nothing visible, and whitespace or comments do not count. If you put a control in it and still see `data-empty`, either the control never arrived — a mistyped slot name, a framework that rendered nothing — or it hides itself: a child with `hidden` or `data-empty` does not count, and `` stays `data-empty` until the first reply reports its usage. A toolbar that shows but is unstyled, with your control at the start instead of the end, means the stylesheet did not load: import `@aparte/core/styles.css` ([The composer toolbar](/guides/customization/composer-toolbar/)). ## What an error tells you [Section titled “What an error tells you”](#what-an-error-tells-you)
```ts
document.querySelector('aparte-chat')?.addEventListener('aparte-message-error', (event) => {
const { error } = event.detail; // an AparteError
console.error(error.code, error.message, error.httpStatus);
});
```
A failure that reaches the chat — a refused key, a rate limit, a dropped connection — is an `AparteError`: its `message`, a `code`, the `httpStatus` when there was one, its `data` and the `originalError`. The message shows it as an [error](/components/error/), and the chat sends `aparte-message-error` with `{ messageId, error }`. A missing or unregistered provider is the exception: the send is refused before a reply exists, so nothing appears in the chat and only the event reports it. A vendor’s HTTP status sets the code: | Status | `code` | | ------------- | ---------------------- | | 400 | `USAGE_BAD_REQUEST` | | 401, 403 | `CONFIG_INVALID_KEY` | | 408 | `NET_TIMEOUT` | | 429 | `USAGE_RATE_LIMIT` | | 503 | `PROVIDER_UNAVAILABLE` | | any other 5xx | `PROVIDER_ERROR` | A network failure is `NET_ERROR`, or `NET_OFFLINE` when the browser says it is offline; a timeout is `NET_TIMEOUT`; no provider selected is `CONFIG_NO_PROVIDER`, and one not registered `CONFIG_MISSING_KEY`. Anything else is `UNKNOWN_ERROR` — except under a runner that calls the model elsewhere (`createAgentStreamRunner`, an AG-UI runner), where a failed or cut run, and a status the table does not name, is `PROVIDER_ERROR`. `AparteError.from(error)` turns any error into one by the same rules, and `AparteError.codeForStatus(status)` is the table above, for a provider of yours. To change how the error looks, use `aparteGlobalConfig.setErrorRenderer`. ## Next [Section titled “Next”](#next) * [Installation and first chat The setup these failures come from, step by step.](/guides/getting-started/) * [Docs for AI agents Give a coding agent these docs to read.](/guides/docs-for-agents/)
# Why aparté
> What aparté sets out to be, the choices it is built on, and when another library fits better.
aparté is a chat runtime that stays a library. The chat, its streaming, its tools and its loop come as web components you place in any page, with no framework, no backend and no vendor imposed — and you take as much of it as you need. ## The choices it is built on [Section titled “The choices it is built on”](#the-choices-it-is-built-on) * **Web components, with one dependency of its own.** `@aparte/core` is plain custom elements, and its only dependency is `@aparte/engine`, the agent loop, released with it. Nothing from outside has to match your stack’s versions, and the [React, Vue, Svelte and Angular packages](/frameworks/) are thin bridges over the same elements. * **The format is not the transport.** A provider knows how to talk to a model; a transport decides where the request goes and who holds the key — [the browser, or your server](/guides/backend-transport/). The chat is the same either way. * **The loop is a part, not a lock-in.** `AparteClient` drives the loop in [`@aparte/engine`](/guides/engine/); the engine also runs alone, on a server or out of the page. Or keep your own loop and [stream its replies](/guides/bring-your-own-loop/) into the chat. ## What aparté is not [Section titled “What aparté is not”](#what-aparté-is-not) No routing, no sign-in, no settings screens, no database: those belong to your product. Storage is an interface you implement — [`AparteStorageAdapter`](/guides/conversation-persistence/) — not a backend that comes with it. ## Where it stands [Section titled “Where it stands”](#where-it-stands) aparté is in beta. Every `@aparte/*` package is a `0.x`, released together, and its public surface is [frozen](/reference/stability/); what changed, release by release, is in the [changelog](/changelog/). ## When another library fits better [Section titled “When another library fits better”](#when-another-library-fits-better) You only want visual building blocks and already have a chat runtime you like; or you are all-in on one framework and want its idioms end to end rather than web components. If that changes, every package here is MIT-licensed and works on its own — core alone, the engine alone, or the whole set. [Compare](/compare/) puts it side by side with the others.